Compare commits
1 Commits
improvemen
...
feature-ge
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7962857a95 |
1
.gitignore
vendored
@@ -38,4 +38,3 @@ next-env.d.ts
|
|||||||
.env
|
.env
|
||||||
.yarn/*
|
.yarn/*
|
||||||
.history*
|
.history*
|
||||||
__ENV.js
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
. "$(dirname -- "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
yarn build
|
|
||||||
28
.vscode/launch.json
vendored
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "0.2.0",
|
|
||||||
"configurations": [
|
|
||||||
{
|
|
||||||
"name": "Next.js: debug server-side",
|
|
||||||
"type": "node-terminal",
|
|
||||||
"request": "launch",
|
|
||||||
"command": "npm run dev"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Next.js: debug client-side",
|
|
||||||
"type": "chrome",
|
|
||||||
"request": "launch",
|
|
||||||
"url": "http://localhost:3000"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Next.js: debug full stack",
|
|
||||||
"type": "node-terminal",
|
|
||||||
"request": "launch",
|
|
||||||
"command": "npm run dev",
|
|
||||||
"serverReadyAction": {
|
|
||||||
"pattern": "- Local:.+(https?://.+)",
|
|
||||||
"uriFormat": "%s",
|
|
||||||
"action": "debugWithChrome"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
33
package.json
@@ -6,86 +6,57 @@
|
|||||||
"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": {
|
||||||
"@beam-australia/react-env": "^3.1.1",
|
|
||||||
"@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",
|
||||||
"@paypal/paypal-js": "^7.1.0",
|
|
||||||
"@paypal/react-paypal-js": "^8.1.3",
|
|
||||||
"@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",
|
|
||||||
"currency-symbol-map": "^5.1.0",
|
|
||||||
"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",
|
||||||
"howler": "^2.2.4",
|
|
||||||
"iron-session": "^6.3.1",
|
"iron-session": "^6.3.1",
|
||||||
"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-currency-input-field": "^3.6.12",
|
|
||||||
"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"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/formidable": "^3.4.0",
|
"@types/formidable": "^3.4.0",
|
||||||
"@types/howler": "^2.2.11",
|
|
||||||
"@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"
|
||||||
"types/": "paypal/react-paypal-js"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 13 KiB |
BIN
public/logo.png
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 48 KiB |
@@ -7,11 +7,18 @@ interface Props {
|
|||||||
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({
|
||||||
|
isOpen,
|
||||||
|
abandonPopupTitle,
|
||||||
|
abandonPopupDescription,
|
||||||
|
abandonConfirmButtonText,
|
||||||
|
onAbandon,
|
||||||
|
onCancel,
|
||||||
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<Transition show={isOpen} as={Fragment}>
|
<Transition show={isOpen} as={Fragment}>
|
||||||
<Dialog onClose={onCancel} className="relative z-50">
|
<Dialog onClose={onCancel} className="relative z-50">
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,172 +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 {
|
|
||||||
user: User;
|
|
||||||
mutateUser: KeyedMutator<User>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DemographicInformationInput({user, mutateUser}: Props) {
|
|
||||||
const [country, setCountry] = useState<string>();
|
|
||||||
const [phone, setPhone] = useState<string>();
|
|
||||||
const [gender, setGender] = useState<Gender>();
|
|
||||||
const [employment, setEmployment] = useState<EmploymentStatus>();
|
|
||||||
const [position, setPosition] = useState<string>();
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const [companyName, setCompanyName] = useState<string>();
|
|
||||||
const [commercialRegistration, setCommercialRegistration] = useState<string>();
|
|
||||||
|
|
||||||
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: user.type === "corporate" ? undefined : employment,
|
|
||||||
position: user.type === "corporate" ? position : undefined,
|
|
||||||
},
|
|
||||||
agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined,
|
|
||||||
})
|
|
||||||
.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}>
|
|
||||||
{user.type === "agent" && (
|
|
||||||
<div className="w-full flex gap-8">
|
|
||||||
<Input type="text" onChange={setCompanyName} name="companyName" label="Corporate Name" required />
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
onChange={setCommercialRegistration}
|
|
||||||
name="commercialRegistration"
|
|
||||||
label="Commercial Registration"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<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>
|
|
||||||
{user.type === "corporate" && (
|
|
||||||
<Input name="position" onChange={setPosition} type="text" label="Position" placeholder="CEO, Head of Marketing..." required />
|
|
||||||
)}
|
|
||||||
{user.type !== "corporate" && (
|
|
||||||
<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 ||
|
|
||||||
(user.type === "corporate" ? !position : !employment) ||
|
|
||||||
(user.type === "agent" ? !companyName || !commercialRegistration : false)
|
|
||||||
}>
|
|
||||||
{!isLoading && "Save information"}
|
|
||||||
{isLoading && (
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,253 +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,
|
|
||||||
updateIndex,
|
|
||||||
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 (updateIndex) updateIndex(promptIndex);
|
|
||||||
}, [promptIndex, updateIndex]);
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
@@ -48,16 +48,7 @@ function Question({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MultipleChoice({
|
export default function MultipleChoice({id, prompt, type, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
|
||||||
id,
|
|
||||||
prompt,
|
|
||||||
type,
|
|
||||||
questions,
|
|
||||||
userSolutions,
|
|
||||||
updateIndex,
|
|
||||||
onNext,
|
|
||||||
onBack,
|
|
||||||
}: MultipleChoiceExercise & CommonProps) {
|
|
||||||
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
||||||
const [questionIndex, setQuestionIndex] = useState(0);
|
const [questionIndex, setQuestionIndex] = useState(0);
|
||||||
|
|
||||||
@@ -68,10 +59,6 @@ export default function MultipleChoice({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [hasExamEnded]);
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (updateIndex) updateIndex(questionIndex);
|
|
||||||
}, [questionIndex, updateIndex]);
|
|
||||||
|
|
||||||
const onSelectOption = (option: string) => {
|
const onSelectOption = (option: string) => {
|
||||||
const question = questions[questionIndex];
|
const question = questions[questionIndex];
|
||||||
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);
|
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);
|
||||||
@@ -79,10 +66,8 @@ export default function MultipleChoice({
|
|||||||
|
|
||||||
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};
|
||||||
};
|
};
|
||||||
@@ -105,7 +90,7 @@ export default function MultipleChoice({
|
|||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -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,10 +45,9 @@ 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}>
|
||||||
@@ -57,16 +56,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
|
||||||
)}
|
|
||||||
{prompts && prompts.length > 0 && (
|
{prompts && prompts.length > 0 && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<span className="font-bold">You should talk about the following things:</span>
|
<span className="font-bold">You should talk about the following things:</span>
|
||||||
@@ -80,7 +70,6 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<ReactMediaRecorder
|
<ReactMediaRecorder
|
||||||
audio
|
audio
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -93,8 +79,22 @@ 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 className="whitespace-pre-wrap">{prefix}</span>
|
<span>
|
||||||
<span className="font-semibold whitespace-pre-wrap">{prompt}</span>
|
{prefix.split("\\n").map((line) => (
|
||||||
|
<>
|
||||||
|
{line}
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold">
|
||||||
|
{prompt.split("\\n").map((line, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
<p>{line}</p>
|
||||||
|
<br />
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
{attachment && (
|
{attachment && (
|
||||||
<img
|
<img
|
||||||
onClick={() => setIsModalOpen(true)}
|
onClick={() => setIsModalOpen(true)}
|
||||||
@@ -106,16 +106,20 @@ export default function Writing({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full h-full flex flex-col gap-4">
|
<div className="w-full h-full flex flex-col gap-4">
|
||||||
<span className="whitespace-pre-wrap">{suffix}</span>
|
<span>
|
||||||
|
{suffix.split("\\n").map((line) => (
|
||||||
|
<>
|
||||||
|
{line}
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Exercise,
|
Exercise,
|
||||||
FillBlanksExercise,
|
FillBlanksExercise,
|
||||||
InteractiveSpeakingExercise,
|
|
||||||
MatchSentencesExercise,
|
MatchSentencesExercise,
|
||||||
MultipleChoiceExercise,
|
MultipleChoiceExercise,
|
||||||
SpeakingExercise,
|
SpeakingExercise,
|
||||||
@@ -17,54 +16,29 @@ 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});
|
||||||
|
|
||||||
export interface CommonProps {
|
export interface CommonProps {
|
||||||
updateIndex?: (internalIndex: number) => void;
|
|
||||||
onNext: (userSolutions: UserSolution) => void;
|
onNext: (userSolutions: UserSolution) => void;
|
||||||
onBack: (userSolutions: UserSolution) => void;
|
onBack: (userSolutions: UserSolution) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const renderExercise = (
|
export const renderExercise = (exercise: Exercise, onNext: (userSolutions: UserSolution) => void, onBack: (userSolutions: UserSolution) => void) => {
|
||||||
exercise: Exercise,
|
|
||||||
onNext: (userSolutions: UserSolution) => void,
|
|
||||||
onBack: (userSolutions: UserSolution) => void,
|
|
||||||
updateIndex?: (internalIndex: number) => 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 (
|
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
<MultipleChoice
|
|
||||||
key={exercise.id}
|
|
||||||
{...(exercise as MultipleChoiceExercise)}
|
|
||||||
updateIndex={updateIndex}
|
|
||||||
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)}
|
|
||||||
updateIndex={updateIndex}
|
|
||||||
onNext={onNext}
|
|
||||||
onBack={onBack}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,33 +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";
|
||||||
roundness?: "full" | "xl";
|
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
defaultValue?: string | number;
|
defaultValue?: string;
|
||||||
value?: string | number;
|
|
||||||
className?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
name: string;
|
name: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Input({
|
export default function Input({type, label, placeholder, name, required = false, defaultValue, onChange}: Props) {
|
||||||
type,
|
|
||||||
label,
|
|
||||||
placeholder,
|
|
||||||
name,
|
|
||||||
required = false,
|
|
||||||
value,
|
|
||||||
defaultValue,
|
|
||||||
className,
|
|
||||||
roundness = "full",
|
|
||||||
disabled = false,
|
|
||||||
onChange,
|
|
||||||
}: Props) {
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
if (type === "password") {
|
if (type === "password") {
|
||||||
@@ -45,7 +28,6 @@ export default function Input({
|
|||||||
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
|
||||||
@@ -60,7 +42,7 @@ export default function Input({
|
|||||||
}
|
}
|
||||||
|
|
||||||
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}
|
||||||
@@ -70,16 +52,9 @@ export default function Input({
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
name={name}
|
name={name}
|
||||||
disabled={disabled}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
min={type === "number" ? 0 : undefined}
|
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className={clsx(
|
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"
|
||||||
"px-8 py-6 text-sm font-normal bg-white border border-mti-gray-platinum focus:outline-none",
|
|
||||||
"placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed",
|
|
||||||
roundness === "full" ? "rounded-full" : "rounded-xl",
|
|
||||||
)}
|
|
||||||
required={required}
|
required={required}
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -1,149 +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>
|
|
||||||
{["admin", "developer", "agent"].includes(user.type) && (
|
|
||||||
<Link
|
|
||||||
href="/payment-record"
|
|
||||||
className={clsx(
|
|
||||||
"transition ease-in-out duration-300 w-fit",
|
|
||||||
path === "/payment-record" &&
|
|
||||||
"text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
|
||||||
)}>
|
|
||||||
Payment Record
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{["admin", "developer", "corporate", "teacher"].includes(user.type) && (
|
|
||||||
<Link
|
|
||||||
href="/settings"
|
|
||||||
className={clsx(
|
|
||||||
"transition ease-in-out duration-300 w-fit",
|
|
||||||
path === "/settings" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
|
||||||
)}>
|
|
||||||
Settings
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
</Link>
|
|
||||||
<div className="flex justify-end -md:items-center gap-4 md:w-5/6 md:mr-8">
|
|
||||||
{showExpirationDate() && (
|
|
||||||
<Link
|
|
||||||
href="/payment"
|
|
||||||
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" />
|
<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>
|
<span className="text-right">{user.name}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}>
|
|
||||||
<BsList className="text-mti-purple-light w-8 h-8" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter}/>}
|
||||||
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
|
||||||
</header>
|
</header>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
import {DurationUnit} from "@/interfaces/paypal";
|
|
||||||
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OnCancelledActions, OrderResponseBody} from "@paypal/paypal-js";
|
|
||||||
import {PayPalButtons, PayPalScriptProvider, usePayPalScriptReducer} from "@paypal/react-paypal-js";
|
|
||||||
import axios from "axios";
|
|
||||||
import {useEffect, useState} from "react";
|
|
||||||
import {toast} from "react-toastify";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
clientID: string;
|
|
||||||
currency: string;
|
|
||||||
price: number;
|
|
||||||
duration: number;
|
|
||||||
duration_unit: DurationUnit;
|
|
||||||
setIsLoading: (isLoading: boolean) => void;
|
|
||||||
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PayPalPayment({clientID, price, currency, duration, duration_unit, setIsLoading, onSuccess}: Props) {
|
|
||||||
const createOrder = async (data: CreateOrderData, actions: CreateOrderActions): Promise<string> => {
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
return axios
|
|
||||||
.post<OrderResponseBody>("/api/paypal", {currencyCode: currency, price})
|
|
||||||
.then((response) => response.data)
|
|
||||||
.then((data) => data.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onApprove = async (data: OnApproveData, actions: OnApproveActions) => {
|
|
||||||
const request = await axios.post<{ok: boolean; reason?: string}>("/api/paypal/approve", {id: data.orderID, duration, duration_unit});
|
|
||||||
|
|
||||||
if (request.status !== 200) {
|
|
||||||
toast.error("Something went wrong, please try again later");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success("Your account has been credited more time!");
|
|
||||||
return onSuccess(duration, duration_unit);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onError = async (data: Record<string, unknown>) => {
|
|
||||||
setIsLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCancel = async (data: Record<string, unknown>, actions: OnCancelledActions) => {
|
|
||||||
setIsLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PayPalScriptProvider
|
|
||||||
options={{
|
|
||||||
clientId: clientID,
|
|
||||||
currency,
|
|
||||||
intent: "capture",
|
|
||||||
commit: true,
|
|
||||||
vault: true,
|
|
||||||
}}>
|
|
||||||
<PayPalButtons
|
|
||||||
className="w-full"
|
|
||||||
style={{layout: "vertical"}}
|
|
||||||
createOrder={createOrder}
|
|
||||||
onApprove={onApprove}
|
|
||||||
onCancel={onCancel}
|
|
||||||
onError={onError}></PayPalButtons>
|
|
||||||
</PayPalScriptProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
31
src/components/ProfileCard.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import {User} from "@/interfaces/user";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import LevelLabel from "./LevelLabel";
|
||||||
|
import LevelProgressBar from "./LevelProgressBar";
|
||||||
|
import {Avatar} from "primereact/avatar";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
className: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfileCard({user, className}: Props) {
|
||||||
|
return (
|
||||||
|
<div className={clsx("bg-white drop-shadow-xl p-4 md:p-8 rounded-xl w-full flex flex-col gap-6", className)}>
|
||||||
|
<div className="flex w-full items-center gap-8">
|
||||||
|
<div className="w-16 md:w-24 h-16 md:h-24 rounded-full border-2 md:border-4 border-white drop-shadow-md md:drop-shadow-xl">
|
||||||
|
{user.profilePicture.length > 0 && <img src={user.profilePicture} alt="Profile picture" className="rounded-full object-cover" />}
|
||||||
|
{user.profilePicture.length === 0 && (
|
||||||
|
<Avatar size="xlarge" style={{width: "100%", height: "100%"}} label={user.name.slice(0, 1)} shape="circle" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col justify-center">
|
||||||
|
<span className="text-neutral-600 font-bold text-xl lg:text-2xl">{user.name}</span>
|
||||||
|
<LevelLabel experience={user.experience} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<LevelProgressBar experience={user.experience} progressBarWidth="w-32 md:w-96" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/components/ProfileLevel.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import {User} from "@/interfaces/user";
|
||||||
|
import {levelCalculator} from "@/resources/level";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import LevelLabel from "./LevelLabel";
|
||||||
|
import LevelProgressBar from "./LevelProgressBar";
|
||||||
|
import {Avatar} from "primereact/avatar";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfileLevel({user, className}: Props) {
|
||||||
|
const levelResult = levelCalculator(user.experience);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx("flex flex-col items-center justify-center gap-4", className)}>
|
||||||
|
<div className="w-16 md:w-24 h-16 md:h-24 rounded-full">
|
||||||
|
{user.profilePicture.length > 0 && <img src={user.profilePicture} alt="Profile picture" className="rounded-full object-cover" />}
|
||||||
|
{user.profilePicture.length === 0 && (
|
||||||
|
<Avatar size="xlarge" style={{width: "100%", height: "100%"}} label={user.name.slice(0, 1)} shape="circle" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 items-center">
|
||||||
|
<LevelLabel experience={user.experience} />
|
||||||
|
<LevelProgressBar experience={user.experience} className="text-black" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
|
||||||
import {User} from "@/interfaces/user";
|
|
||||||
import {USER_TYPE_LABELS} from "@/resources/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">{USER_TYPE_LABELS[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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,35 +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 {
|
import {BsFileEarmarkText, BsClockHistory, BsPencil, BsGraphUp, BsShield} from "react-icons/bs";
|
||||||
BsFileEarmarkText,
|
|
||||||
BsClockHistory,
|
|
||||||
BsPencil,
|
|
||||||
BsGraphUp,
|
|
||||||
BsChevronBarRight,
|
|
||||||
BsChevronBarLeft,
|
|
||||||
BsShieldFill,
|
|
||||||
BsCloudFill,
|
|
||||||
BsCurrencyDollar,
|
|
||||||
} 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 {
|
||||||
@@ -38,135 +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} />
|
|
||||||
{["admin", "developer", "agent"].includes(userType || "") && (
|
|
||||||
<Nav
|
|
||||||
disabled={disableNavigation}
|
|
||||||
Icon={BsCurrencyDollar}
|
|
||||||
label="Payment Record"
|
|
||||||
path={path}
|
|
||||||
keyPath="/payment-record"
|
|
||||||
isMinimized={isMinimized}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{["admin", "developer", "corporate", "teacher"].includes(userType || "") && (
|
|
||||||
<Nav
|
|
||||||
disabled={disableNavigation}
|
|
||||||
Icon={BsShieldFill}
|
|
||||||
label="Settings"
|
|
||||||
path={path}
|
|
||||||
keyPath="/settings"
|
|
||||||
isMinimized={isMinimized}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{userType === "developer" && (
|
|
||||||
<Nav
|
|
||||||
disabled={disableNavigation}
|
|
||||||
Icon={BsCloudFill}
|
|
||||||
label="Generation"
|
|
||||||
path={path}
|
|
||||||
keyPath="/generation"
|
|
||||||
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="Settings" path={path} keyPath="/settings" isMinimized={true} />
|
|
||||||
)}
|
|
||||||
{userType === "developer" && (
|
|
||||||
<Nav disabled={disableNavigation} Icon={BsCloudFill} label="Generation" path={path} keyPath="/generation" isMinimized={true} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-0 absolute bottom-12">
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabIndex={1}
|
|
||||||
onClick={toggleMinimize}
|
|
||||||
className={clsx(
|
|
||||||
"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",
|
|
||||||
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
|
||||||
)}>
|
|
||||||
{isMinimized ? <BsChevronBarRight size={24} /> : <BsChevronBarLeft size={24} />}
|
|
||||||
{!isMinimized && <span className="text-lg font-medium">Minimize</span>}
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={1}
|
tabIndex={1}
|
||||||
onClick={focusMode ? () => {} : logout}
|
onClick={focusMode ? () => {} : logout}
|
||||||
className={clsx(
|
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",
|
"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",
|
||||||
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
"absolute bottom-8",
|
||||||
)}>
|
)}>
|
||||||
<RiLogoutBoxFill size={24} />
|
<RiLogoutBoxFill size={20} />
|
||||||
{!isMinimized && <span className="text-lg font-medium -xl:hidden">Log Out</span>}
|
<span className="text-lg font-medium">Log Out</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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,8 +83,7 @@ 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 />
|
||||||
|
|||||||
@@ -1,198 +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";
|
|
||||||
import {Tab} from "@headlessui/react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
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-1 md:grid-cols-2 lg: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="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
|
||||||
{solutionsURL.map((x, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="w-full 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 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
|
||||||
<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>
|
|
||||||
{userSolutions[0].evaluation &&
|
|
||||||
Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length === 3 ? (
|
|
||||||
<Tab.Group>
|
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
|
||||||
<Tab
|
|
||||||
className={({selected}) =>
|
|
||||||
clsx(
|
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking 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-ielts-speaking",
|
|
||||||
)
|
|
||||||
}>
|
|
||||||
Evaluation
|
|
||||||
</Tab>
|
|
||||||
<Tab
|
|
||||||
className={({selected}) =>
|
|
||||||
clsx(
|
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking 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-ielts-speaking",
|
|
||||||
)
|
|
||||||
}>
|
|
||||||
Recommended Answer (Prompt 1)
|
|
||||||
</Tab>
|
|
||||||
<Tab
|
|
||||||
className={({selected}) =>
|
|
||||||
clsx(
|
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking 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-ielts-speaking",
|
|
||||||
)
|
|
||||||
}>
|
|
||||||
Recommended Answer (Prompt 2)
|
|
||||||
</Tab>
|
|
||||||
<Tab
|
|
||||||
className={({selected}) =>
|
|
||||||
clsx(
|
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking 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-ielts-speaking",
|
|
||||||
)
|
|
||||||
}>
|
|
||||||
Recommended Answer (Prompt 3)
|
|
||||||
</Tab>
|
|
||||||
</Tab.List>
|
|
||||||
<Tab.Panels>
|
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
|
||||||
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
|
||||||
</Tab.Panel>
|
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
|
||||||
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
|
||||||
{userSolutions[0].evaluation!.perfect_answer_1!.replaceAll(/\s{2,}/g, "\n\n")}
|
|
||||||
</span>
|
|
||||||
</Tab.Panel>
|
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
|
||||||
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
|
||||||
{userSolutions[0].evaluation!.perfect_answer_2!.replaceAll(/\s{2,}/g, "\n\n")}
|
|
||||||
</span>
|
|
||||||
</Tab.Panel>
|
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
|
||||||
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
|
||||||
{userSolutions[0].evaluation!.perfect_answer_3!.replaceAll(/\s{2,}/g, "\n\n")}
|
|
||||||
</span>
|
|
||||||
</Tab.Panel>
|
|
||||||
</Tab.Panels>
|
|
||||||
</Tab.Group>
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-ielts-speaking/10 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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,8 +72,7 @@ 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}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
|
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useEffect, useState} from "react";
|
import {useState} from "react";
|
||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
|
|
||||||
@@ -54,32 +54,17 @@ function Question({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MultipleChoice({
|
export default function MultipleChoice({id, type, prompt, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
|
||||||
id,
|
|
||||||
type,
|
|
||||||
prompt,
|
|
||||||
questions,
|
|
||||||
userSolutions,
|
|
||||||
updateIndex,
|
|
||||||
onNext,
|
|
||||||
onBack,
|
|
||||||
}: MultipleChoiceExercise & CommonProps) {
|
|
||||||
const [questionIndex, setQuestionIndex] = useState(0);
|
const [questionIndex, setQuestionIndex] = useState(0);
|
||||||
|
|
||||||
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};
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (updateIndex) updateIndex(questionIndex);
|
|
||||||
}, [questionIndex, updateIndex]);
|
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
if (questionIndex === questions.length - 1) {
|
if (questionIndex === questions.length - 1) {
|
||||||
onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
|
onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
|
||||||
@@ -101,7 +86,7 @@ export default function MultipleChoice({
|
|||||||
<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}
|
||||||
|
|||||||
@@ -5,33 +5,27 @@ 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";
|
|
||||||
import {Tab} from "@headlessui/react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
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}>
|
||||||
@@ -40,17 +34,7 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
|
||||||
)}
|
|
||||||
{prompts && prompts.length > 0 && (
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<span className="font-bold">You should talk about the following things:</span>
|
<span className="font-bold">You should talk about the following things:</span>
|
||||||
<div className="flex flex-col gap-1 ml-4">
|
<div className="flex flex-col gap-1 ml-4">
|
||||||
@@ -61,8 +45,6 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full h-full flex flex-col gap-8">
|
<div className="w-full h-full flex flex-col gap-8">
|
||||||
@@ -71,7 +53,7 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
{solutionURL && <Waveform audio={solutionURL} waveColor="#FCDDEC" progressColor="#EF5DA8" />}
|
{solutionURL && <Waveform audio={solutionURL} waveColor="#FCDDEC" progressColor="#EF5DA8" />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
{userSolutions && userSolutions.length > 0 && (
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<div className="flex gap-4 px-1">
|
<div className="flex gap-4 px-1">
|
||||||
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
|
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
|
||||||
@@ -80,48 +62,9 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{userSolutions[0].evaluation && userSolutions[0].evaluation.perfect_answer ? (
|
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-mti-gray-smoke rounded-3xl">
|
||||||
<Tab.Group>
|
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
|
||||||
<Tab
|
|
||||||
className={({selected}) =>
|
|
||||||
clsx(
|
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking 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-ielts-speaking",
|
|
||||||
)
|
|
||||||
}>
|
|
||||||
Evaluation
|
|
||||||
</Tab>
|
|
||||||
<Tab
|
|
||||||
className={({selected}) =>
|
|
||||||
clsx(
|
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking 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-ielts-speaking",
|
|
||||||
)
|
|
||||||
}>
|
|
||||||
Recommended Answer
|
|
||||||
</Tab>
|
|
||||||
</Tab.List>
|
|
||||||
<Tab.Panels>
|
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
|
||||||
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
|
||||||
</Tab.Panel>
|
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
|
||||||
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
|
||||||
{userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n")}
|
|
||||||
</span>
|
|
||||||
</Tab.Panel>
|
|
||||||
</Tab.Panels>
|
|
||||||
</Tab.Group>
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-ielts-speaking/10 rounded-3xl">
|
|
||||||
{userSolutions[0].evaluation!.comment}
|
{userSolutions[0].evaluation!.comment}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -135,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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -148,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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,12 +62,11 @@ 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>
|
||||||
@@ -87,9 +84,7 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
|||||||
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"
|
className="!py-2"
|
||||||
color={getButtonColor("not_given", question.solution, userSolution?.solution)}>
|
color={getButtonColor("not_given", question.solution, userSolution?.solution)}>
|
||||||
Not Given
|
Not Given
|
||||||
|
|||||||
@@ -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,8 +118,7 @@ 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 />
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
||||||
import {WritingExercise} from "@/interfaces/exam";
|
import {WritingExercise} from "@/interfaces/exam";
|
||||||
import {CommonProps} from ".";
|
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
||||||
import {Fragment, useState} from "react";
|
import Icon from "@mdi/react";
|
||||||
import Button from "../Low/Button";
|
|
||||||
import {Dialog, Tab, Transition} from "@headlessui/react";
|
|
||||||
import {writingReverseMarking} from "@/utils/score";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import {CommonProps} from ".";
|
||||||
|
import {Fragment, useEffect, useState} from "react";
|
||||||
|
import {toast} from "react-toastify";
|
||||||
|
import Button from "../Low/Button";
|
||||||
|
import {Dialog, Transition} from "@headlessui/react";
|
||||||
|
|
||||||
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);
|
||||||
@@ -75,7 +78,7 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
{userSolutions && userSolutions.length > 0 && (
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<div className="flex gap-4 px-1">
|
<div className="flex gap-4 px-1">
|
||||||
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
|
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
|
||||||
@@ -84,48 +87,9 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{userSolutions[0].evaluation && userSolutions[0].evaluation.perfect_answer ? (
|
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-mti-gray-smoke rounded-3xl">
|
||||||
<Tab.Group>
|
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1">
|
|
||||||
<Tab
|
|
||||||
className={({selected}) =>
|
|
||||||
clsx(
|
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
|
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing 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-ielts-writing",
|
|
||||||
)
|
|
||||||
}>
|
|
||||||
Evaluation
|
|
||||||
</Tab>
|
|
||||||
<Tab
|
|
||||||
className={({selected}) =>
|
|
||||||
clsx(
|
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
|
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing 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-ielts-writing",
|
|
||||||
)
|
|
||||||
}>
|
|
||||||
Recommended Answer
|
|
||||||
</Tab>
|
|
||||||
</Tab.List>
|
|
||||||
<Tab.Panels>
|
|
||||||
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
|
||||||
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
|
||||||
</Tab.Panel>
|
|
||||||
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
|
||||||
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
|
||||||
{userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n")}
|
|
||||||
</span>
|
|
||||||
</Tab.Panel>
|
|
||||||
</Tab.Panels>
|
|
||||||
</Tab.Group>
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-ielts-writing/10 rounded-3xl">
|
|
||||||
{userSolutions[0].evaluation!.comment}
|
{userSolutions[0].evaluation!.comment}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -135,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>
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -22,12 +20,11 @@ import Writing from "./Writing";
|
|||||||
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), {ssr: false});
|
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), {ssr: false});
|
||||||
|
|
||||||
export interface CommonProps {
|
export interface CommonProps {
|
||||||
updateIndex?: (internalIndex: number) => void;
|
|
||||||
onNext: (userSolutions: UserSolution) => void;
|
onNext: (userSolutions: UserSolution) => void;
|
||||||
onBack: (userSolutions: UserSolution) => void;
|
onBack: (userSolutions: UserSolution) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: () => void, updateIndex?: (internalIndex: number) => void) => {
|
export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: () => void) => {
|
||||||
switch (exercise.type) {
|
switch (exercise.type) {
|
||||||
case "fillBlanks":
|
case "fillBlanks":
|
||||||
return <FillBlanks {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
return <FillBlanks {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
@@ -36,14 +33,12 @@ export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: (
|
|||||||
case "matchSentences":
|
case "matchSentences":
|
||||||
return <MatchSentences {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
return <MatchSentences {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "multipleChoice":
|
case "multipleChoice":
|
||||||
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} updateIndex={updateIndex} onNext={onNext} onBack={onBack} />;
|
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "writeBlanks":
|
case "writeBlanks":
|
||||||
return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "writing":
|
case "writing":
|
||||||
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} />;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,489 +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 {useEffect, 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";
|
|
||||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
|
||||||
import {CURRENCIES} from "@/resources/paypal";
|
|
||||||
|
|
||||||
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;
|
|
||||||
onViewCorporate?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers, onViewCorporate}: Props) => {
|
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null | undefined>(user.subscriptionExpirationDate);
|
|
||||||
const [type, setType] = useState(user.type);
|
|
||||||
const [status, setStatus] = useState(user.status);
|
|
||||||
const [referralAgentLabel, setReferralAgentLabel] = useState<string>();
|
|
||||||
const [position, setPosition] = useState<string | undefined>(user.type === "corporate" ? user.demographicInformation?.position : undefined);
|
|
||||||
|
|
||||||
const [referralAgent, setReferralAgent] = useState(user.type === "corporate" ? user.corporateInformation?.referralAgent : undefined);
|
|
||||||
const [companyName, setCompanyName] = useState(
|
|
||||||
user.type === "corporate"
|
|
||||||
? user.corporateInformation?.companyInformation.name
|
|
||||||
: user.type === "agent"
|
|
||||||
? user.agentInformation.companyName
|
|
||||||
: undefined,
|
|
||||||
);
|
|
||||||
const [commercialRegistration, setCommercialRegistration] = useState(
|
|
||||||
user.type === "agent" ? user.agentInformation.commercialRegistration : undefined,
|
|
||||||
);
|
|
||||||
const [userAmount, setUserAmount] = useState(user.type === "corporate" ? user.corporateInformation?.companyInformation.userAmount : undefined);
|
|
||||||
const [paymentValue, setPaymentValue] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.value : undefined);
|
|
||||||
const [paymentCurrency, setPaymentCurrency] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.currency : "EUR");
|
|
||||||
const [monthlyDuration, setMonthlyDuration] = useState(user.type === "corporate" ? user.corporateInformation?.monthlyDuration : undefined);
|
|
||||||
|
|
||||||
const {stats} = useStats(user.id);
|
|
||||||
const {users} = useUsers();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (users && users.length > 0) {
|
|
||||||
if (!referralAgent) {
|
|
||||||
setReferralAgentLabel("No manager");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const agent = users.find((x) => x.id === referralAgent);
|
|
||||||
setReferralAgentLabel(`${agent?.name} - ${agent?.email}`);
|
|
||||||
}
|
|
||||||
}, [users, referralAgent]);
|
|
||||||
|
|
||||||
const updateUser = () => {
|
|
||||||
if (user.type === "corporate" && (!paymentValue || paymentValue < 0))
|
|
||||||
return toast.error("Please set a price for the user's package before updating!");
|
|
||||||
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,
|
|
||||||
agentInformation:
|
|
||||||
type === "agent"
|
|
||||||
? {
|
|
||||||
companyName,
|
|
||||||
commercialRegistration,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
corporateInformation:
|
|
||||||
type === "corporate"
|
|
||||||
? {
|
|
||||||
referralAgent,
|
|
||||||
monthlyDuration,
|
|
||||||
companyInformation: {
|
|
||||||
companyName,
|
|
||||||
userAmount,
|
|
||||||
},
|
|
||||||
payment: {
|
|
||||||
value: paymentValue,
|
|
||||||
currency: paymentCurrency,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
})
|
|
||||||
.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",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{user.type === "agent" && (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full">
|
|
||||||
<Input
|
|
||||||
label="Corporate Name"
|
|
||||||
type="text"
|
|
||||||
name="companyName"
|
|
||||||
onChange={setCompanyName}
|
|
||||||
placeholder="Enter corporate name"
|
|
||||||
defaultValue={companyName}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Commercial Registration"
|
|
||||||
type="text"
|
|
||||||
name="commercialRegistration"
|
|
||||||
onChange={setCommercialRegistration}
|
|
||||||
placeholder="Enter commercial registration"
|
|
||||||
defaultValue={commercialRegistration}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Divider className="w-full !m-0" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{user.type === "corporate" && (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 w-full">
|
|
||||||
<Input
|
|
||||||
label="Corporate Name"
|
|
||||||
type="text"
|
|
||||||
name="companyName"
|
|
||||||
onChange={setCompanyName}
|
|
||||||
placeholder="Enter corporate name"
|
|
||||||
defaultValue={companyName}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Number of Users"
|
|
||||||
type="number"
|
|
||||||
name="userAmount"
|
|
||||||
onChange={(e) => setUserAmount(e ? parseInt(e) : undefined)}
|
|
||||||
placeholder="Enter number of users"
|
|
||||||
defaultValue={userAmount}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Monthly Duration"
|
|
||||||
type="number"
|
|
||||||
name="monthlyDuration"
|
|
||||||
onChange={(e) => setMonthlyDuration(e ? parseInt(e) : undefined)}
|
|
||||||
placeholder="Enter monthly duration"
|
|
||||||
defaultValue={monthlyDuration}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 w-full">
|
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Country Manager</label>
|
|
||||||
{referralAgentLabel && (
|
|
||||||
<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: referralAgentLabel,
|
|
||||||
}}
|
|
||||||
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 lg:col-span-2">
|
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Pricing</label>
|
|
||||||
<div className="w-full grid grid-cols-5 gap-2">
|
|
||||||
<Input
|
|
||||||
name="paymentValue"
|
|
||||||
onChange={(e) => setPaymentValue(e ? parseInt(e) : undefined)}
|
|
||||||
type="number"
|
|
||||||
defaultValue={paymentValue || 0}
|
|
||||||
className="col-span-3"
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
defaultValue={paymentCurrency}
|
|
||||||
onChange={(e) => setPaymentCurrency(e.target.value)}
|
|
||||||
className="p-6 col-span-2 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
|
||||||
{CURRENCIES.map(({label, currency}) => (
|
|
||||||
<option value={currency} key={currency}>
|
|
||||||
{label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Divider className="w-full !m-0" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<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">
|
|
||||||
{user.type !== "corporate" && (
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
{user.type === "corporate" && (
|
|
||||||
<Input
|
|
||||||
name="position"
|
|
||||||
onChange={setPosition}
|
|
||||||
type="text"
|
|
||||||
label="Position"
|
|
||||||
defaultValue={position}
|
|
||||||
placeholder="CEO, Head of Marketing..."
|
|
||||||
disabled
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<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() : null)}>
|
|
||||||
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 === "admin") && (
|
|
||||||
<>
|
|
||||||
<Divider className="w-full !m-0" />
|
|
||||||
<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">
|
|
||||||
{Object.keys(USER_TYPE_LABELS).map((type) => (
|
|
||||||
<option key={type} value={type}>
|
|
||||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</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">
|
|
||||||
{onViewCorporate && (
|
|
||||||
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewCorporate}>
|
|
||||||
View Corporate
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{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;
|
|
||||||
31
src/components/UserResultChart.tsx
Normal 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}}}} />;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import {Type} from "@/interfaces/user";
|
|
||||||
|
|
||||||
export const PERMISSIONS = {
|
|
||||||
generateCode: {
|
|
||||||
student: ["corporate", "developer", "admin"],
|
|
||||||
teacher: ["corporate", "developer", "admin"],
|
|
||||||
corporate: ["admin", "developer"],
|
|
||||||
admin: ["developer", "admin"],
|
|
||||||
agent: ["developer", "admin"],
|
|
||||||
developer: ["developer"],
|
|
||||||
},
|
|
||||||
deleteUser: {
|
|
||||||
student: ["teacher", "corporate", "developer", "admin"],
|
|
||||||
teacher: ["corporate", "developer", "admin"],
|
|
||||||
corporate: ["admin", "developer"],
|
|
||||||
admin: ["developer", "admin"],
|
|
||||||
agent: ["developer", "admin"],
|
|
||||||
developer: ["developer"],
|
|
||||||
},
|
|
||||||
updateUser: {
|
|
||||||
student: ["teacher", "corporate", "developer", "admin"],
|
|
||||||
teacher: ["corporate", "developer", "admin"],
|
|
||||||
corporate: ["admin", "developer"],
|
|
||||||
admin: ["developer", "admin"],
|
|
||||||
agent: ["developer", "admin"],
|
|
||||||
developer: ["developer"],
|
|
||||||
},
|
|
||||||
updateExpiryDate: {
|
|
||||||
student: ["developer", "admin"],
|
|
||||||
teacher: ["developer", "admin"],
|
|
||||||
corporate: ["admin", "developer"],
|
|
||||||
admin: ["developer", "admin"],
|
|
||||||
agent: ["developer", "admin"],
|
|
||||||
developer: ["developer"],
|
|
||||||
},
|
|
||||||
examManagement: {
|
|
||||||
delete: ["developer", "admin"],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,488 +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,
|
|
||||||
BsBriefcaseFill,
|
|
||||||
BsGlobeCentralSouthAsia,
|
|
||||||
BsPerson,
|
|
||||||
BsPersonFill,
|
|
||||||
BsPencilSquare,
|
|
||||||
BsBank,
|
|
||||||
} from "react-icons/bs";
|
|
||||||
import UserCard from "@/components/UserCard";
|
|
||||||
import useGroups from "@/hooks/useGroups";
|
|
||||||
import IconCard from "./IconCard";
|
|
||||||
import useFilterStore from "@/stores/listFilterStore";
|
|
||||||
import {useRouter} from "next/router";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
user: User;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminDashboard({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();
|
|
||||||
|
|
||||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setShowModal(!!selectedUser && page === "");
|
|
||||||
}, [selectedUser, page]);
|
|
||||||
|
|
||||||
const inactiveCountryManagerFilter = (x: User) => x.type === "agent" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
|
||||||
|
|
||||||
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.type === "corporate"
|
|
||||||
? displayUser.corporateInformation?.companyInformation?.name || displayUser.name
|
|
||||||
: 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)
|
|
||||||
: 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} filters={[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} filters={[filter]} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AgentsList = () => {
|
|
||||||
const filter = (x: User) => x.type === "agent";
|
|
||||||
|
|
||||||
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">Country Managers ({users.filter(filter).length})</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UserList user={user} filters={[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} filters={[(x) => x.type === "corporate"]} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
const InactiveCountryManagerList = () => {
|
|
||||||
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 Country Managers ({users.filter(inactiveCountryManagerFilter).length})</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UserList user={user} filters={[inactiveCountryManagerFilter]} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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} filters={[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} filters={[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={BsPencilSquare}
|
|
||||||
label="Teachers"
|
|
||||||
value={users.filter((x) => x.type === "teacher").length}
|
|
||||||
onClick={() => setPage("teachers")}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
Icon={BsBank}
|
|
||||||
label="Corporate"
|
|
||||||
value={users.filter((x) => x.type === "corporate").length}
|
|
||||||
onClick={() => setPage("corporate")}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
Icon={BsBriefcaseFill}
|
|
||||||
label="Country Managers"
|
|
||||||
value={users.filter((x) => x.type === "agent").length}
|
|
||||||
onClick={() => setPage("agents")}
|
|
||||||
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("inactiveCountryManagers")}
|
|
||||||
Icon={BsPerson}
|
|
||||||
label="Inactive Country Managers"
|
|
||||||
value={users.filter(inactiveCountryManagerFilter).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">Country Manager 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 Country Manager</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"
|
|
||||||
? () => {
|
|
||||||
appendUserFilters({
|
|
||||||
id: "view-students",
|
|
||||||
filter: (x: User) => x.type === "student",
|
|
||||||
});
|
|
||||||
appendUserFilters({
|
|
||||||
id: "belongs-to-admin",
|
|
||||||
filter: (x: User) =>
|
|
||||||
groups
|
|
||||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
|
||||||
.flatMap((g) => g.participants)
|
|
||||||
.includes(x.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push("/list/users");
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onViewTeachers={
|
|
||||||
selectedUser.type === "corporate" || selectedUser.type === "student"
|
|
||||||
? () => {
|
|
||||||
appendUserFilters({
|
|
||||||
id: "view-teachers",
|
|
||||||
filter: (x: User) => x.type === "teacher",
|
|
||||||
});
|
|
||||||
appendUserFilters({
|
|
||||||
id: "belongs-to-admin",
|
|
||||||
filter: (x: User) =>
|
|
||||||
groups
|
|
||||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
|
||||||
.flatMap((g) => g.participants)
|
|
||||||
.includes(x.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push("/list/users");
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onViewCorporate={
|
|
||||||
selectedUser.type === "teacher" || selectedUser.type === "student"
|
|
||||||
? () => {
|
|
||||||
appendUserFilters({
|
|
||||||
id: "view-corporate",
|
|
||||||
filter: (x: User) => x.type === "corporate",
|
|
||||||
});
|
|
||||||
appendUserFilters({
|
|
||||||
id: "belongs-to-admin",
|
|
||||||
filter: (x: User) =>
|
|
||||||
groups
|
|
||||||
.filter((g) => g.participants.includes(selectedUser.id))
|
|
||||||
.flatMap((g) => [g.admin, ...g.participants])
|
|
||||||
.includes(x.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push("/list/users");
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
user={selectedUser}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
</Modal>
|
|
||||||
{page === "students" && <StudentsList />}
|
|
||||||
{page === "teachers" && <TeachersList />}
|
|
||||||
{page === "corporate" && <CorporateList />}
|
|
||||||
{page === "agents" && <AgentsList />}
|
|
||||||
{page === "inactiveStudents" && <InactiveStudentsList />}
|
|
||||||
{page === "inactiveCorporate" && <InactiveCorporateList />}
|
|
||||||
{page === "inactiveCountryManagers" && <InactiveCountryManagerList />}
|
|
||||||
{page === "" && <DefaultDashboard />}
|
|
||||||
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,214 +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,
|
|
||||||
BsPersonFill,
|
|
||||||
BsBank
|
|
||||||
} 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 AgentDashboard({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 corporateFilter = (user: User) => user.type === "corporate";
|
|
||||||
const referredCorporateFilter = (x: User) =>
|
|
||||||
x.type === "corporate" && !!x.corporateInformation && x.corporateInformation.referralAgent === user.id;
|
|
||||||
const inactiveReferredCorporateFilter = (x: User) => referredCorporateFilter(x) && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
|
||||||
|
|
||||||
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.type === "corporate"
|
|
||||||
? displayUser.corporateInformation?.companyInformation?.name || displayUser.name
|
|
||||||
: displayUser.name}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm opacity-75">{displayUser.email}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const ReferredCorporateList = () => {
|
|
||||||
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">Referred Corporate ({users.filter(referredCorporateFilter).length})</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UserList user={user} filters={[referredCorporateFilter]} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const InactiveReferredCorporateList = () => {
|
|
||||||
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 Referred Corporate ({users.filter(inactiveReferredCorporateFilter).length})</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UserList user={user} filters={[inactiveReferredCorporateFilter]} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const CorporateList = () => {
|
|
||||||
const filter = (x: User) => x.type === "corporate";
|
|
||||||
|
|
||||||
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">Referred Corporate ({users.filter(filter).length})</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UserList user={user} filters={[filter]} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const DefaultDashboard = () => (
|
|
||||||
<>
|
|
||||||
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:gap-4 text-center">
|
|
||||||
<IconCard
|
|
||||||
onClick={() => setPage("referredCorporate")}
|
|
||||||
Icon={BsPersonFill}
|
|
||||||
label="Referred Corporate"
|
|
||||||
value={users.filter(referredCorporateFilter).length}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
onClick={() => setPage("inactiveReferredCorporate")}
|
|
||||||
Icon={BsPersonFill}
|
|
||||||
label="Inactive Referred Corporate"
|
|
||||||
value={users.filter(inactiveReferredCorporateFilter).length}
|
|
||||||
color="rose"
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
onClick={() => setPage("corporate")}
|
|
||||||
Icon={BsBank}
|
|
||||||
label="Corporate"
|
|
||||||
value={users.filter(corporateFilter).length}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
</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 Referred Corporate</span>
|
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
|
||||||
{users
|
|
||||||
.filter(referredCorporateFilter)
|
|
||||||
.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(corporateFilter)
|
|
||||||
.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">Referenced corporate expiring in 1 month</span>
|
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
|
||||||
{users
|
|
||||||
.filter(
|
|
||||||
(x) =>
|
|
||||||
referredCorporateFilter(x) &&
|
|
||||||
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
|
|
||||||
moment().isBefore(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 === "referredCorporate" && <ReferredCorporateList />}
|
|
||||||
{page === "corporate" && <CorporateList />}
|
|
||||||
{page === "inactiveReferredCorporate" && <InactiveReferredCorporateList />}
|
|
||||||
{page === "" && <DefaultDashboard />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,318 +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";
|
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
|
||||||
|
|
||||||
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());
|
|
||||||
// creates a new exam for each assignee or just one exam for all assignees
|
|
||||||
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
(assignment ? axios.patch : axios.post)(
|
|
||||||
`/api/assignments${assignment ? `/${assignment.id}` : ""}`,
|
|
||||||
{
|
|
||||||
assignees,
|
|
||||||
name,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
selectedModules,
|
|
||||||
generateMultiple,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.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));
|
|
||||||
};
|
|
||||||
|
|
||||||
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">
|
|
||||||
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple(d => !d)}>
|
|
||||||
Generate different exams
|
|
||||||
</Checkbox>
|
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,315 +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,
|
|
||||||
BsPencilSquare,
|
|
||||||
} 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 useFilterStore from "@/stores/listFilterStore";
|
|
||||||
import {useRouter} from "next/router";
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
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} filters={[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} filters={[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={BsPencilSquare}
|
|
||||||
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"
|
|
||||||
? () => {
|
|
||||||
appendUserFilters({
|
|
||||||
id: "view-students",
|
|
||||||
filter: (x: User) => x.type === "student",
|
|
||||||
});
|
|
||||||
appendUserFilters({
|
|
||||||
id: "belongs-to-admin",
|
|
||||||
filter: (x: User) =>
|
|
||||||
groups
|
|
||||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
|
||||||
.flatMap((g) => g.participants)
|
|
||||||
.includes(x.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push("/list/users");
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onViewTeachers={
|
|
||||||
selectedUser.type === "corporate" || selectedUser.type === "student"
|
|
||||||
? () => {
|
|
||||||
appendUserFilters({
|
|
||||||
id: "view-teachers",
|
|
||||||
filter: (x: User) => x.type === "teacher",
|
|
||||||
});
|
|
||||||
appendUserFilters({
|
|
||||||
id: "belongs-to-admin",
|
|
||||||
filter: (x: User) =>
|
|
||||||
groups
|
|
||||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
|
||||||
.flatMap((g) => g.participants)
|
|
||||||
.includes(x.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push("/list/users");
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
user={selectedUser}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
</Modal>
|
|
||||||
{page === "students" && <StudentsList />}
|
|
||||||
{page === "teachers" && <TeachersList />}
|
|
||||||
{page === "groups" && <GroupsList />}
|
|
||||||
{page === "" && <DefaultDashboard />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
import Button from "@/components/Low/Button";
|
|
||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
|
||||||
import PayPalPayment from "@/components/PayPalPayment";
|
|
||||||
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 {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js";
|
|
||||||
import {PayPalButtons} from "@paypal/react-paypal-js";
|
|
||||||
import axios from "axios";
|
|
||||||
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";
|
|
||||||
import {toast} from "react-toastify";
|
|
||||||
|
|
||||||
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.filter((e) => e.assignee === user.id).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
|
|
||||||
.filter((e) => e.assignee === user.id)
|
|
||||||
.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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,340 +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,
|
|
||||||
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} filters={[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 />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 48 KiB |
@@ -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>
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"type": "student",
|
|
||||||
"code": "123a"
|
|
||||||
}
|
|
||||||
@@ -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 didn’t ask to verify this address, you can ignore this email.</p>
|
|
||||||
<br />
|
|
||||||
<p>Thanks,</p>
|
|
||||||
<p>Your EnCoach team</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Tiago Ribeiro",
|
|
||||||
"email": "tiago.ribeiro@ecrop.dev",
|
|
||||||
"code": "123"
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,102 +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 [questionIndex, setQuestionIndex] = useState(0);
|
|
||||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentQuestionIndex(0);
|
|
||||||
}, [questionIndex]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasExamEnded && exerciseIndex === -1) {
|
|
||||||
setExerciseIndex((prev) => prev + 1);
|
|
||||||
}
|
|
||||||
}, [hasExamEnded, exerciseIndex]);
|
|
||||||
|
|
||||||
const nextExercise = (solution?: UserSolution) => {
|
|
||||||
if (solution) {
|
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
|
||||||
}
|
|
||||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
|
||||||
|
|
||||||
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 + questionIndex + currentQuestionIndex}
|
|
||||||
module="level"
|
|
||||||
totalExercises={countExercises(exam.exercises)}
|
|
||||||
disableTimer={showSolutions}
|
|
||||||
/>
|
|
||||||
{exerciseIndex > -1 &&
|
|
||||||
exerciseIndex < exam.exercises.length &&
|
|
||||||
!showSolutions &&
|
|
||||||
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
|
|
||||||
{exerciseIndex > -1 &&
|
|
||||||
exerciseIndex < exam.exercises.length &&
|
|
||||||
showSolutions &&
|
|
||||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,14 +20,9 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Listening({exam, showSolutions = false, onFinish}: Props) {
|
export default function Listening({exam, showSolutions = false, onFinish}: Props) {
|
||||||
const [questionIndex, setQuestionIndex] = useState(0);
|
const [exerciseIndex, setExerciseIndex] = useState(-1);
|
||||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
|
||||||
const [exerciseIndex, setExerciseIndex] = useState(showSolutions ? 0 : -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]);
|
||||||
@@ -35,10 +33,6 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
|||||||
}
|
}
|
||||||
}, [hasExamEnded, exerciseIndex]);
|
}, [hasExamEnded, exerciseIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentQuestionIndex(0);
|
|
||||||
}, [questionIndex]);
|
|
||||||
|
|
||||||
const confirmFinishModule = (keepGoing?: boolean) => {
|
const confirmFinishModule = (keepGoing?: boolean) => {
|
||||||
if (!keepGoing) {
|
if (!keepGoing) {
|
||||||
setShowBlankModal(false);
|
setShowBlankModal(false);
|
||||||
@@ -52,19 +46,12 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
|||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
}
|
}
|
||||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
|
||||||
|
|
||||||
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(
|
||||||
@@ -97,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 || [],
|
||||||
@@ -109,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>
|
||||||
@@ -132,49 +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) +
|
|
||||||
questionIndex +
|
|
||||||
currentQuestionIndex
|
|
||||||
}
|
|
||||||
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, setCurrentQuestionIndex)}
|
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, setCurrentQuestionIndex)}
|
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
|
||||||
|
|||||||
@@ -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,36 +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 [questionIndex, setQuestionIndex] = useState(0);
|
const [exerciseIndex, setExerciseIndex] = useState(-1);
|
||||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
|
||||||
const [exerciseIndex, setExerciseIndex] = useState(showSolutions ? 0 : -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(() => {
|
|
||||||
setCurrentQuestionIndex(0);
|
|
||||||
}, [questionIndex]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded && exerciseIndex === -1) {
|
if (hasExamEnded && exerciseIndex === -1) {
|
||||||
setExerciseIndex((prev) => prev + 1);
|
setExerciseIndex((prev) => prev + 1);
|
||||||
@@ -130,19 +106,12 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
}
|
}
|
||||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
|
||||||
|
|
||||||
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(
|
||||||
@@ -175,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 || [],
|
||||||
@@ -183,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've read.
|
Please read the following excerpt attentively, you will then be asked questions about the text you've read.
|
||||||
@@ -191,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>
|
||||||
@@ -206,36 +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) +
|
|
||||||
questionIndex +
|
|
||||||
currentQuestionIndex
|
|
||||||
}
|
|
||||||
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.parts[partIndex].exercises.length &&
|
exerciseIndex < exam.exercises.length &&
|
||||||
!showSolutions &&
|
!showSolutions &&
|
||||||
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
|
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, setCurrentQuestionIndex)}
|
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||||
</div>
|
{exerciseIndex > -1 && exerciseIndex < exam.exercises.length && (
|
||||||
{exerciseIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
|
|
||||||
<Button
|
<Button
|
||||||
color="purple"
|
color="purple"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -245,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>
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <BsPen className="text-ielts-writing w-6 h-6 md:w-8 md:h-8" />,
|
|
||||||
label: "Writing",
|
|
||||||
value: totalExamsByModule(stats, "writing"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <BsMegaphone className="text-ielts-speaking w-6 h-6 md:w-8 md:h-8" />,
|
|
||||||
label: "Speaking",
|
|
||||||
value: totalExamsByModule(stats, "speaking"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <BsClipboard className="text-ielts-level w-6 h-6 md:w-8 md:h-8" />,
|
|
||||||
label: "Level",
|
|
||||||
value: totalExamsByModule(stats, "level"),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
|
<ProgressBar
|
||||||
|
label=""
|
||||||
|
percentage={Math.round((calculateAverageLevel(user.levels) * 100) / calculateAverageLevel(user.desiredLevels))}
|
||||||
|
color="red"
|
||||||
|
className="w-full h-3 drop-shadow-lg"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between w-full mt-8">
|
||||||
|
<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" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<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'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'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'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,73 +173,24 @@ export default function Selection({user, page, onStart, disableSelection = false
|
|||||||
<p className="text-center text-xs">
|
<p className="text-center text-xs">
|
||||||
You'll have access to interactive dialogs, pronunciation exercises and speech recordings.
|
You'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'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">
|
|
||||||
<div
|
|
||||||
className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer tooltip w-full -md:justify-center"
|
|
||||||
data-tip="If possible, the platform will choose exams not yet done"
|
|
||||||
onClick={() => setAvoidRepeatedExams((prev) => !prev)}>
|
|
||||||
<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",
|
|
||||||
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
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onStart(
|
onStart(!disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"])
|
||||||
!disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"],
|
|
||||||
avoidRepeatedExams,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
color="purple"
|
color="purple"
|
||||||
className="px-12 w-full max-w-xs md:self-end -md:hidden"
|
className="px-12 w-full max-w-xs self-end"
|
||||||
disabled={selectedModules.length === 0 && !disableSelection}>
|
disabled={selectedModules.length === 0 && !disableSelection}>
|
||||||
Start Exam
|
Start Exam
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -20,21 +18,14 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Speaking({exam, showSolutions = false, onFinish}: Props) {
|
export default function Speaking({exam, showSolutions = false, onFinish}: Props) {
|
||||||
const [questionIndex, setQuestionIndex] = useState(0);
|
|
||||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
|
||||||
const [exerciseIndex, setExerciseIndex] = useState(0);
|
const [exerciseIndex, setExerciseIndex] = useState(0);
|
||||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
|
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
|
||||||
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
|
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentQuestionIndex(0);
|
|
||||||
}, [questionIndex]);
|
|
||||||
|
|
||||||
const nextExercise = (solution?: UserSolution) => {
|
const nextExercise = (solution?: UserSolution) => {
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
}
|
}
|
||||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.exercises.length) {
|
if (exerciseIndex + 1 < exam.exercises.length) {
|
||||||
setExerciseIndex((prev) => prev + 1);
|
setExerciseIndex((prev) => prev + 1);
|
||||||
@@ -76,21 +67,20 @@ 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 + questionIndex + currentQuestionIndex}
|
exerciseIndex={exerciseIndex + 1}
|
||||||
module="speaking"
|
module="speaking"
|
||||||
totalExercises={countExercises(exam.exercises)}
|
totalExercises={exam.exercises.length}
|
||||||
disableTimer={showSolutions}
|
disableTimer={showSolutions}
|
||||||
/>
|
/>
|
||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
exerciseIndex < exam.exercises.length &&
|
exerciseIndex < exam.exercises.length &&
|
||||||
!showSolutions &&
|
!showSolutions &&
|
||||||
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
|
renderExercise(getExercise(), nextExercise, previousExercise)}
|
||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
exerciseIndex < exam.exercises.length &&
|
exerciseIndex < exam.exercises.length &&
|
||||||
showSolutions &&
|
showSolutions &&
|
||||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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 || "",
|
||||||
@@ -10,12 +8,7 @@ const firebaseConfig = {
|
|||||||
storageBucket: process.env.FIREBASE_STORAGE_BUCKET || "",
|
storageBucket: process.env.FIREBASE_STORAGE_BUCKET || "",
|
||||||
messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID || "",
|
messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID || "",
|
||||||
appId: process.env.FIREBASE_APP_ID || "",
|
appId: process.env.FIREBASE_APP_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(),
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -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};
|
|
||||||
}
|
|
||||||
@@ -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};
|
|
||||||
}
|
|
||||||
@@ -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};
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import {Exam} from "@/interfaces/exam";
|
|
||||||
import {Package} from "@/interfaces/paypal";
|
|
||||||
import axios from "axios";
|
|
||||||
import {useEffect, useState} from "react";
|
|
||||||
|
|
||||||
export default function usePackages() {
|
|
||||||
const [packages, setPackages] = useState<Package[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [isError, setIsError] = useState(false);
|
|
||||||
|
|
||||||
const getData = () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
axios
|
|
||||||
.get<Package[]>("/api/packages")
|
|
||||||
.then((response) => setPackages(response.data))
|
|
||||||
.finally(() => setIsLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(getData, []);
|
|
||||||
|
|
||||||
return {packages, isLoading, isError, reload: getData};
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import {Payment} from "@/interfaces/paypal";
|
|
||||||
import {Group, User} from "@/interfaces/user";
|
|
||||||
import axios from "axios";
|
|
||||||
import {useEffect, useState} from "react";
|
|
||||||
|
|
||||||
export default function usePayments() {
|
|
||||||
const [payments, setPayments] = useState<Payment[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [isError, setIsError] = useState(false);
|
|
||||||
|
|
||||||
const getData = () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
axios
|
|
||||||
.get<Payment[]>("/api/payments")
|
|
||||||
.then((response) => {
|
|
||||||
return setPayments(response.data);
|
|
||||||
})
|
|
||||||
.finally(() => setIsLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(getData, []);
|
|
||||||
|
|
||||||
return {payments, isLoading, isError, reload: getData};
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,30 @@
|
|||||||
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: ReadingPart[];
|
text: {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
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 ReadingPart {
|
|
||||||
text: {
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
};
|
|
||||||
exercises: Exercise[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LevelExam {
|
|
||||||
module: "level";
|
|
||||||
id: string;
|
|
||||||
exercises: Exercise[];
|
|
||||||
minTimer: number;
|
|
||||||
isDiagnostic: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ListeningExam {
|
export interface ListeningExam {
|
||||||
parts: ListeningPart[];
|
|
||||||
id: string;
|
|
||||||
module: "listening";
|
|
||||||
minTimer: number;
|
|
||||||
isDiagnostic: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ListeningPart {
|
|
||||||
audio: {
|
audio: {
|
||||||
source: string;
|
source: string;
|
||||||
repeatableTimes: number; // *The amount of times the user is allowed to repeat the audio, 0 for unlimited
|
repeatableTimes: number; // *The amount of times the user is allowed to repeat the audio, 0 for unlimited
|
||||||
};
|
};
|
||||||
|
id: string;
|
||||||
exercises: Exercise[];
|
exercises: Exercise[];
|
||||||
|
module: "listening";
|
||||||
|
minTimer: number;
|
||||||
|
isDiagnostic: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserSolution {
|
export interface UserSolution {
|
||||||
@@ -84,25 +68,13 @@ export type Exercise =
|
|||||||
| MultipleChoiceExercise
|
| MultipleChoiceExercise
|
||||||
| WriteBlanksExercise
|
| WriteBlanksExercise
|
||||||
| WritingExercise
|
| WritingExercise
|
||||||
| SpeakingExercise
|
| SpeakingExercise;
|
||||||
| InteractiveSpeakingExercise;
|
|
||||||
|
|
||||||
export interface Evaluation {
|
export interface Evaluation {
|
||||||
comment: string;
|
comment: string;
|
||||||
overall: number;
|
overall: number;
|
||||||
task_response: {[key: string]: number};
|
task_response: {[key: string]: number};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InteractiveSpeakingEvaluation extends Evaluation {
|
|
||||||
perfect_answer_1?: string;
|
|
||||||
perfect_answer_2?: string;
|
|
||||||
perfect_answer_3?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CommonEvaluation extends Evaluation {
|
|
||||||
perfect_answer?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WritingExercise {
|
export interface WritingExercise {
|
||||||
id: string;
|
id: string;
|
||||||
type: "writing";
|
type: "writing";
|
||||||
@@ -117,7 +89,7 @@ export interface WritingExercise {
|
|||||||
userSolutions: {
|
userSolutions: {
|
||||||
id: string;
|
id: string;
|
||||||
solution: string;
|
solution: string;
|
||||||
evaluation?: CommonEvaluation;
|
evaluation?: Evaluation;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,24 +99,10 @@ 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;
|
||||||
evaluation?: CommonEvaluation;
|
evaluation?: Evaluation;
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
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?: InteractiveSpeakingEvaluation;
|
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export type Module = "reading" | "listening" | "writing" | "speaking" | "level";
|
export type Module = "reading" | "listening" | "writing" | "speaking";
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
export interface TokenSuccess {
|
|
||||||
scope: string;
|
|
||||||
access_token: string;
|
|
||||||
token_type: string;
|
|
||||||
app_id: string;
|
|
||||||
expires_in: number;
|
|
||||||
nonce: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TokenError {
|
|
||||||
error: string;
|
|
||||||
error_description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Package {
|
|
||||||
id: string;
|
|
||||||
currency: string;
|
|
||||||
duration: number;
|
|
||||||
duration_unit: DurationUnit;
|
|
||||||
price: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DurationUnit = "weeks" | "days" | "months" | "years";
|
|
||||||
|
|
||||||
export interface Payment {
|
|
||||||
id: string;
|
|
||||||
corporate: string;
|
|
||||||
agent?: string;
|
|
||||||
agentCommission: number;
|
|
||||||
agentValue: number;
|
|
||||||
currency: string;
|
|
||||||
value: number;
|
|
||||||
isPaid: boolean;
|
|
||||||
date: Date;
|
|
||||||
}
|
|
||||||
@@ -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, assignee: string}[];
|
|
||||||
startDate: Date;
|
|
||||||
endDate: Date;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,101 +1,19 @@
|
|||||||
import {Module} from ".";
|
import {Module} from ".";
|
||||||
|
|
||||||
export type User = StudentUser | TeacherUser | CorporateUser | AgentUser | AdminUser | DeveloperUser;
|
export interface User {
|
||||||
|
|
||||||
export interface BasicUser {
|
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
profilePicture: string;
|
profilePicture: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
experience: number;
|
||||||
isFirstLogin: boolean;
|
isFirstLogin: boolean;
|
||||||
focus: "academic" | "general";
|
focus: "academic" | "general";
|
||||||
levels: {[key in Module]: number};
|
levels: {[key in Module]: number};
|
||||||
desiredLevels: {[key in Module]: number};
|
desiredLevels: {[key in Module]: number};
|
||||||
type: Type;
|
type: Type;
|
||||||
bio: string;
|
bio: string;
|
||||||
isVerified: boolean;
|
|
||||||
subscriptionExpirationDate?: null | Date;
|
|
||||||
registrationDate?: Date;
|
|
||||||
status: "active" | "disabled" | "paymentDue";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StudentUser extends BasicUser {
|
|
||||||
type: "student";
|
|
||||||
demographicInformation?: DemographicInformation;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TeacherUser extends BasicUser {
|
|
||||||
type: "teacher";
|
|
||||||
demographicInformation?: DemographicInformation;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CorporateUser extends BasicUser {
|
|
||||||
type: "corporate";
|
|
||||||
corporateInformation: CorporateInformation;
|
|
||||||
demographicInformation?: DemographicCorporateInformation;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentUser extends BasicUser {
|
|
||||||
type: "agent";
|
|
||||||
agentInformation: AgentInformation;
|
|
||||||
demographicInformation?: DemographicInformation;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminUser extends BasicUser {
|
|
||||||
type: "admin";
|
|
||||||
demographicInformation?: DemographicInformation;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeveloperUser extends BasicUser {
|
|
||||||
type: "developer";
|
|
||||||
demographicInformation?: DemographicInformation;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CorporateInformation {
|
|
||||||
companyInformation: CompanyInformation;
|
|
||||||
monthlyDuration: number;
|
|
||||||
payment?: {
|
|
||||||
value: number;
|
|
||||||
currency: string;
|
|
||||||
};
|
|
||||||
referralAgent?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentInformation {
|
|
||||||
companyName: string;
|
|
||||||
commercialRegistration: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CompanyInformation {
|
|
||||||
name: string;
|
|
||||||
userAmount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DemographicInformation {
|
|
||||||
country: string;
|
|
||||||
phone: string;
|
|
||||||
gender: Gender;
|
|
||||||
employment: EmploymentStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DemographicCorporateInformation {
|
|
||||||
country: string;
|
|
||||||
phone: string;
|
|
||||||
gender: Gender;
|
|
||||||
position: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
||||||
@@ -105,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;
|
||||||
@@ -114,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" | "admin" | "developer" | "agent";
|
|
||||||
export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent"];
|
|
||||||
|
|||||||
@@ -14,6 +14,5 @@ export const sessionOptions: IronSessionOptions = {
|
|||||||
declare module "iron-session" {
|
declare module "iron-session" {
|
||||||
interface IronSessionData {
|
interface IronSessionData {
|
||||||
user?: User | null;
|
user?: User | null;
|
||||||
envVariables?: {[key: string]: string};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
import Button from "@/components/Low/Button";
|
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
|
||||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
|
||||||
import useUsers from "@/hooks/useUsers";
|
|
||||||
import {Type, User} from "@/interfaces/user";
|
|
||||||
import {USER_TYPE_LABELS} from "@/resources/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 [type, setType] = useState<Type>("student");
|
|
||||||
|
|
||||||
const {users} = useUsers();
|
|
||||||
|
|
||||||
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")
|
|
||||||
.map((x) => x.trim())
|
|
||||||
.filter((x) => new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/).test(x))
|
|
||||||
.filter((x) => !users.map((u) => u.email).includes(x));
|
|
||||||
|
|
||||||
if (emails.length === 0) {
|
|
||||||
toast.error("Please upload a .txt file containing e-mails, one per line! All already registered e-mails have also been ignored!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEmails([...new Set(emails)]);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [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 === "admin") && (
|
|
||||||
<>
|
|
||||||
<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 && (
|
|
||||||
<select
|
|
||||||
defaultValue="student"
|
|
||||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
|
||||||
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
|
||||||
{Object.keys(USER_TYPE_LABELS).map((type) => (
|
|
||||||
<option key={type} value={type}>
|
|
||||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
<Button onClick={() => generateCode(type)} disabled={emails.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}>
|
|
||||||
Generate & Send
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,115 +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 {USER_TYPE_LABELS} from "@/resources/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);
|
|
||||||
const [type, setType] = useState<Type>("student");
|
|
||||||
|
|
||||||
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 && (
|
|
||||||
<select
|
|
||||||
defaultValue="student"
|
|
||||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
|
||||||
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
|
||||||
{Object.keys(USER_TYPE_LABELS).map((type) => (
|
|
||||||
<option key={type} value={type}>
|
|
||||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
{user && (user.type === "developer" || user.type === "admin") && (
|
|
||||||
<>
|
|
||||||
<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)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Button onClick={() => generateCode(type)} disabled={isExpiryDateEnabled ? !expiryDate : false}>
|
|
||||||
Generate
|
|
||||||
</Button>
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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="p-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" key={cell.id}>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 === "admin" || 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 === "admin" || 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>
|
|
||||||
</>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,561 +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";
|
|
||||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
|
||||||
import useFilterStore from "@/stores/listFilterStore";
|
|
||||||
import {useRouter} from "next/router";
|
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<User>();
|
|
||||||
|
|
||||||
export default function UserList({user, filters = []}: {user: User; filters?: ((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?.type === "corporate" || user?.type === "teacher") ? user.id : undefined);
|
|
||||||
|
|
||||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
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 === "teacher"
|
|
||||||
? users.filter((u) => groups.flatMap((g) => g.participants).includes(u.id))
|
|
||||||
: users;
|
|
||||||
|
|
||||||
const filteredUsers = filters.reduce((d, f) => d.filter(f), 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)}>
|
|
||||||
Corporate
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => updateAccountType(row.original, "admin")}
|
|
||||||
className="text-sm !py-2 !px-4"
|
|
||||||
disabled={row.original.type === "admin" || !PERMISSIONS.generateCode["admin"].includes(user.type)}>
|
|
||||||
Admin
|
|
||||||
</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((x) => (x.type === "corporate" ? x.demographicInformation?.position : x.demographicInformation?.employment), {
|
|
||||||
id: "employment",
|
|
||||||
header: (
|
|
||||||
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "employment"))}>
|
|
||||||
<span>Employment/Position</span>
|
|
||||||
<SorterArrow name="employment" />
|
|
||||||
</button>
|
|
||||||
) as any,
|
|
||||||
cell: (info) => (info.row.original.type === "corporate" ? info.getValue() : 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)}>
|
|
||||||
{row.original.type === "corporate" ? row.original.corporateInformation?.companyInformation?.name || getValue() : 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) => USER_TYPE_LABELS[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")) {
|
|
||||||
const aSortingItem = a.type === "corporate" ? a.demographicInformation?.position : a.demographicInformation?.employment;
|
|
||||||
const bSortingItem = b.type === "corporate" ? b.demographicInformation?.position : b.demographicInformation?.employment;
|
|
||||||
|
|
||||||
if (!aSortingItem && bSortingItem) return sorter === "employment" ? -1 : 1;
|
|
||||||
if (aSortingItem && !bSortingItem) return sorter === "employment" ? 1 : -1;
|
|
||||||
if (!aSortingItem && !bSortingItem) return 0;
|
|
||||||
|
|
||||||
return sorter === "employment" ? aSortingItem!.localeCompare(bSortingItem!) : bSortingItem!.localeCompare(aSortingItem!);
|
|
||||||
}
|
|
||||||
|
|
||||||
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}
|
|
||||||
onViewStudents={
|
|
||||||
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
|
||||||
? () => {
|
|
||||||
appendUserFilters({
|
|
||||||
id: "view-students",
|
|
||||||
filter: (x: User) => x.type === "student",
|
|
||||||
});
|
|
||||||
appendUserFilters({
|
|
||||||
id: "belongs-to-admin",
|
|
||||||
filter: (x: User) =>
|
|
||||||
groups
|
|
||||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
|
||||||
.flatMap((g) => g.participants)
|
|
||||||
.includes(x.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push("/list/users");
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onViewTeachers={
|
|
||||||
selectedUser.type === "corporate" || selectedUser.type === "student"
|
|
||||||
? () => {
|
|
||||||
appendUserFilters({
|
|
||||||
id: "view-teachers",
|
|
||||||
filter: (x: User) => x.type === "teacher",
|
|
||||||
});
|
|
||||||
appendUserFilters({
|
|
||||||
id: "belongs-to-admin",
|
|
||||||
filter: (x: User) =>
|
|
||||||
groups
|
|
||||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
|
||||||
.flatMap((g) => g.participants)
|
|
||||||
.includes(x.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push("/list/users");
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onViewCorporate={
|
|
||||||
selectedUser.type === "teacher" || selectedUser.type === "student"
|
|
||||||
? () => {
|
|
||||||
appendUserFilters({
|
|
||||||
id: "view-corporate",
|
|
||||||
filter: (x: User) => x.type === "corporate",
|
|
||||||
});
|
|
||||||
appendUserFilters({
|
|
||||||
id: "belongs-to-admin",
|
|
||||||
filter: (x: User) =>
|
|
||||||
groups
|
|
||||||
.filter((g) => g.participants.includes(selectedUser.id))
|
|
||||||
.flatMap((g) => [g.admin, ...g.participants])
|
|
||||||
.includes(x.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push("/list/users");
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||