Compare commits
296 Commits
bug-fixing
...
feature/EN
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a65b72adad | ||
|
|
e13aea9f7d | ||
|
|
2920fa7f3a | ||
|
|
7af96ecccc | ||
|
|
70716b3483 | ||
|
|
d7bb64e7e0 | ||
|
|
dd19b5746c | ||
|
|
f967282f71 | ||
|
|
8b2459c304 | ||
|
|
72fb934d4f | ||
|
|
ed0b8bcb99 | ||
|
|
6f211d8435 | ||
|
|
b59589b855 | ||
|
|
db20feaa00 | ||
|
|
8fc2cf571e | ||
|
|
3128fea8c9 | ||
|
|
0e53b4a454 | ||
|
|
cbb61d18fe | ||
|
|
dff51cf6ea | ||
|
|
15dbadcc53 | ||
|
|
624a3fb88e | ||
|
|
00feee2179 | ||
|
|
0f8f9bc05b | ||
|
|
f76b7578a6 | ||
|
|
1a17689cd2 | ||
|
|
a958e2ff0d | ||
|
|
36b861266f | ||
|
|
771262fc18 | ||
|
|
0f03ce95e7 | ||
|
|
6a6e010daa | ||
|
|
13496387c4 | ||
|
|
4ecb21e0ae | ||
|
|
8663fe13bd | ||
|
|
de4638bc46 | ||
|
|
c9740fe8ee | ||
|
|
9b9b67c6cd | ||
|
|
fe2abaacae | ||
|
|
11e2ea3249 | ||
|
|
2de4b7c715 | ||
|
|
a8ffebe944 | ||
|
|
9ab7c3ed59 | ||
|
|
f374d91ef8 | ||
|
|
62ecc4e395 | ||
|
|
46764cacfa | ||
|
|
0b9e1bd734 | ||
|
|
bddb2ed18e | ||
|
|
e8fbeff77a | ||
|
|
b64593df90 | ||
|
|
2657cb409c | ||
|
|
329ed573b3 | ||
|
|
bb7558afb8 | ||
|
|
259ed03ee4 | ||
|
|
bf6c805487 | ||
|
|
1086e78936 | ||
|
|
7d0d930140 | ||
|
|
f02fff55e7 | ||
|
|
08e71c4dd8 | ||
|
|
6f5a74844c | ||
|
|
c4011cd456 | ||
|
|
5ef2568aa5 | ||
|
|
6d817e6d27 | ||
|
|
5decfb098d | ||
|
|
c2b6be4425 | ||
|
|
f320fee416 | ||
|
|
445e486cd2 | ||
|
|
ee26b50cf6 | ||
|
|
22f2b43692 | ||
|
|
29b2c8b3b8 | ||
|
|
51cc1e3f36 | ||
|
|
d9fce10538 | ||
|
|
bd74313bd5 | ||
|
|
18df890ef9 | ||
|
|
13ebb9bbd8 | ||
|
|
38c0c823e1 | ||
|
|
b50e15d1d9 | ||
|
|
969698d8b8 | ||
|
|
7d83ebc5c5 | ||
|
|
e99650ecd8 | ||
|
|
7287a9ce9a | ||
|
|
8cc7e6a57d | ||
|
|
0a24cb9978 | ||
|
|
a5c1286748 | ||
|
|
06684a4900 | ||
|
|
1823538058 | ||
|
|
60ccc822b5 | ||
|
|
9abd69c5e5 | ||
|
|
2667891bdd | ||
|
|
65485a0d1f | ||
|
|
74dd96d000 | ||
|
|
49ee3c45e5 | ||
|
|
49d2680a07 | ||
|
|
9dac7fd19e | ||
|
|
528299571c | ||
|
|
dcc630b8e5 | ||
|
|
be5125e5b0 | ||
|
|
0adf45c6ad | ||
|
|
d9b93a3470 | ||
|
|
83e4173750 | ||
|
|
e2d5f6ac9d | ||
|
|
37c3c6f7f4 | ||
|
|
3b4dfb9648 | ||
|
|
330c177ff9 | ||
|
|
0cff310354 | ||
|
|
87a1d7c288 | ||
|
|
8e1fe15a24 | ||
|
|
c95c0eff9b | ||
|
|
eaf94f458a | ||
|
|
ba85596e79 | ||
|
|
c6a478c406 | ||
|
|
2a27fbd02f | ||
|
|
a86ed9f76c | ||
|
|
20b52d049d | ||
|
|
165e33b188 | ||
|
|
04cbcbc4cb | ||
|
|
2feb9223c1 | ||
|
|
02d2d07f6c | ||
|
|
ecd66d61f2 | ||
|
|
424b72efaf | ||
|
|
79e51d6294 | ||
|
|
773480875f | ||
|
|
96d1b85f56 | ||
|
|
cf20920fd8 | ||
|
|
4114971244 | ||
|
|
bee20388d9 | ||
|
|
bd97529658 | ||
|
|
d3c24d738c | ||
|
|
eac43a160d | ||
|
|
24c3f506c6 | ||
|
|
3e13ed5830 | ||
|
|
9b5ff70037 | ||
|
|
d7f1a4f6b2 | ||
|
|
b663e5c706 | ||
|
|
efb99b31f2 | ||
|
|
03882d2a7e | ||
|
|
a3087567ea | ||
|
|
e37afd5bbc | ||
|
|
cf5e827ca7 | ||
|
|
bfa9d039e2 | ||
|
|
62b915fbc1 | ||
|
|
cdfafb3eea | ||
|
|
29cae5c3d2 | ||
|
|
04f97b62c3 | ||
|
|
52d309e7f4 | ||
|
|
dbf5b17f64 | ||
|
|
703fb0df5f | ||
|
|
be4d2de76f | ||
|
|
44c61c2e5d | ||
|
|
764064bc28 | ||
|
|
d87de9fea9 | ||
|
|
b63ba3f316 | ||
|
|
64b1d9266e | ||
|
|
b7cd1fb141 | ||
|
|
30cb2f460c | ||
|
|
6a38b7a32e | ||
|
|
2a1b5236ee | ||
|
|
a99f6fd20e | ||
|
|
c0c9d22864 | ||
|
|
718782cfd5 | ||
|
|
f643430068 | ||
|
|
2823af7ef8 | ||
|
|
57116f50e8 | ||
|
|
e382a09ae8 | ||
|
|
b4c7c9a911 | ||
|
|
86e920f102 | ||
|
|
6f12a4a1db | ||
|
|
a27a3c1fb0 | ||
|
|
63618405bc | ||
|
|
7ab67fdf15 | ||
|
|
17ec004a59 | ||
|
|
417bd7fecb | ||
|
|
e82895351d | ||
|
|
4802310474 | ||
|
|
dc3373be6a | ||
|
|
2e894622d0 | ||
|
|
1895b9e183 | ||
|
|
03f78ceb46 | ||
|
|
872cc62fe4 | ||
|
|
ce7032c8a7 | ||
|
|
71f07af2eb | ||
|
|
89250fb98e | ||
|
|
b09fe79cb7 | ||
|
|
870ed57166 | ||
|
|
2a9e204041 | ||
|
|
00f6aaf058 | ||
|
|
044a4f91aa | ||
|
|
65fe1ec8ed | ||
|
|
779fb76b8b | ||
|
|
4ec439492e | ||
|
|
c4b61c4787 | ||
|
|
934394b17f | ||
|
|
8baa25c445 | ||
|
|
f6166ca9e1 | ||
|
|
e6017854fd | ||
|
|
0bd8b0ab24 | ||
|
|
401d212d85 | ||
|
|
9383929ebb | ||
|
|
5dcab23fdb | ||
|
|
d111be2f70 | ||
|
|
00c171b161 | ||
|
|
53d3f843da | ||
|
|
8d7f312a83 | ||
|
|
6f11818876 | ||
|
|
81bc4e7a0c | ||
|
|
48265a8e54 | ||
|
|
0053105dd3 | ||
|
|
846d829d10 | ||
|
|
c0c3e37568 | ||
|
|
a872190e1b | ||
|
|
147a450be2 | ||
|
|
908ce5b5b9 | ||
|
|
0ec62c107c | ||
|
|
626655d0d0 | ||
|
|
16eeba76fd | ||
|
|
85729116e7 | ||
|
|
2de9636c8b | ||
|
|
bcad5b5646 | ||
|
|
4e40dc9c8c | ||
|
|
e3bcaf6b30 | ||
|
|
a35c85545e | ||
|
|
c4707d6426 | ||
|
|
3564d0af6b | ||
|
|
e7acdb5858 | ||
|
|
8bff64dd13 | ||
|
|
2c4168a014 | ||
|
|
800d04da37 | ||
|
|
b7b2718387 | ||
|
|
a862e59574 | ||
|
|
688d8ba0b2 | ||
|
|
8b7e550a70 | ||
|
|
cf1cb6f270 | ||
|
|
476a6b0188 | ||
|
|
01e55f970d | ||
|
|
bca73dff2e | ||
|
|
aef3800c08 | ||
|
|
a40c21ca53 | ||
|
|
34b1c7f25b | ||
|
|
7c641508ce | ||
|
|
4163076524 | ||
|
|
009c610033 | ||
|
|
c05df7d6b7 | ||
|
|
b881969bd4 | ||
|
|
5e6af11156 | ||
|
|
c1162c5e88 | ||
|
|
213bdd0c8f | ||
|
|
13401562fb | ||
|
|
4e199931aa | ||
|
|
3eafc799ab | ||
|
|
9b87764afb | ||
|
|
a969e90c98 | ||
|
|
c38c1d9ff6 | ||
|
|
bcacbbdd15 | ||
|
|
fa481dc50e | ||
|
|
710c7931aa | ||
|
|
d3f80603c4 | ||
|
|
fea2d311ae | ||
|
|
5f475fb7a7 | ||
|
|
bd0fab4c8f | ||
|
|
74d3f30c93 | ||
|
|
67c2e06575 | ||
|
|
506ee1e0e4 | ||
|
|
81943dbf42 | ||
|
|
c868ea8795 | ||
|
|
cfde8ac9f0 | ||
|
|
8c1da3a84a | ||
|
|
52143d2472 | ||
|
|
c7f303e410 | ||
|
|
da93b79c78 | ||
|
|
83b8ab7774 | ||
|
|
f6bb69f994 | ||
|
|
a97c40dc47 | ||
|
|
3de0357369 | ||
|
|
8eb8a7af46 | ||
|
|
9773f1da72 | ||
|
|
2ef86344cd | ||
|
|
5e8b6f96bb | ||
|
|
b757cbbed7 | ||
|
|
4e08afb259 | ||
|
|
68069d118f | ||
|
|
74dcccf089 | ||
|
|
b7ae9fb837 | ||
|
|
63d2baf35f | ||
|
|
c02a6a01f4 | ||
|
|
a646955493 | ||
|
|
7a577a7ca2 | ||
|
|
c26ff48b60 | ||
|
|
9ee09c8fda | ||
|
|
d4867fd9a2 | ||
|
|
13e52bfce6 | ||
|
|
5540e4a3e6 | ||
|
|
a18ee93909 | ||
|
|
0641d4250c | ||
|
|
f85a1f5601 | ||
|
|
01a9da3a5b | ||
|
|
9baf3109c9 | ||
|
|
8aca34e8b5 | ||
|
|
aaaf7f646d |
@@ -1,4 +1,5 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const websiteUrl = process.env.NODE_ENV === 'production' ? "https://encoach.com" : "http://localhost:3000";
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
output: "standalone",
|
||||
@@ -8,7 +9,7 @@ const nextConfig = {
|
||||
source: "/api/packages",
|
||||
headers: [
|
||||
{key: "Access-Control-Allow-Credentials", value: "false"},
|
||||
{key: "Access-Control-Allow-Origin", value: "https://encoach.com"},
|
||||
{key: "Access-Control-Allow-Origin", value: websiteUrl},
|
||||
{
|
||||
key: "Access-Control-Allow-Methods",
|
||||
value: "GET",
|
||||
@@ -19,6 +20,36 @@ const nextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/api/tickets",
|
||||
headers: [
|
||||
{key: "Access-Control-Allow-Credentials", value: "false"},
|
||||
{key: "Access-Control-Allow-Origin", value: websiteUrl},
|
||||
{
|
||||
key: "Access-Control-Allow-Methods",
|
||||
value: "POST,OPTIONS",
|
||||
},
|
||||
{
|
||||
key: "Access-Control-Allow-Headers",
|
||||
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/api/users/agents",
|
||||
headers: [
|
||||
{key: "Access-Control-Allow-Credentials", value: "false"},
|
||||
{key: "Access-Control-Allow-Origin", value: websiteUrl},
|
||||
{
|
||||
key: "Access-Control-Allow-Methods",
|
||||
value: "POST,OPTIONS",
|
||||
},
|
||||
{
|
||||
key: "Access-Control-Allow-Headers",
|
||||
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@beam-australia/react-env": "^3.1.1",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@headlessui/react": "^1.7.13",
|
||||
"@mdi/js": "^7.1.96",
|
||||
"@mdi/react": "^1.6.1",
|
||||
@@ -42,9 +43,11 @@
|
||||
"iron-session": "^6.3.1",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.4",
|
||||
"moment-timezone": "^0.5.44",
|
||||
"next": "13.1.6",
|
||||
"nodemailer": "^6.9.5",
|
||||
"nodemailer-express-handlebars": "^6.1.0",
|
||||
"paymob-react": "git+https://github.com/tiago-ecrop/paymob-react-oman.git",
|
||||
"primeicons": "^6.0.1",
|
||||
"primereact": "^9.2.3",
|
||||
"qrcode": "^1.5.3",
|
||||
@@ -54,6 +57,7 @@
|
||||
"react-csv": "^2.2.2",
|
||||
"react-currency-input-field": "^3.6.12",
|
||||
"react-datepicker": "^4.18.0",
|
||||
"react-diff-viewer": "^3.1.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-firebase-hooks": "^5.1.1",
|
||||
"react-icons": "^4.8.0",
|
||||
|
||||
BIN
public/audio/error.mp3
Normal file
BIN
public/audio/error.mp3
Normal file
Binary file not shown.
BIN
public/manuals/corporate.pdf
Normal file
BIN
public/manuals/corporate.pdf
Normal file
Binary file not shown.
BIN
public/manuals/student.pdf
Normal file
BIN
public/manuals/student.pdf
Normal file
Binary file not shown.
BIN
public/manuals/teacher.pdf
Normal file
BIN
public/manuals/teacher.pdf
Normal file
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
import {EmploymentStatus, EMPLOYMENT_STATUS, Gender, User} from "@/interfaces/user";
|
||||
import {FormEvent, useState} from "react";
|
||||
import {FormEvent, useEffect, useState} from "react";
|
||||
import countryCodes from "country-codes-list";
|
||||
import {RadioGroup} from "@headlessui/react";
|
||||
import Input from "./Low/Input";
|
||||
@@ -12,6 +12,8 @@ import {KeyedMutator} from "swr";
|
||||
import CountrySelect from "./Low/CountrySelect";
|
||||
import GenderInput from "@/components/High/GenderInput";
|
||||
import EmploymentStatusInput from "@/components/High/EmploymentStatusInput";
|
||||
import TimezoneSelect from "./Low/TImezoneSelect";
|
||||
import moment from "moment";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
@@ -25,6 +27,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
|
||||
const [gender, setGender] = useState<Gender>();
|
||||
const [employment, setEmployment] = useState<EmploymentStatus>();
|
||||
const [position, setPosition] = useState<string>();
|
||||
const [timezone, setTimezone] = useState<string>(moment.tz.guess());
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [companyName, setCompanyName] = useState<string>();
|
||||
@@ -43,6 +46,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
|
||||
employment: user.type === "corporate" ? undefined : employment,
|
||||
position: user.type === "corporate" ? position : undefined,
|
||||
passport_id,
|
||||
timezone,
|
||||
},
|
||||
agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined,
|
||||
})
|
||||
@@ -94,6 +98,12 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
|
||||
required
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Timezone</label>
|
||||
<TimezoneSelect value={timezone} onChange={setTimezone} />
|
||||
</div>
|
||||
|
||||
<GenderInput value={gender} onChange={setGender} />
|
||||
{user.type === "corporate" && (
|
||||
<Input name="position" onChange={setPosition} type="text" label="Position" placeholder="CEO, Head of Marketing..." required />
|
||||
|
||||
@@ -14,6 +14,7 @@ import {useEffect, useState} from "react";
|
||||
import {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen, BsQuestionSquare} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import Button from "./Low/Button";
|
||||
import ModuleLevelSelector from "./Medium/ModuleLevelSelector";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
@@ -36,7 +37,7 @@ export default function Diagnostic({onFinish}: Props) {
|
||||
};
|
||||
|
||||
const selectExam = () => {
|
||||
const examPromises = MODULE_ARRAY.map((module) => getExam(module, true));
|
||||
const examPromises = MODULE_ARRAY.map((module) => getExam(module, true, "partial"));
|
||||
|
||||
Promise.all(examPromises).then((exams) => {
|
||||
if (exams.every((x) => !!x)) {
|
||||
@@ -90,111 +91,17 @@ export default function Diagnostic({onFinish}: Props) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div className="flex flex-col gap-32 w-full mb-20">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-16 mb-24">
|
||||
<div className="w-full flex flex-col gap-3.5 relative">
|
||||
<span className="text-sm text-mti-gray-dim">
|
||||
<span className="font-bold">Reading</span> level
|
||||
</span>
|
||||
<Menu>
|
||||
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
||||
<BsBook className="text-ielts-reading" size={34} />
|
||||
<span className="text-mti-gray-cool text-sm">
|
||||
{levels.reading === -1 ? "Select your reading level" : `Level ${levels.reading}`}
|
||||
</span>
|
||||
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
||||
</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">
|
||||
{Object.values(writingMarking).map((x) => (
|
||||
<Menu.Item key={x}>
|
||||
<span
|
||||
onClick={() => setLevels((prev) => ({...prev, reading: x}))}
|
||||
className="w-full py-4 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
||||
Level {x}
|
||||
</span>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-3.5 relative">
|
||||
<span className="text-sm text-mti-gray-dim">
|
||||
<span className="font-bold">Listening</span> level
|
||||
</span>
|
||||
<Menu>
|
||||
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
||||
<BsHeadphones className="text-ielts-listening" size={34} />
|
||||
<span className="text-mti-gray-cool text-sm">
|
||||
{levels.listening === -1 ? "Select your listening level" : `Level ${levels.listening}`}
|
||||
</span>
|
||||
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
||||
</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">
|
||||
{Object.values(writingMarking).map((x) => (
|
||||
<Menu.Item key={x}>
|
||||
<span
|
||||
onClick={() => setLevels((prev) => ({...prev, listening: x}))}
|
||||
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
||||
Level {x}
|
||||
</span>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-3.5 relative">
|
||||
<span className="text-sm text-mti-gray-dim">
|
||||
<span className="font-bold">Writing</span> level
|
||||
</span>
|
||||
<Menu>
|
||||
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
||||
<BsPen className="text-ielts-writing" size={34} />
|
||||
<span className="text-mti-gray-cool text-sm">
|
||||
{levels.writing === -1 ? "Select your writing level" : `Level ${levels.writing}`}
|
||||
</span>
|
||||
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
||||
</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">
|
||||
{Object.values(writingMarking).map((x) => (
|
||||
<Menu.Item key={x}>
|
||||
<span
|
||||
onClick={() => setLevels((prev) => ({...prev, writing: x}))}
|
||||
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
||||
Level {x}
|
||||
</span>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-3.5 relative">
|
||||
<span className="text-sm text-mti-gray-dim">
|
||||
<span className="font-bold">Speaking</span> level
|
||||
</span>
|
||||
<Menu>
|
||||
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
||||
<BsMegaphone className="text-ielts-speaking" size={34} />
|
||||
<span className="text-mti-gray-cool text-sm">
|
||||
{levels.speaking === -1 ? "Select your speaking level" : `Level ${levels.speaking}`}
|
||||
</span>
|
||||
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
||||
</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">
|
||||
{Object.values(writingMarking).map((x) => (
|
||||
<Menu.Item key={x}>
|
||||
<span
|
||||
onClick={() => setLevels((prev) => ({...prev, speaking: x}))}
|
||||
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
||||
Level {x}
|
||||
</span>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
<ModuleLevelSelector levels={levels} setLevels={setLevels} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center gap-8 w-full mb-44">
|
||||
<h2 className="font-semibold text-xl">What is your desired IELTS level?</h2>
|
||||
<ModuleLevelSelector levels={desiredLevels} setLevels={setDesiredLevels} />
|
||||
</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">
|
||||
<Button
|
||||
@@ -224,7 +131,5 @@ export default function Diagnostic({onFinish}: Props) {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ export default function FillBlanks({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
||||
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||
{(!!currentBlankId || isDrawerShowing) && (
|
||||
<WordsDrawer
|
||||
key={currentBlankId}
|
||||
|
||||
@@ -5,6 +5,8 @@ import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill}
|
||||
import dynamic from "next/dynamic";
|
||||
import Button from "../Low/Button";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {downloadBlob} from "@/utils/evaluation";
|
||||
import axios from "axios";
|
||||
|
||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
||||
@@ -14,9 +16,11 @@ const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mo
|
||||
export default function InteractiveSpeaking({
|
||||
id,
|
||||
title,
|
||||
examID,
|
||||
text,
|
||||
type,
|
||||
prompts,
|
||||
userSolutions,
|
||||
updateIndex,
|
||||
onNext,
|
||||
onBack,
|
||||
@@ -24,21 +28,105 @@ export default function InteractiveSpeaking({
|
||||
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 [answers, setAnswers] = useState<{prompt: string; blob: string; questionIndex: number}[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
|
||||
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
|
||||
const saveToStorage = async (previousURL?: string) => {
|
||||
if (mediaBlob && mediaBlob.startsWith("blob")) {
|
||||
const blobBuffer = await downloadBlob(mediaBlob);
|
||||
const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"});
|
||||
|
||||
const seed = Math.random().toString().replace("0.", "");
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("audio", audioFile, `${seed}.wav`);
|
||||
formData.append("root", "speaking_recordings");
|
||||
|
||||
const config = {
|
||||
headers: {
|
||||
"Content-Type": "audio/wav",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await axios.post<{path: string}>("/api/storage/insert", formData, config);
|
||||
if (previousURL && !previousURL.startsWith("blob")) await axios.post("/api/storage/delete", {path: previousURL});
|
||||
return response.data.path;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const back = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const answer = await saveAnswer(questionIndex);
|
||||
if (questionIndex - 1 >= 0) {
|
||||
setQuestionIndex(questionIndex - 1);
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
setIsLoading(false);
|
||||
|
||||
onBack({
|
||||
exercise: id,
|
||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||
score: {correct: 100, total: 100, missing: 0},
|
||||
type,
|
||||
});
|
||||
};
|
||||
|
||||
const next = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const answer = await saveAnswer(questionIndex);
|
||||
if (questionIndex + 1 < prompts.length) {
|
||||
setQuestionIndex(questionIndex + 1);
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
setIsLoading(false);
|
||||
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||
score: {correct: 100, total: 100, missing: 0},
|
||||
type,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (updateIndex) updateIndex(promptIndex);
|
||||
}, [promptIndex, updateIndex]);
|
||||
if (userSolutions.length > 0 && answers.length === 0) {
|
||||
const solutions = userSolutions as unknown as typeof answers;
|
||||
setAnswers(solutions);
|
||||
|
||||
if (!mediaBlob) setMediaBlob(solutions.find((x) => x.questionIndex === questionIndex)?.blob);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userSolutions, mediaBlob, answers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (updateIndex) updateIndex(questionIndex);
|
||||
}, [questionIndex, updateIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded) {
|
||||
const answer = {
|
||||
questionIndex,
|
||||
prompt: prompts[questionIndex].text,
|
||||
blob: mediaBlob!,
|
||||
};
|
||||
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||
score: {correct: 100, total: 100, missing: 0},
|
||||
type,
|
||||
});
|
||||
}
|
||||
@@ -59,19 +147,38 @@ export default function InteractiveSpeaking({
|
||||
}, [isRecording]);
|
||||
|
||||
useEffect(() => {
|
||||
if (promptIndex === answers.length - 1) {
|
||||
setMediaBlob(answers[promptIndex].blob);
|
||||
if (questionIndex <= answers.length - 1) {
|
||||
const blob = answers.find((x) => x.questionIndex === questionIndex)?.blob;
|
||||
setMediaBlob(blob);
|
||||
}
|
||||
}, [answers, promptIndex]);
|
||||
}, [answers, questionIndex]);
|
||||
|
||||
const saveAnswer = async (index: number) => {
|
||||
const previousURL = answers.find((x) => x.questionIndex === questionIndex)?.blob;
|
||||
const audioPath = await saveToStorage(previousURL);
|
||||
|
||||
const saveAnswer = () => {
|
||||
const answer = {
|
||||
prompt: prompts[promptIndex].text,
|
||||
blob: mediaBlob!,
|
||||
questionIndex,
|
||||
prompt: prompts[questionIndex].text,
|
||||
blob: audioPath ? audioPath : mediaBlob!,
|
||||
};
|
||||
|
||||
setAnswers((prev) => [...prev, answer]);
|
||||
setAnswers((prev) => [...prev.filter((x) => x.questionIndex !== index), answer]);
|
||||
setMediaBlob(undefined);
|
||||
|
||||
setUserSolutions([
|
||||
...storeUserSolutions.filter((x) => x.exercise !== id),
|
||||
{
|
||||
exercise: id,
|
||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||
score: {correct: 100, total: 100, missing: 0},
|
||||
module: "speaking",
|
||||
exam: examID,
|
||||
type,
|
||||
},
|
||||
]);
|
||||
|
||||
return answer;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -82,8 +189,8 @@ export default function InteractiveSpeaking({
|
||||
</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 key={questionIndex} autoPlay controls className="max-w-3xl rounded-xl">
|
||||
<source src={prompts[questionIndex].video_url} />
|
||||
</video>
|
||||
</div>
|
||||
)}
|
||||
@@ -91,13 +198,13 @@ export default function InteractiveSpeaking({
|
||||
|
||||
<ReactMediaRecorder
|
||||
audio
|
||||
key={promptIndex}
|
||||
key={questionIndex}
|
||||
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" && (
|
||||
{status === "idle" && !mediaBlob && (
|
||||
<>
|
||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||
{status === "idle" && (
|
||||
@@ -176,9 +283,9 @@ export default function InteractiveSpeaking({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{status === "stopped" && mediaBlobUrl && (
|
||||
{((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && (
|
||||
<>
|
||||
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||
<Waveform audio={mediaBlobUrl ? mediaBlobUrl : mediaBlob!} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||
<div className="flex gap-4 items-center">
|
||||
<BsTrashFill
|
||||
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||
@@ -208,44 +315,11 @@ export default function InteractiveSpeaking({
|
||||
/>
|
||||
|
||||
<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">
|
||||
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} 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 color="purple" disabled={!mediaBlob} isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
|
||||
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import {MatchSentencesExercise} from "@/interfaces/exam";
|
||||
import {MatchSentenceExerciseOption, MatchSentenceExerciseSentence, MatchSentencesExercise} from "@/interfaces/exam";
|
||||
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import clsx from "clsx";
|
||||
@@ -9,13 +9,74 @@ import {CommonProps} from ".";
|
||||
import Button from "../Low/Button";
|
||||
import Xarrow from "react-xarrows";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {DndContext, DragEndEvent, useDraggable, useDroppable} from "@dnd-kit/core";
|
||||
|
||||
function DroppableQuestionArea({question, answer}: {question: MatchSentenceExerciseSentence; answer?: string}) {
|
||||
const {isOver, setNodeRef} = useDroppable({id: `droppable_sentence_${question.id}`});
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-4" ref={setNodeRef}>
|
||||
<div className="flex items-center gap-3 cursor-pointer col-span-2">
|
||||
<button
|
||||
className={clsx(
|
||||
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
||||
"transition duration-300 ease-in-out",
|
||||
)}>
|
||||
{question.id}
|
||||
</button>
|
||||
<span>{question.sentence}</span>
|
||||
</div>
|
||||
<div
|
||||
key={`answer_${question.id}_${answer}`}
|
||||
className={clsx("w-48 h-10 border rounded-xl flex items-center justify-center", isOver && "border-mti-purple-light")}>
|
||||
{answer && `Paragraph ${answer}`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DraggableOptionArea({option}: {option: MatchSentenceExerciseOption}) {
|
||||
const {attributes, listeners, setNodeRef, transform} = useDraggable({
|
||||
id: `draggable_option_${option.id}`,
|
||||
});
|
||||
|
||||
const style = transform
|
||||
? {
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
zIndex: 99,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className={clsx("flex items-center justify-start gap-6 cursor-pointer")} ref={setNodeRef} style={style} {...listeners} {...attributes}>
|
||||
<button
|
||||
id={`option_${option.id}`}
|
||||
// onClick={() => selectOption(id)}
|
||||
className={clsx(
|
||||
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple px-3 py-2 rounded-full z-10",
|
||||
"transition duration-300 ease-in-out",
|
||||
option.id,
|
||||
)}>
|
||||
Paragraph {option.id}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MatchSentences({id, options, type, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) {
|
||||
const [selectedQuestion, setSelectedQuestion] = useState<string>();
|
||||
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
||||
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
if (event.over && event.over.id.toString().startsWith("droppable")) {
|
||||
const optionID = event.active.id.toString().replace("draggable_option_", "");
|
||||
const sentenceID = event.over.id.toString().replace("droppable_sentence_", "");
|
||||
|
||||
setAnswers((prev) => [...prev.filter((x) => x.question.toString() !== sentenceID), {question: sentenceID, option: optionID}]);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateScore = () => {
|
||||
const total = sentences.length;
|
||||
const correct = answers.filter(
|
||||
@@ -26,11 +87,9 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
||||
return {total, correct, missing};
|
||||
};
|
||||
|
||||
const selectOption = (option: string) => {
|
||||
if (!selectedQuestion) return;
|
||||
setAnswers((prev) => [...prev.filter((x) => x.question !== selectedQuestion), {question: selectedQuestion, option}]);
|
||||
setSelectedQuestion(undefined);
|
||||
};
|
||||
useEffect(() => {
|
||||
console.log(answers);
|
||||
}, [answers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||
@@ -39,7 +98,7 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
||||
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||
<span className="text-sm w-full leading-6">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
@@ -48,47 +107,29 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
<div className="flex gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
|
||||
|
||||
<DndContext onDragEnd={handleDragEnd}>
|
||||
<div className="flex flex-col gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
{sentences.map(({sentence, id}) => (
|
||||
<div key={`question_${id}`} className="flex items-center justify-end gap-2 cursor-pointer">
|
||||
<span>{sentence} </span>
|
||||
<button
|
||||
id={id}
|
||||
onClick={() => setSelectedQuestion((prev) => (prev === id ? undefined : id))}
|
||||
className={clsx(
|
||||
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
||||
"transition duration-300 ease-in-out",
|
||||
selectedQuestion === id && "!text-white !bg-mti-purple",
|
||||
id,
|
||||
)}>
|
||||
{id}
|
||||
</button>
|
||||
</div>
|
||||
{sentences.map((question) => (
|
||||
<DroppableQuestionArea
|
||||
key={`question_${question.id}`}
|
||||
question={question}
|
||||
answer={answers.find((x) => x.question.toString() === question.id.toString())?.option}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{options.map(({sentence, id}) => (
|
||||
<div key={`answer_${id}`} className={clsx("flex items-center justify-start gap-2 cursor-pointer")}>
|
||||
<button
|
||||
id={id}
|
||||
onClick={() => selectOption(id)}
|
||||
className={clsx(
|
||||
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
||||
"transition duration-300 ease-in-out",
|
||||
id,
|
||||
)}>
|
||||
{id}
|
||||
</button>
|
||||
<span>{sentence}</span>
|
||||
</div>
|
||||
<span>Drag one of these paragraphs into the slots above:</span>
|
||||
<div className="flex gap-4 flex-wrap justify-center items-center max-w-lg">
|
||||
{options.map((option) => (
|
||||
<DraggableOptionArea key={`answer_${option.id}`} option={option} />
|
||||
))}
|
||||
</div>
|
||||
{answers.map((solution, index) => (
|
||||
<Xarrow key={index} start={solution.question} end={solution.option} lineColor="#7872BF" showHead={false} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||
<Button
|
||||
|
||||
@@ -7,6 +7,7 @@ import {CommonProps} from ".";
|
||||
import Button from "../Low/Button";
|
||||
|
||||
function Question({
|
||||
id,
|
||||
variant,
|
||||
prompt,
|
||||
options,
|
||||
@@ -15,7 +16,9 @@ function Question({
|
||||
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<span className="">{prompt}</span>
|
||||
<span className="">
|
||||
{id} - {prompt}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-4 justify-between">
|
||||
{variant === "image" &&
|
||||
options.map((option) => (
|
||||
@@ -59,10 +62,18 @@ export default function MultipleChoice({
|
||||
onBack,
|
||||
}: MultipleChoiceExercise & CommonProps) {
|
||||
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
||||
const [questionIndex, setQuestionIndex] = useState(0);
|
||||
|
||||
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
|
||||
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
|
||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||
|
||||
useEffect(() => {
|
||||
setUserSolutions([...storeUserSolutions.filter((x) => x.exercise !== id), {exercise: id, solutions: answers, score: calculateScore(), type}]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [answers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -91,21 +102,25 @@ export default function MultipleChoice({
|
||||
if (questionIndex === questions.length - 1) {
|
||||
onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||
} else {
|
||||
setQuestionIndex((prev) => prev + 1);
|
||||
setQuestionIndex(questionIndex + 1);
|
||||
}
|
||||
|
||||
scrollToTop();
|
||||
};
|
||||
|
||||
const back = () => {
|
||||
if (questionIndex === 0) {
|
||||
onBack({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||
} else {
|
||||
setQuestionIndex((prev) => prev - 1);
|
||||
setQuestionIndex(questionIndex - 1);
|
||||
}
|
||||
|
||||
scrollToTop();
|
||||
};
|
||||
|
||||
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-fit w-full mb-20 bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||
<span className="text-xl font-semibold">{prompt}</span>
|
||||
{questionIndex < questions.length && (
|
||||
<Question
|
||||
|
||||
@@ -5,28 +5,58 @@ import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill}
|
||||
import dynamic from "next/dynamic";
|
||||
import Button from "../Low/Button";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {downloadBlob} from "@/utils/evaluation";
|
||||
import axios from "axios";
|
||||
|
||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function Speaking({id, title, text, video_url, type, prompts, onNext, onBack}: SpeakingExercise & CommonProps) {
|
||||
export default function Speaking({id, title, text, video_url, type, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||
const [audioURL, setAudioURL] = useState<string>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded) {
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
type,
|
||||
});
|
||||
const saveToStorage = async () => {
|
||||
if (mediaBlob && mediaBlob.startsWith("blob")) {
|
||||
const blobBuffer = await downloadBlob(mediaBlob);
|
||||
const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"});
|
||||
|
||||
const seed = Math.random().toString().replace("0.", "");
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("audio", audioFile, `${seed}.wav`);
|
||||
formData.append("root", "speaking_recordings");
|
||||
|
||||
const config = {
|
||||
headers: {
|
||||
"Content-Type": "audio/wav",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await axios.post<{path: string}>("/api/storage/insert", formData, config);
|
||||
if (audioURL) await axios.post("/api/storage/delete", {path: audioURL});
|
||||
return response.data.path;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (userSolutions.length > 0) {
|
||||
const {solution} = userSolutions[0] as {solution?: string};
|
||||
if (solution && !mediaBlob) setMediaBlob(solution);
|
||||
if (solution && !solution.startsWith("blob")) setAudioURL(solution);
|
||||
}
|
||||
}, [userSolutions, mediaBlob]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded) next();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasExamEnded]);
|
||||
|
||||
@@ -43,6 +73,32 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
|
||||
};
|
||||
}, [isRecording]);
|
||||
|
||||
const next = async () => {
|
||||
setIsLoading(true);
|
||||
const storagePath = await saveToStorage();
|
||||
setIsLoading(false);
|
||||
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: storagePath ? [{id, solution: storagePath}] : [],
|
||||
score: {correct: 0, total: 100, missing: 0},
|
||||
type,
|
||||
});
|
||||
};
|
||||
|
||||
const back = async () => {
|
||||
setIsLoading(true);
|
||||
const storagePath = await saveToStorage();
|
||||
setIsLoading(false);
|
||||
|
||||
onBack({
|
||||
exercise: id,
|
||||
solutions: storagePath ? [{id, solution: storagePath}] : [],
|
||||
score: {correct: 0, total: 100, missing: 0},
|
||||
type,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
@@ -89,7 +145,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
|
||||
<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" && (
|
||||
{status === "idle" && !mediaBlob && (
|
||||
<>
|
||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||
{status === "idle" && (
|
||||
@@ -168,9 +224,9 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{status === "stopped" && mediaBlobUrl && (
|
||||
{((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && (
|
||||
<>
|
||||
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||
<Waveform audio={mediaBlobUrl ? mediaBlobUrl : mediaBlob!} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||
<div className="flex gap-4 items-center">
|
||||
<BsTrashFill
|
||||
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||
@@ -200,32 +256,10 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
|
||||
/>
|
||||
|
||||
<div className="self-end flex justify-between w-full gap-8">
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onBack({
|
||||
exercise: id,
|
||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
type,
|
||||
})
|
||||
}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
color="purple"
|
||||
disabled={!mediaBlob}
|
||||
onClick={() =>
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
type,
|
||||
})
|
||||
}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
||||
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||
<span className="text-sm w-full leading-6">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
||||
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||
<span className="text-sm w-full leading-6">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<span key={index}>
|
||||
|
||||
@@ -22,9 +22,31 @@ export default function Writing({
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : "");
|
||||
const [isSubmitEnabled, setIsSubmitEnabled] = useState(false);
|
||||
const [saveTimer, setSaveTimer] = useState(0);
|
||||
|
||||
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
|
||||
useEffect(() => {
|
||||
const saveTimerInterval = setInterval(() => {
|
||||
setSaveTimer((prev) => prev + 1);
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(saveTimerInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputText.length > 0 && saveTimer % 10 === 0) {
|
||||
setUserSolutions([
|
||||
...storeUserSolutions.filter((x) => x.exercise !== id),
|
||||
{exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type, module: "writing"},
|
||||
]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [saveTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem("enable_paste")) return;
|
||||
|
||||
@@ -42,7 +64,8 @@ export default function Writing({
|
||||
}, []);
|
||||
|
||||
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: 100, total: 100, missing: 0}, type, module: "writing"});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasExamEnded]);
|
||||
|
||||
@@ -125,14 +148,24 @@ export default function Writing({
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={() => onBack({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type})}
|
||||
onClick={() =>
|
||||
onBack({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type})
|
||||
}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
color="purple"
|
||||
disabled={!isSubmitEnabled}
|
||||
onClick={() => onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type})}
|
||||
onClick={() =>
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: [{id, solution: inputText.replaceAll(/\s{2,}/g, " ")}],
|
||||
score: {correct: 100, total: 100, missing: 0},
|
||||
type,
|
||||
module: "writing",
|
||||
})
|
||||
}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
</Button>
|
||||
|
||||
@@ -22,6 +22,7 @@ import InteractiveSpeaking from "./InteractiveSpeaking";
|
||||
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false});
|
||||
|
||||
export interface CommonProps {
|
||||
examID?: string;
|
||||
updateIndex?: (internalIndex: number) => void;
|
||||
onNext: (userSolutions: UserSolution) => void;
|
||||
onBack: (userSolutions: UserSolution) => void;
|
||||
@@ -29,17 +30,18 @@ export interface CommonProps {
|
||||
|
||||
export const renderExercise = (
|
||||
exercise: Exercise,
|
||||
examID: string,
|
||||
onNext: (userSolutions: UserSolution) => void,
|
||||
onBack: (userSolutions: UserSolution) => void,
|
||||
updateIndex?: (internalIndex: number) => void,
|
||||
) => {
|
||||
switch (exercise.type) {
|
||||
case "fillBlanks":
|
||||
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||
case "trueFalse":
|
||||
return <TrueFalse key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <TrueFalse key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||
case "matchSentences":
|
||||
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||
case "multipleChoice":
|
||||
return (
|
||||
<MultipleChoice
|
||||
@@ -48,19 +50,21 @@ export const renderExercise = (
|
||||
updateIndex={updateIndex}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
examID={examID}
|
||||
/>
|
||||
);
|
||||
case "writeBlanks":
|
||||
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||
case "writing":
|
||||
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||
case "speaking":
|
||||
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||
case "interactiveSpeaking":
|
||||
return (
|
||||
<InteractiveSpeaking
|
||||
key={exercise.id}
|
||||
{...(exercise as InteractiveSpeakingExercise)}
|
||||
examID={examID}
|
||||
updateIndex={updateIndex}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
|
||||
@@ -34,6 +34,7 @@ export default function Layout({user, children, className, navDisabled = false,
|
||||
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
|
||||
className="-md:hidden"
|
||||
userType={user.type}
|
||||
userId={user.id}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
|
||||
254
src/components/High/TicketDisplay.tsx
Normal file
254
src/components/High/TicketDisplay.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {
|
||||
Ticket,
|
||||
TicketStatus,
|
||||
TicketStatusLabel,
|
||||
TicketType,
|
||||
TicketTypeLabel,
|
||||
} from "@/interfaces/ticket";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||
import axios from "axios";
|
||||
import moment from "moment";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import ShortUniqueId from "short-unique-id";
|
||||
import Button from "../Low/Button";
|
||||
import Input from "../Low/Input";
|
||||
import Select from "../Low/Select";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
ticket: Ticket;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function TicketDisplay({ user, ticket, onClose }: Props) {
|
||||
const [subject] = useState(ticket.subject);
|
||||
const [type, setType] = useState<TicketType>(ticket.type);
|
||||
const [description] = useState(ticket.description);
|
||||
const [reporter] = useState(ticket.reporter);
|
||||
const [reportedFrom] = useState(ticket.reportedFrom);
|
||||
const [status, setStatus] = useState(ticket.status);
|
||||
const [assignedTo, setAssignedTo] = useState<string | null>(
|
||||
ticket.assignedTo || null,
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { users } = useUsers();
|
||||
|
||||
const submit = () => {
|
||||
if (!type)
|
||||
return toast.error("Please choose a type!", { toastId: "missing-type" });
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.patch(`/api/tickets/${ticket.id}`, {
|
||||
subject,
|
||||
type,
|
||||
description,
|
||||
reporter,
|
||||
reportedFrom,
|
||||
status,
|
||||
assignedTo,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(`The ticket has been updated!`, { toastId: "submitted" });
|
||||
onClose();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong, please try again later!", {
|
||||
toastId: "error",
|
||||
});
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
const del = () => {
|
||||
if (!confirm("Are you sure you want to delete this ticket?")) return;
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.delete(`/api/tickets/${ticket.id}`)
|
||||
.then(() => {
|
||||
toast.success(`The ticket has been deleted!`, { toastId: "submitted" });
|
||||
onClose();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong, please try again later!", {
|
||||
toastId: "error",
|
||||
});
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4 pt-8">
|
||||
<Input
|
||||
label="Subject"
|
||||
type="text"
|
||||
name="subject"
|
||||
placeholder="Subject..."
|
||||
value={subject}
|
||||
onChange={(e) => null}
|
||||
disabled
|
||||
/>
|
||||
|
||||
<div className="-md:flex-col flex w-full items-center gap-4">
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Status
|
||||
</label>
|
||||
<Select
|
||||
options={Object.keys(TicketStatusLabel).map((x) => ({
|
||||
value: x,
|
||||
label: TicketStatusLabel[x as keyof typeof TicketStatusLabel],
|
||||
}))}
|
||||
value={{ value: status, label: TicketStatusLabel[status] }}
|
||||
onChange={(value) =>
|
||||
setStatus((value?.value as TicketStatus) ?? undefined)
|
||||
}
|
||||
placeholder="Status..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Type
|
||||
</label>
|
||||
<Select
|
||||
options={Object.keys(TicketTypeLabel).map((x) => ({
|
||||
value: x,
|
||||
label: TicketTypeLabel[x as keyof typeof TicketTypeLabel],
|
||||
}))}
|
||||
value={{ value: type, label: TicketTypeLabel[type] }}
|
||||
onChange={(value) => setType(value!.value as TicketType)}
|
||||
placeholder="Type..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Assignee
|
||||
</label>
|
||||
<Select
|
||||
options={[
|
||||
{ value: "me", label: "Assign to me" },
|
||||
...users
|
||||
.filter((x) => ["admin", "developer", "agent"].includes(x.type))
|
||||
.map((u) => ({
|
||||
value: u.id,
|
||||
label: `${u.name} - ${u.email}`,
|
||||
})),
|
||||
]}
|
||||
disabled={user.type === "agent"}
|
||||
value={
|
||||
assignedTo
|
||||
? {
|
||||
value: assignedTo,
|
||||
label: `${users.find((u) => u.id === assignedTo)?.name} - ${users.find((u) => u.id === assignedTo)?.email}`,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onChange={(value) =>
|
||||
value
|
||||
? setAssignedTo(value.value === "me" ? user.id : value.value)
|
||||
: setAssignedTo(null)
|
||||
}
|
||||
placeholder="Assignee..."
|
||||
isClearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="-md:flex-col flex w-full items-center gap-4">
|
||||
<Input
|
||||
label="Reported From"
|
||||
type="text"
|
||||
name="reportedFrom"
|
||||
onChange={() => null}
|
||||
value={reportedFrom}
|
||||
disabled
|
||||
/>
|
||||
<Input
|
||||
label="Date"
|
||||
type="text"
|
||||
name="date"
|
||||
onChange={() => null}
|
||||
value={moment(ticket.date).format("DD/MM/YYYY - HH:mm")}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="-md:flex-col flex w-full items-center gap-4">
|
||||
<Input
|
||||
label="Reporter's Name"
|
||||
type="text"
|
||||
name="reporter"
|
||||
onChange={() => null}
|
||||
value={reporter.name}
|
||||
disabled
|
||||
/>
|
||||
<Input
|
||||
label="Reporter's E-mail"
|
||||
type="text"
|
||||
name="reporter"
|
||||
onChange={() => null}
|
||||
value={reporter.email}
|
||||
disabled
|
||||
/>
|
||||
<Input
|
||||
label="Reporter's Type"
|
||||
type="text"
|
||||
name="reporterType"
|
||||
onChange={() => null}
|
||||
value={USER_TYPE_LABELS[reporter.type]}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8"
|
||||
placeholder="Write your ticket's description here..."
|
||||
contentEditable={false}
|
||||
value={description}
|
||||
spellCheck
|
||||
/>
|
||||
|
||||
<div className="-md:flex-col-reverse mt-2 flex w-full items-center justify-between gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
color="red"
|
||||
className="w-full md:max-w-[200px]"
|
||||
variant="outline"
|
||||
onClick={del}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
<div className="-md:flex-col-reverse flex w-full items-center justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
color="red"
|
||||
className="w-full md:max-w-[200px]"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full md:max-w-[200px]"
|
||||
isLoading={isLoading}
|
||||
onClick={submit}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
116
src/components/High/TicketSubmission.tsx
Normal file
116
src/components/High/TicketSubmission.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import {Ticket, TicketType, TicketTypeLabel} from "@/interfaces/ticket";
|
||||
import {User} from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import axios from "axios";
|
||||
import {useState} from "react";
|
||||
import {toast} from "react-toastify";
|
||||
import ShortUniqueId from "short-unique-id";
|
||||
import Button from "../Low/Button";
|
||||
import Input from "../Low/Input";
|
||||
import Select from "../Low/Select";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
page: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function TicketSubmission({user, page, onClose}: Props) {
|
||||
const [subject, setSubject] = useState("");
|
||||
const [type, setType] = useState<TicketType>();
|
||||
const [description, setDescription] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const examState = useExamStore((state) => state);
|
||||
|
||||
const submit = () => {
|
||||
if (!type) return toast.error("Please choose a type!", {toastId: "missing-type"});
|
||||
if (subject.trim() === "")
|
||||
return toast.error("Please input a subject!", {
|
||||
toastId: "missing-subject",
|
||||
});
|
||||
if (description.trim() === "")
|
||||
return toast.error("Please describe your ticket!", {
|
||||
toastId: "missing-desc",
|
||||
});
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const shortUID = new ShortUniqueId();
|
||||
const ticket: Ticket = {
|
||||
id: shortUID.randomUUID(8),
|
||||
date: new Date().toISOString(),
|
||||
reporter: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
type: user.type,
|
||||
},
|
||||
status: "submitted",
|
||||
subject,
|
||||
type,
|
||||
reportedFrom: page,
|
||||
description,
|
||||
examInformation:
|
||||
page.includes("exam") || page.includes("exercises")
|
||||
? {
|
||||
exam: examState.exam?.id || "",
|
||||
exams: examState.exams.map((x) => x.id),
|
||||
exerciseIndex: examState.exerciseIndex,
|
||||
moduleIndex: examState.moduleIndex,
|
||||
partIndex: examState.partIndex,
|
||||
questionIndex: examState.questionIndex,
|
||||
selectedModules: examState.selectedModules,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
axios
|
||||
.post(`/api/tickets`, ticket)
|
||||
.then(() => {
|
||||
toast.success(`Your ticket has been submitted! You will be contacted by e-mail for further discussion.`, {toastId: "submitted"});
|
||||
onClose();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong, please try again later!", {
|
||||
toastId: "error",
|
||||
});
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4 pt-8">
|
||||
<Input label="Subject" type="text" name="subject" placeholder="Subject..." onChange={(e) => setSubject(e)} />
|
||||
<div className="-md:flex-col flex w-full items-center gap-4">
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<label className="text-mti-gray-dim text-base font-normal">Type</label>
|
||||
<Select
|
||||
options={Object.keys(TicketTypeLabel).map((x) => ({
|
||||
value: x,
|
||||
label: TicketTypeLabel[x as keyof typeof TicketTypeLabel],
|
||||
}))}
|
||||
onChange={(value) => setType((value?.value as TicketType) ?? undefined)}
|
||||
placeholder="Type..."
|
||||
/>
|
||||
</div>
|
||||
<Input label="Reporter" type="text" name="reporter" onChange={() => null} value={`${user.name} - ${user.email}`} disabled />
|
||||
</div>
|
||||
<textarea
|
||||
className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8"
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Write your ticket's description here..."
|
||||
spellCheck
|
||||
/>
|
||||
<div className="mt-2 flex w-full items-center justify-end gap-4">
|
||||
<Button type="button" color="red" className="w-full max-w-[200px]" variant="outline" onClick={onClose} isLoading={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" className="w-full max-w-[200px]" isLoading={isLoading} onClick={submit}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {BsArrowRepeat} from "react-icons/bs";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
color?: "rose" | "purple" | "red" | "green" | "gray";
|
||||
color?: "rose" | "purple" | "red" | "green" | "gray" | "pink";
|
||||
variant?: "outline" | "solid";
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
@@ -49,6 +49,11 @@ export default function Button({
|
||||
outline:
|
||||
"bg-transparent text-mti-rose-light border border-mti-rose-light hover:bg-mti-rose-light disabled:text-mti-rose disabled:bg-mti-rose-ultralight disabled:border-none selection:bg-mti-rose-dark hover:text-white selection:text-white",
|
||||
},
|
||||
pink: {
|
||||
solid: "bg-ielts-speaking text-white border border-ielts-speaking hover:bg-ielts-speaking disabled:text-ielts-speaking disabled:bg-ielts-speaking-transparent selection:bg-ielts-speaking",
|
||||
outline:
|
||||
"bg-transparent text-ielts-speaking border border-ielts-speaking hover:bg-ielts-speaking disabled:text-ielts-speaking disabled:bg-ielts-speaking-transparent disabled:border-none selection:bg-ielts-speaking hover:text-white selection:text-white",
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -57,8 +62,8 @@ export default function Button({
|
||||
onClick={onClick}
|
||||
className={clsx(
|
||||
"py-4 px-6 rounded-full transition ease-in-out duration-300 disabled:cursor-not-allowed cursor-pointer",
|
||||
className,
|
||||
colorClassNames[color][variant],
|
||||
className,
|
||||
)}
|
||||
disabled={disabled || isLoading}>
|
||||
{!isLoading && children}
|
||||
|
||||
@@ -42,7 +42,9 @@ export default function CountrySelect({value, disabled = false, onChange}: Props
|
||||
displayValue={(code: string) => {
|
||||
const country = countries[code as unknown as keyof TCountries];
|
||||
|
||||
return `${countryCodes.findOne("countryCode" as any, code).flag} ${country.name} (+${country.phone})`;
|
||||
return `${countryCodes.findOne("countryCode" as any, code)?.flag || ""} ${country?.name || "N/A"} (+${
|
||||
country?.phone || "N/A"
|
||||
})`;
|
||||
}}
|
||||
/>
|
||||
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-8">
|
||||
|
||||
@@ -5,12 +5,14 @@ interface Props {
|
||||
label: string;
|
||||
percentage: number;
|
||||
color: "red" | "rose" | "purple" | Module;
|
||||
mark?: number;
|
||||
markLabel?: string;
|
||||
useColor?: boolean;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
}
|
||||
|
||||
export default function ProgressBar({label, percentage, color, useColor = false, className, textClassName}: Props) {
|
||||
export default function ProgressBar({label, percentage, color, mark, markLabel, useColor = false, className, textClassName}: Props) {
|
||||
const progressColorClass: {[key in typeof color]: string} = {
|
||||
red: "bg-mti-red-light",
|
||||
rose: "bg-mti-rose-light",
|
||||
@@ -30,6 +32,9 @@ export default function ProgressBar({label, percentage, color, useColor = false,
|
||||
!useColor ? "bg-mti-gray-anti-flash" : progressColorClass[color],
|
||||
useColor && "bg-opacity-20",
|
||||
)}>
|
||||
{mark && (
|
||||
<div style={{left: `${mark}%`}} className={clsx("w-3 h-2 bg-mti-gray-davy/60 absolute -translate-x-1/2 top-0 z-20 cursor-pointer")} />
|
||||
)}
|
||||
<div
|
||||
style={{width: `${percentage}%`}}
|
||||
className={clsx("absolute transition-all duration-300 ease-in-out top-0 left-0 h-full overflow-hidden", progressColorClass[color])}
|
||||
|
||||
70
src/components/Low/Select.tsx
Normal file
70
src/components/Low/Select.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import clsx from "clsx";
|
||||
import {ComponentProps, useEffect, useState} from "react";
|
||||
import ReactSelect, {GroupBase, StylesConfig} from "react-select";
|
||||
|
||||
interface Option {
|
||||
[key: string]: any;
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
defaultValue?: Option;
|
||||
value?: Option | null;
|
||||
options: Option[];
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
onChange: (value: Option | null) => void;
|
||||
isClearable?: boolean;
|
||||
styles?: StylesConfig<Option, boolean, GroupBase<Option>>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Select({value, defaultValue, options, placeholder, disabled, onChange, styles, isClearable, className}: Props) {
|
||||
const [target, setTarget] = useState<HTMLElement>();
|
||||
|
||||
useEffect(() => {
|
||||
if (document) setTarget(document.body);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ReactSelect
|
||||
className={
|
||||
styles
|
||||
? undefined
|
||||
: clsx(
|
||||
"placeholder:text-mti-gray-cool border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none",
|
||||
disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
|
||||
className,
|
||||
)
|
||||
}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange as any}
|
||||
placeholder={placeholder}
|
||||
menuPortalTarget={target}
|
||||
defaultValue={defaultValue}
|
||||
styles={
|
||||
styles || {
|
||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||
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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
isDisabled={disabled}
|
||||
isClearable={isClearable}
|
||||
/>
|
||||
);
|
||||
}
|
||||
64
src/components/Low/TImezoneSelect.tsx
Normal file
64
src/components/Low/TImezoneSelect.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Fragment, useState } from "react";
|
||||
import { Combobox, Transition } from "@headlessui/react";
|
||||
import { BsChevronExpand } from "react-icons/bs";
|
||||
import moment from "moment-timezone";
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function TimezoneSelect({
|
||||
value,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: Props) {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const timezones = moment.tz.names();
|
||||
|
||||
const filteredTimezones = query === "" ? timezones : timezones.filter((x) => x.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)}
|
||||
/>
|
||||
<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">
|
||||
{filteredTimezones.map((timezone: string) => (
|
||||
<Combobox.Option
|
||||
key={timezone}
|
||||
value={timezone}
|
||||
className={({ active }) =>
|
||||
`relative cursor-default select-none py-2 pl-10 pr-4 ${
|
||||
active
|
||||
? "bg-mti-purple-light text-white"
|
||||
: "text-gray-900"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{timezone}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</Combobox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
77
src/components/Medium/InviteCard.tsx
Normal file
77
src/components/Medium/InviteCard.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Invite } from "@/interfaces/invite";
|
||||
import { User } from "@/interfaces/user";
|
||||
import axios from "axios";
|
||||
import { useState } from "react";
|
||||
import { BsArrowRepeat } from "react-icons/bs";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
interface Props {
|
||||
invite: Invite;
|
||||
users: User[];
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
export default function InviteCard({ invite, users, reload }: Props) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const inviter = users.find((u) => u.id === invite.from);
|
||||
const name = !inviter
|
||||
? null
|
||||
: inviter.type === "corporate"
|
||||
? inviter.corporateInformation?.companyInformation?.name || inviter.name
|
||||
: inviter.name;
|
||||
|
||||
const decide = (decision: "accept" | "decline") => {
|
||||
if (!confirm(`Are you sure you want to ${decision} this invite?`)) return;
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get(`/api/invites/${decision}/${invite.id}`)
|
||||
.then(() => {
|
||||
toast.success(
|
||||
`Successfully ${decision === "accept" ? "accepted" : "declined"} the invite!`,
|
||||
{ toastId: "success" },
|
||||
);
|
||||
reload();
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.success(`Something went wrong, please try again later!`, {
|
||||
toastId: "error",
|
||||
});
|
||||
reload();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-mti-gray-anti-flash flex min-w-[200px] flex-col gap-6 rounded-xl border p-4 text-black">
|
||||
<span>Invited by {name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => decide("accept")}
|
||||
disabled={isLoading}
|
||||
className="bg-mti-green-ultralight hover:bg-mti-green-light w-24 rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed"
|
||||
>
|
||||
{!isLoading && "Accept"}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => decide("decline")}
|
||||
disabled={isLoading}
|
||||
className="bg-mti-red-ultralight hover:bg-mti-red-light w-24 rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed"
|
||||
>
|
||||
{!isLoading && "Decline"}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
src/components/Medium/ModuleLevelSelector.tsx
Normal file
121
src/components/Medium/ModuleLevelSelector.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import {Module} from "@/interfaces";
|
||||
import {writingMarking} from "@/utils/score";
|
||||
import {Menu} from "@headlessui/react";
|
||||
import {Dispatch, SetStateAction} from "react";
|
||||
import {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||
|
||||
type Levels = {[key in Module]: number};
|
||||
|
||||
interface Props {
|
||||
levels: Levels;
|
||||
setLevels: Dispatch<SetStateAction<Levels>>;
|
||||
}
|
||||
|
||||
export default function ModuleLevelSelector({levels, setLevels}: Props) {
|
||||
return (
|
||||
<div className="flex flex-col gap-32 w-full">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-16">
|
||||
<div className="w-full flex flex-col gap-3.5 relative">
|
||||
<span className="text-sm text-mti-gray-dim">
|
||||
<span className="font-bold">Reading</span> level
|
||||
</span>
|
||||
<Menu>
|
||||
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
||||
<BsBook className="text-ielts-reading" size={34} />
|
||||
<span className="text-mti-gray-cool text-sm">
|
||||
{levels.reading === -1 ? "Select your reading level" : `Level ${levels.reading}`}
|
||||
</span>
|
||||
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
||||
</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">
|
||||
{Object.values(writingMarking).map((x) => (
|
||||
<Menu.Item key={x}>
|
||||
<span
|
||||
onClick={() => setLevels((prev) => ({...prev, reading: x}))}
|
||||
className="w-full py-4 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
||||
Level {x}
|
||||
</span>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-3.5 relative">
|
||||
<span className="text-sm text-mti-gray-dim">
|
||||
<span className="font-bold">Listening</span> level
|
||||
</span>
|
||||
<Menu>
|
||||
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
||||
<BsHeadphones className="text-ielts-listening" size={34} />
|
||||
<span className="text-mti-gray-cool text-sm">
|
||||
{levels.listening === -1 ? "Select your listening level" : `Level ${levels.listening}`}
|
||||
</span>
|
||||
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
||||
</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-50 drop-shadow-lg rounded-2xl">
|
||||
{Object.values(writingMarking).map((x) => (
|
||||
<Menu.Item key={x}>
|
||||
<span
|
||||
onClick={() => setLevels((prev) => ({...prev, listening: x}))}
|
||||
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
||||
Level {x}
|
||||
</span>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-3.5 relative">
|
||||
<span className="text-sm text-mti-gray-dim">
|
||||
<span className="font-bold">Writing</span> level
|
||||
</span>
|
||||
<Menu>
|
||||
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
||||
<BsPen className="text-ielts-writing" size={34} />
|
||||
<span className="text-mti-gray-cool text-sm">
|
||||
{levels.writing === -1 ? "Select your writing level" : `Level ${levels.writing}`}
|
||||
</span>
|
||||
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
||||
</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">
|
||||
{Object.values(writingMarking).map((x) => (
|
||||
<Menu.Item key={x}>
|
||||
<span
|
||||
onClick={() => setLevels((prev) => ({...prev, writing: x}))}
|
||||
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
||||
Level {x}
|
||||
</span>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-3.5 relative">
|
||||
<span className="text-sm text-mti-gray-dim">
|
||||
<span className="font-bold">Speaking</span> level
|
||||
</span>
|
||||
<Menu>
|
||||
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
||||
<BsMegaphone className="text-ielts-speaking" size={34} />
|
||||
<span className="text-mti-gray-cool text-sm">
|
||||
{levels.speaking === -1 ? "Select your speaking level" : `Level ${levels.speaking}`}
|
||||
</span>
|
||||
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
||||
</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">
|
||||
{Object.values(writingMarking).map((x) => (
|
||||
<Menu.Item key={x}>
|
||||
<span
|
||||
onClick={() => setLevels((prev) => ({...prev, speaking: x}))}
|
||||
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
||||
Level {x}
|
||||
</span>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,11 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
|
||||
const [timer, setTimer] = useState(minTimer * 60);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [warningMode, setWarningMode] = useState(false);
|
||||
|
||||
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
|
||||
const {timeSpent} = useExamStore((state) => state);
|
||||
|
||||
useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!disableTimer) {
|
||||
|
||||
101
src/components/Medium/SessionCard.tsx
Normal file
101
src/components/Medium/SessionCard.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import {Session} from "@/hooks/useSessions";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {sortByModuleName} from "@/utils/moduleUtils";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize} from "lodash";
|
||||
import moment from "moment";
|
||||
import {useState} from "react";
|
||||
import {BsArrowRepeat, BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
|
||||
export default function SessionCard({
|
||||
session,
|
||||
reload,
|
||||
loadSession,
|
||||
}: {
|
||||
session: Session;
|
||||
reload: () => void;
|
||||
loadSession: (session: Session) => Promise<void>;
|
||||
}) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const deleteSession = async () => {
|
||||
if (!confirm("Are you sure you want to delete this session?")) return;
|
||||
|
||||
setIsLoading(true);
|
||||
await axios
|
||||
.delete(`/api/sessions/${session.sessionId}`)
|
||||
.then(() => {
|
||||
toast.success(`Successfully delete session "${session.sessionId}"`);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
toast.error("Something went wrong, please try again later");
|
||||
})
|
||||
.finally(() => {
|
||||
reload();
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-mti-gray-anti-flash flex w-64 flex-col gap-3 rounded-xl border p-4 text-black">
|
||||
<span className="flex gap-1">
|
||||
<b>ID:</b>
|
||||
{session.sessionId}
|
||||
</span>
|
||||
<span className="flex gap-1">
|
||||
<b>Date:</b>
|
||||
{moment(session.date).format("DD/MM/YYYY - HH:mm")}
|
||||
</span>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-center justify-center gap-2">
|
||||
{session.selectedModules.sort(sortByModuleName).map((module) => (
|
||||
<div
|
||||
key={module}
|
||||
data-tip={capitalize(module)}
|
||||
className={clsx(
|
||||
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||
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="h-4 w-4" />}
|
||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<button
|
||||
onClick={async () => await loadSession(session)}
|
||||
disabled={isLoading}
|
||||
className="bg-mti-green-ultralight w-full hover:bg-mti-green-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
|
||||
{!isLoading && "Resume"}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={deleteSession}
|
||||
disabled={isLoading}
|
||||
className="bg-mti-red-ultralight w-full hover:bg-mti-red-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
|
||||
{!isLoading && "Delete"}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
src/components/Medium/TopicModal.tsx
Normal file
70
src/components/Medium/TopicModal.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import topics from "@/resources/topics";
|
||||
import {useState} from "react";
|
||||
import {BsArrowLeft, BsArrowRight} from "react-icons/bs";
|
||||
import Button from "../Low/Button";
|
||||
import Modal from "../Modal";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
initialTopics: string[];
|
||||
onClose: VoidFunction;
|
||||
selectTopics: (topics: string[]) => void;
|
||||
}
|
||||
|
||||
export default function TopicModal({isOpen, initialTopics, onClose, selectTopics}: Props) {
|
||||
const [selectedTopics, setSelectedTopics] = useState([...initialTopics]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Preferred Topics">
|
||||
<div className="flex flex-col w-full h-full gap-4 mt-4">
|
||||
<div className="w-full h-full grid grid-cols-2 -md:gap-1 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="border-b border-b-neutral-400/30">Available Topics</span>
|
||||
<div className=" max-h-[500px] overflow-y-scroll scrollbar-hide">
|
||||
{topics
|
||||
.filter((x) => !selectedTopics.includes(x))
|
||||
.map((x) => (
|
||||
<div key={x} className="odd:bg-mti-purple-ultralight/40 p-2 flex justify-between items-center">
|
||||
<span>{x}</span>
|
||||
<button
|
||||
onClick={() => setSelectedTopics((prev) => [...prev, x])}
|
||||
className="border border-mti-purple-light cursor-pointer p-2 rounded-lg bg-white drop-shadow transition ease-in-out duration-300 hover:bg-mti-purple hover:text-white">
|
||||
<BsArrowRight />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="border-b border-b-neutral-400/30">Preferred Topics ({selectedTopics.length || "All"})</span>
|
||||
<div className=" max-h-[500px] overflow-y-scroll scrollbar-hide">
|
||||
{selectedTopics.map((x) => (
|
||||
<div key={x} className="odd:bg-mti-purple-ultralight/40 p-2 flex justify-between items-center text-right">
|
||||
<button
|
||||
onClick={() => setSelectedTopics((prev) => [...prev.filter((y) => y !== x)])}
|
||||
className="border border-mti-purple-light cursor-pointer p-2 rounded-lg bg-white drop-shadow transition ease-in-out duration-300 hover:bg-mti-purple hover:text-white">
|
||||
<BsArrowLeft />
|
||||
</button>
|
||||
<span>{x}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex gap-4 items-center justify-end">
|
||||
<Button variant="outline" color="rose" className="w-full max-w-[200px]" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full max-w-[200px]"
|
||||
onClick={() => {
|
||||
selectTopics(selectedTopics);
|
||||
onClose();
|
||||
}}>
|
||||
Select
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -6,16 +6,17 @@ 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";
|
||||
import {BsXLg} from "react-icons/bs";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
path: string;
|
||||
user: User;
|
||||
disableNavigation?: boolean;
|
||||
}
|
||||
|
||||
export default function MobileMenu({isOpen, onClose, path, user}: Props) {
|
||||
export default function MobileMenu({isOpen, onClose, path, user, disableNavigation}: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const logout = async () => {
|
||||
@@ -48,93 +49,103 @@ export default function MobileMenu({isOpen, onClose, path, user}: Props) {
|
||||
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="/">
|
||||
<Dialog.Panel className="flex h-screen w-full transform flex-col gap-8 overflow-hidden bg-white text-left align-middle text-black shadow-xl transition-all">
|
||||
<Dialog.Title as="header" className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden">
|
||||
<Link href={disableNavigation ? "" : "/"}>
|
||||
<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} />
|
||||
<BsXLg className="text-mti-purple-light text-2xl" onClick={onClose} />
|
||||
</div>
|
||||
</Dialog.Title>
|
||||
<div className="flex flex-col h-full gap-6 px-8 text-lg">
|
||||
<div className="flex h-full flex-col gap-6 px-8 text-lg">
|
||||
<Link
|
||||
href="/"
|
||||
href={disableNavigation ? "" : "/"}
|
||||
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 ",
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Dashboard
|
||||
</Link>
|
||||
{(user.type === "student" || user.type === "teacher" || user.type === "developer") && (
|
||||
<>
|
||||
<Link
|
||||
href="/exam"
|
||||
href={disableNavigation ? "" : "/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 ",
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/exam" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Exams
|
||||
</Link>
|
||||
<Link
|
||||
href="/exercises"
|
||||
href={disableNavigation ? "" : "/exercises"}
|
||||
className={clsx(
|
||||
"transition ease-in-out duration-300 w-fit",
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/exercises" &&
|
||||
"text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Exercises
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<Link
|
||||
href="/stats"
|
||||
href={disableNavigation ? "" : "/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 ",
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/stats" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Stats
|
||||
</Link>
|
||||
<Link
|
||||
href="/record"
|
||||
href={disableNavigation ? "" : "/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 ",
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/record" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Record
|
||||
</Link>
|
||||
{["admin", "developer", "agent", "corporate"].includes(user.type) && (
|
||||
<Link
|
||||
href="/payment-record"
|
||||
href={disableNavigation ? "" : "/payment-record"}
|
||||
className={clsx(
|
||||
"transition ease-in-out duration-300 w-fit",
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/payment-record" &&
|
||||
"text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Payment Record
|
||||
</Link>
|
||||
)}
|
||||
{["admin", "developer", "corporate", "teacher"].includes(user.type) && (
|
||||
<Link
|
||||
href="/settings"
|
||||
href={disableNavigation ? "" : "/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 ",
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/settings" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Settings
|
||||
</Link>
|
||||
)}
|
||||
{["admin", "developer", "agent"].includes(user.type) && (
|
||||
<Link
|
||||
href="/profile"
|
||||
href={disableNavigation ? "" : "/tickets"}
|
||||
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 ",
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/tickets" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Tickets
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/profile"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/profile" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Profile
|
||||
</Link>
|
||||
|
||||
<span
|
||||
className={clsx("transition ease-in-out duration-300 w-fit justify-self-end cursor-pointer")}
|
||||
className={clsx("w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out")}
|
||||
onClick={logout}>
|
||||
Logout
|
||||
</span>
|
||||
|
||||
@@ -11,7 +11,7 @@ interface Props {
|
||||
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}>
|
||||
<Dialog as="div" className="relative z-[200]" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
|
||||
@@ -3,7 +3,7 @@ import Link from "next/link";
|
||||
import FocusLayer from "@/components/FocusLayer";
|
||||
import {preventNavigation} from "@/utils/navigation.disabled";
|
||||
import {useRouter} from "next/router";
|
||||
import {BsList} from "react-icons/bs";
|
||||
import {BsList, BsQuestionCircle, BsQuestionCircleFill} from "react-icons/bs";
|
||||
import clsx from "clsx";
|
||||
import moment from "moment";
|
||||
import MobileMenu from "./MobileMenu";
|
||||
@@ -12,6 +12,10 @@ import {Type} from "@/interfaces/user";
|
||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import {isUserFromCorporate} from "@/utils/groups";
|
||||
import Button from "./Low/Button";
|
||||
import Modal from "./Modal";
|
||||
import Input from "./Low/Input";
|
||||
import TicketSubmission from "./High/TicketSubmission";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
@@ -25,9 +29,11 @@ interface Props {
|
||||
export default function Navbar({user, path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
|
||||
const [isTicketOpen, setIsTicketOpen] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||
const router = useRouter();
|
||||
|
||||
const expirationDateColor = (date: Date) => {
|
||||
const momentDate = moment(date);
|
||||
@@ -48,44 +54,61 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user.type !== "student" && user.type !== "teacher") setDisablePaymentPage(false);
|
||||
if (user.type !== "student" && user.type !== "teacher") return setDisablePaymentPage(false);
|
||||
isUserFromCorporate(user.id).then((result) => setDisablePaymentPage(result));
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{user && <MobileMenu path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />}
|
||||
<header className="w-full bg-transparent py-2 md:py-4 -md:justify-between md:gap-12 flex items-center relative -md:px-4">
|
||||
<Link href={disableNavigation ? "" : "/"} className=" md:px-8 flex gap-8 items-center">
|
||||
<Modal isOpen={isTicketOpen} onClose={() => setIsTicketOpen(false)} title="Submit a ticket">
|
||||
<TicketSubmission user={user} page={router.asPath} onClose={() => setIsTicketOpen(false)} />
|
||||
</Modal>
|
||||
|
||||
{user && (
|
||||
<MobileMenu disableNavigation={disableNavigation} path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />
|
||||
)}
|
||||
<header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4">
|
||||
<Link href={disableNavigation ? "" : "/"} className=" flex items-center gap-8 md:px-8">
|
||||
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" />
|
||||
<h1 className="font-bold text-2xl w-1/6 -md:hidden">EnCoach</h1>
|
||||
<h1 className="-md:hidden w-1/6 text-2xl font-bold">EnCoach</h1>
|
||||
</Link>
|
||||
<div className="flex justify-end -md:items-center gap-4 md:w-5/6 md:mr-8">
|
||||
<div className="flex items-center justify-end gap-4 md:mr-8 md:w-5/6">
|
||||
{/* OPEN TICKET SYSTEM */}
|
||||
<button
|
||||
className={clsx(
|
||||
"border-mti-purple-light tooltip tooltip-bottom flex h-8 w-8 flex-col items-center justify-center rounded-full border p-1",
|
||||
"hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white z-20",
|
||||
)}
|
||||
data-tip="Submit a help/feedback ticket"
|
||||
onClick={() => setIsTicketOpen(true)}>
|
||||
<BsQuestionCircleFill />
|
||||
</button>
|
||||
|
||||
{showExpirationDate() && (
|
||||
<Link
|
||||
href={disablePaymentPage ? "/payment" : ""}
|
||||
href={!!user.subscriptionExpirationDate && !disablePaymentPage ? "/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",
|
||||
"flex w-fit cursor-pointer justify-center rounded-full border px-6 py-2 text-sm font-normal focus:outline-none",
|
||||
"tooltip tooltip-bottom transition duration-300 ease-in-out",
|
||||
!user.subscriptionExpirationDate
|
||||
? "bg-mti-green-ultralight border-mti-green-light"
|
||||
: expirationDateColor(user.subscriptionExpirationDate),
|
||||
"bg-white border-mti-gray-platinum",
|
||||
"border-mti-gray-platinum bg-white",
|
||||
)}>
|
||||
{!user.subscriptionExpirationDate && "Unlimited"}
|
||||
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
|
||||
</Link>
|
||||
)}
|
||||
<Link href={disableNavigation ? "" : "/profile"} className="flex gap-6 items-center justify-end -md:hidden">
|
||||
<img src={user.profilePicture} alt={user.name} className="w-10 h-10 rounded-full object-cover" />
|
||||
<span className="text-right -md:hidden">
|
||||
<Link href={disableNavigation ? "" : "/profile"} className="-md:hidden flex items-center justify-end gap-6">
|
||||
<img src={user.profilePicture} alt={user.name} className="h-10 w-10 rounded-full object-cover" />
|
||||
<span className="-md:hidden text-right">
|
||||
{user.type === "corporate" ? `${user.corporateInformation?.companyInformation.name} |` : ""} {user.name} |{" "}
|
||||
{USER_TYPE_LABELS[user.type]}
|
||||
</span>
|
||||
</Link>
|
||||
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}>
|
||||
<BsList className="text-mti-purple-light w-8 h-8" />
|
||||
<BsList className="text-mti-purple-light h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
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 { 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";
|
||||
import { useState, useEffect } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
interface Props {
|
||||
clientID: string;
|
||||
@@ -11,23 +22,62 @@ interface Props {
|
||||
price: number;
|
||||
duration: number;
|
||||
duration_unit: DurationUnit;
|
||||
loadScript?: boolean;
|
||||
setIsLoading: (isLoading: boolean) => void;
|
||||
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
|
||||
trackingId?: string;
|
||||
}
|
||||
|
||||
export default function PayPalPayment({clientID, price, currency, duration, duration_unit, setIsLoading, onSuccess}: Props) {
|
||||
const createOrder = async (data: CreateOrderData, actions: CreateOrderActions): Promise<string> => {
|
||||
export default function PayPalPayment({
|
||||
clientID,
|
||||
price,
|
||||
currency,
|
||||
duration,
|
||||
duration_unit,
|
||||
loadScript,
|
||||
setIsLoading,
|
||||
onSuccess,
|
||||
trackingId,
|
||||
}: Props) {
|
||||
const createOrder = async (
|
||||
data: CreateOrderData,
|
||||
actions: CreateOrderActions
|
||||
): Promise<string> => {
|
||||
if (!trackingId) {
|
||||
throw new Error("trackingId is not set");
|
||||
}
|
||||
setIsLoading(true);
|
||||
|
||||
return axios
|
||||
.post<OrderResponseBody>("/api/paypal", {currencyCode: currency, price})
|
||||
.post<OrderResponseBody>("/api/paypal", {
|
||||
currencyCode: currency,
|
||||
price,
|
||||
trackingId,
|
||||
})
|
||||
.then((response) => response.data)
|
||||
.then((data) => data.id);
|
||||
.then((data) => {
|
||||
setIsLoading(false);
|
||||
return data.id;
|
||||
})
|
||||
.catch((err) => {
|
||||
setIsLoading(false);
|
||||
return err;
|
||||
});
|
||||
};
|
||||
|
||||
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 (!trackingId) {
|
||||
throw new Error("trackingId is not set");
|
||||
}
|
||||
|
||||
axios
|
||||
.post<{ ok: boolean; reason?: string }>("/api/paypal/approve", {
|
||||
id: data.orderID,
|
||||
duration,
|
||||
duration_unit,
|
||||
trackingId,
|
||||
})
|
||||
.then((request) => {
|
||||
if (request.status !== 200) {
|
||||
toast.error("Something went wrong, please try again later");
|
||||
return;
|
||||
@@ -35,32 +85,54 @@ export default function PayPalPayment({clientID, price, currency, duration, dura
|
||||
|
||||
toast.success("Your account has been credited more time!");
|
||||
return onSuccess(duration, duration_unit);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
toast.error("Something went wrong, please try again later");
|
||||
});
|
||||
};
|
||||
|
||||
const onError = async (data: Record<string, unknown>) => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const onCancel = async (data: Record<string, unknown>, actions: OnCancelledActions) => {
|
||||
const onCancel = async (
|
||||
data: Record<string, unknown>,
|
||||
actions: OnCancelledActions
|
||||
) => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
if (trackingId) {
|
||||
return loadScript ? (
|
||||
<PayPalScriptProvider
|
||||
options={{
|
||||
clientId: clientID,
|
||||
currency,
|
||||
intent: "capture",
|
||||
commit: true,
|
||||
vault: true,
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<PayPalButtons
|
||||
className="w-full"
|
||||
style={{layout: "vertical"}}
|
||||
style={{ layout: "vertical" }}
|
||||
createOrder={createOrder}
|
||||
onApprove={onApprove}
|
||||
onCancel={onCancel}
|
||||
onError={onError}></PayPalButtons>
|
||||
onError={onError}
|
||||
/>
|
||||
</PayPalScriptProvider>
|
||||
) : (
|
||||
<PayPalButtons
|
||||
className="w-full"
|
||||
style={{ layout: "vertical" }}
|
||||
createOrder={createOrder}
|
||||
onApprove={onApprove}
|
||||
onCancel={onCancel}
|
||||
onError={onError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
107
src/components/PaymobPayment.tsx
Normal file
107
src/components/PaymobPayment.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import {PaymentIntention} from "@/interfaces/paymob";
|
||||
import {DurationUnit} from "@/interfaces/paypal";
|
||||
import {User} from "@/interfaces/user";
|
||||
import axios from "axios";
|
||||
import {useRouter} from "next/router";
|
||||
import {useState} from "react";
|
||||
import Button from "./Low/Button";
|
||||
import Input from "./Low/Input";
|
||||
import Modal from "./Modal";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
currency: string;
|
||||
price: number;
|
||||
setIsPaymentLoading: (v: boolean) => void;
|
||||
duration: number;
|
||||
duration_unit: DurationUnit;
|
||||
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
|
||||
}
|
||||
|
||||
export default function PaymobPayment({user, price, setIsPaymentLoading, currency, duration, duration_unit, onSuccess}: Props) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const [firstName, setFirstName] = useState(user.name.split(" ")[0]);
|
||||
const [lastName, setLastName] = useState([...user.name.split(" ")].pop());
|
||||
const [street, setStreet] = useState("");
|
||||
const [apartment, setApartment] = useState("");
|
||||
const [building, setBuilding] = useState("");
|
||||
const [state, setState] = useState("");
|
||||
const [floor, setFloor] = useState("");
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const handleCardPayment = async () => {
|
||||
try {
|
||||
setIsPaymentLoading(true);
|
||||
|
||||
const paymentIntention: PaymentIntention = {
|
||||
amount: price * 1000,
|
||||
currency: "OMR",
|
||||
items: [],
|
||||
payment_methods: [],
|
||||
customer: {
|
||||
email: user.email,
|
||||
first_name: user.name.split(" ")[0],
|
||||
last_name: [...user.name.split(" ")].pop() || "N/A",
|
||||
extras: {
|
||||
re: user.id,
|
||||
},
|
||||
},
|
||||
billing_data: {
|
||||
apartment: apartment || "N/A",
|
||||
building: building || "N/A",
|
||||
country: user.demographicInformation?.country || "N/A",
|
||||
email: user.email,
|
||||
first_name: user.name.split(" ")[0],
|
||||
last_name: [...user.name.split(" ")].pop() || "N/A",
|
||||
floor: floor || "N/A",
|
||||
phone_number: user.demographicInformation?.phone || "N/A",
|
||||
state: state || "N/A",
|
||||
street: street || "N/A",
|
||||
},
|
||||
extras: {
|
||||
userID: user.id,
|
||||
duration,
|
||||
duration_unit,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await axios.post<{iframeURL: string}>(`/api/paymob`, paymentIntention);
|
||||
|
||||
router.push(response.data.iframeURL);
|
||||
setIsModalOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Error starting card payment process:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={isModalOpen} title="Billing Data" onClose={() => setIsModalOpen(false)}>
|
||||
<div className="flex flex-col gap-4 mt-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input label="First Name" value={firstName} onChange={setFirstName} type="text" name="firstName" />
|
||||
<Input label="Last Name" value={lastName} onChange={setLastName} type="text" name="lastName" />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 -md:grid-cols-1 gap-4">
|
||||
<Input label="State" value={state} onChange={setState} type="text" name="state" />
|
||||
<Input label="Street" value={street} onChange={setStreet} type="text" name="street" />
|
||||
<Input label="Building" value={building} onChange={setBuilding} type="text" name="building" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input label="Floor" value={floor} onChange={setFloor} type="text" name="floor" />
|
||||
<Input label="Apartment" value={apartment} onChange={setApartment} type="text" name="apartment" />
|
||||
</div>
|
||||
<Button className="w-full max-w-[200px] self-end mt-4" disabled={!firstName || !lastName} onClick={handleCardPayment}>
|
||||
Complete Payment
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
<Button isLoading={isLoading} onClick={() => setIsModalOpen(true)}>
|
||||
Select
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ interface Props {
|
||||
icon: ReactElement;
|
||||
value: string | number;
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
}[];
|
||||
children?: ReactElement;
|
||||
}
|
||||
@@ -48,7 +49,10 @@ export default function ProfileSummary({user, items}: Props) {
|
||||
<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">
|
||||
<div
|
||||
className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl relative group tooltip tooltip-bottom"
|
||||
data-tip={item.tooltip}
|
||||
>
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
BsShieldFill,
|
||||
BsCloudFill,
|
||||
BsCurrencyDollar,
|
||||
BsClipboardData,
|
||||
} from "react-icons/bs";
|
||||
import {RiLogoutBoxFill} from "react-icons/ri";
|
||||
import {SlPencil} from "react-icons/sl";
|
||||
@@ -20,9 +21,10 @@ import {useRouter} from "next/router";
|
||||
import axios from "axios";
|
||||
import FocusLayer from "@/components/FocusLayer";
|
||||
import {preventNavigation} from "@/utils/navigation.disabled";
|
||||
import {useState} from "react";
|
||||
import {useEffect, useState} from "react";
|
||||
import usePreferencesStore from "@/stores/preferencesStore";
|
||||
import {Type} from "@/interfaces/user";
|
||||
import useTicketsListener from "@/hooks/useTicketsListener";
|
||||
interface Props {
|
||||
path: string;
|
||||
navDisabled?: boolean;
|
||||
@@ -30,6 +32,7 @@ interface Props {
|
||||
onFocusLayerMouseEnter?: () => void;
|
||||
className?: string;
|
||||
userType?: Type;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
interface NavProps {
|
||||
@@ -39,27 +42,43 @@ interface NavProps {
|
||||
keyPath: string;
|
||||
disabled?: boolean;
|
||||
isMinimized?: boolean;
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false}: NavProps) => (
|
||||
const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false, badge}: NavProps) => {
|
||||
return (
|
||||
<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-all duration-300 ease-in-out",
|
||||
"flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white",
|
||||
"transition-all duration-300 ease-in-out relative",
|
||||
disabled ? "hover:bg-mti-gray-dim cursor-not-allowed" : "hover:bg-mti-purple-light cursor-pointer",
|
||||
path === keyPath && "bg-mti-purple-light text-white",
|
||||
isMinimized ? "w-fit" : "w-full min-w-[200px] 2xl:min-w-[220px] px-8",
|
||||
isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]",
|
||||
)}>
|
||||
<Icon size={24} />
|
||||
{!isMinimized && <span className="text-lg font-semibold">{label}</span>}
|
||||
{!!badge && badge > 0 && (
|
||||
<div
|
||||
className={clsx(
|
||||
"bg-mti-purple-light h-5 w-5 text-xs rounded-full flex items-center justify-center text-white",
|
||||
"transition ease-in-out duration-300",
|
||||
isMinimized && "absolute right-0 top-0",
|
||||
)}>
|
||||
{badge}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default function Sidebar({path, navDisabled = false, focusMode = false, userType, onFocusLayerMouseEnter, className}: Props) {
|
||||
export default function Sidebar({path, navDisabled = false, focusMode = false, userType, onFocusLayerMouseEnter, className, userId}: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
|
||||
|
||||
const {totalAssignedTickets} = useTicketsListener(userId);
|
||||
|
||||
const logout = async () => {
|
||||
axios.post("/api/logout").finally(() => {
|
||||
setTimeout(() => router.reload(), 500);
|
||||
@@ -71,11 +90,11 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
||||
return (
|
||||
<section
|
||||
className={clsx(
|
||||
"h-full flex bg-transparent flex-col justify-between px-4 py-4 pb-8 relative",
|
||||
isMinimized ? "w-fit" : "w-1/6 -xl:w-fit",
|
||||
"relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8",
|
||||
isMinimized ? "w-fit" : "-xl:w-fit w-1/6",
|
||||
className,
|
||||
)}>
|
||||
<div className="xl:flex -xl:hidden flex-col gap-3">
|
||||
<div className="-xl:hidden flex-col gap-3 xl:flex">
|
||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
|
||||
{(userType === "student" || userType === "teacher" || userType === "developer") && (
|
||||
<>
|
||||
@@ -97,8 +116,12 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{(userType || "") !== 'agent' && (
|
||||
<>
|
||||
<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", "corporate"].includes(userType || "") && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
@@ -119,6 +142,17 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{["admin", "developer", "agent"].includes(userType || "") && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsClipboardData}
|
||||
label="Tickets"
|
||||
path={path}
|
||||
keyPath="/tickets"
|
||||
isMinimized={isMinimized}
|
||||
badge={totalAssignedTickets}
|
||||
/>
|
||||
)}
|
||||
{userType === "developer" && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
@@ -130,12 +164,16 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="xl:hidden -xl:flex flex-col gap-3">
|
||||
<div className="-xl:flex flex-col gap-3 xl:hidden">
|
||||
<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} />
|
||||
{(userType || "") !== 'agent' && (
|
||||
<>
|
||||
<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} />
|
||||
)}
|
||||
@@ -144,13 +182,13 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-0 bottom-12 fixed">
|
||||
<div className="fixed bottom-12 flex flex-col gap-0">
|
||||
<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",
|
||||
"hover:text-mti-rose -xl:hidden flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
|
||||
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
||||
)}>
|
||||
{isMinimized ? <BsChevronBarRight size={24} /> : <BsChevronBarLeft size={24} />}
|
||||
@@ -161,11 +199,11 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
||||
tabIndex={1}
|
||||
onClick={focusMode ? () => {} : logout}
|
||||
className={clsx(
|
||||
"p-4 rounded-full flex gap-4 items-center cursor-pointer text-black hover:text-mti-rose transition duration-300 ease-in-out",
|
||||
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
|
||||
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
||||
)}>
|
||||
<RiLogoutBoxFill size={24} />
|
||||
{!isMinimized && <span className="text-lg font-medium -xl:hidden">Log Out</span>}
|
||||
{!isMinimized && <span className="-xl:hidden text-lg font-medium">Log Out</span>}
|
||||
</div>
|
||||
</div>
|
||||
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
||||
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||
<span className="text-sm w-full leading-6">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
|
||||
@@ -8,6 +8,8 @@ import axios from "axios";
|
||||
import {speakingReverseMarking} from "@/utils/score";
|
||||
import {Tab} from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import Modal from "../Modal";
|
||||
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
|
||||
|
||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||
|
||||
@@ -22,9 +24,10 @@ export default function InteractiveSpeaking({
|
||||
onBack,
|
||||
}: InteractiveSpeakingExercise & CommonProps) {
|
||||
const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
|
||||
const [diffNumber, setDiffNumber] = useState<0 | 1 | 2 | 3>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (userSolutions && userSolutions.length > 0) {
|
||||
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
|
||||
Promise.all(userSolutions[0].solution.map((x) => axios.post(`/api/speaking`, {path: x.answer}, {responseType: "arraybuffer"}))).then(
|
||||
(values) => {
|
||||
setSolutionsURL(
|
||||
@@ -42,6 +45,44 @@ export default function InteractiveSpeaking({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal title={`Correction (Prompt ${diffNumber})`} isOpen={diffNumber !== 0} onClose={() => setDiffNumber(0)}>
|
||||
<>
|
||||
{userSolutions &&
|
||||
userSolutions.length > 0 &&
|
||||
diffNumber !== 0 &&
|
||||
userSolutions[0].evaluation &&
|
||||
userSolutions[0].evaluation[`transcript_${diffNumber}`] &&
|
||||
userSolutions[0].evaluation[`fixed_text_${diffNumber}`] && (
|
||||
<div className="w-full h-full rounded-xl overflow-hidden flex flex-col mt-4">
|
||||
<div className="w-full grid grid-cols-2 bg-neutral-100">
|
||||
<span className="p-3 font-medium text-lg border-r border-r-neutral-200">Transcript</span>
|
||||
<span className="p-3 font-medium text-lg border-l border-l-neutral-200">Recommended Improvements</span>
|
||||
</div>
|
||||
<ReactDiffViewer
|
||||
styles={{
|
||||
contentText: {
|
||||
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
|
||||
padding: "32px 28px",
|
||||
},
|
||||
marker: {display: "none"},
|
||||
diffRemoved: {padding: "32px 28px"},
|
||||
diffAdded: {padding: "32px 28px"},
|
||||
|
||||
wordRemoved: {padding: "0px", display: "initial"},
|
||||
wordAdded: {padding: "0px", display: "initial"},
|
||||
wordDiff: {padding: "0px", display: "initial"},
|
||||
}}
|
||||
oldValue={userSolutions[0].evaluation[`transcript_${diffNumber}`]?.replaceAll("\\n", "\n")}
|
||||
newValue={userSolutions[0].evaluation[`fixed_text_${diffNumber}`]?.replaceAll("\\n", "\n")}
|
||||
splitView
|
||||
hideLineNumbers
|
||||
showDiffOnly={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</Modal>
|
||||
|
||||
<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">
|
||||
@@ -67,10 +108,23 @@ export default function InteractiveSpeaking({
|
||||
{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">
|
||||
className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex flex-col gap-4">
|
||||
<div className="flex gap-8 items-center justify-center py-8">
|
||||
<Waveform audio={x} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||
</div>
|
||||
{userSolutions &&
|
||||
userSolutions.length > 0 &&
|
||||
userSolutions[0].evaluation &&
|
||||
userSolutions[0].evaluation[`transcript_${(index + 1) as 1 | 2 | 3}`] &&
|
||||
userSolutions[0].evaluation[`fixed_text_${(index + 1) as 1 | 2 | 3}`] && (
|
||||
<Button
|
||||
className="w-full max-w-[180px] !py-2 self-center"
|
||||
color="pink"
|
||||
variant="outline"
|
||||
onClick={() => setDiffNumber((index + 1) as 1 | 2 | 3)}>
|
||||
View Correction
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -185,7 +239,11 @@ export default function InteractiveSpeaking({
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: userSolutions,
|
||||
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
||||
score: {
|
||||
total: 100,
|
||||
missing: 0,
|
||||
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
|
||||
},
|
||||
type,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {MatchSentencesExercise} from "@/interfaces/exam";
|
||||
import {MatchSentenceExerciseSentence, MatchSentencesExercise} from "@/interfaces/exam";
|
||||
import clsx from "clsx";
|
||||
import LineTo from "react-lineto";
|
||||
import {CommonProps} from ".";
|
||||
@@ -9,6 +9,48 @@ import {Fragment} from "react";
|
||||
import Button from "../Low/Button";
|
||||
import Xarrow from "react-xarrows";
|
||||
|
||||
function QuestionSolutionArea({
|
||||
question,
|
||||
userSolution,
|
||||
}: {
|
||||
question: MatchSentenceExerciseSentence;
|
||||
userSolution?: {question: string; option: string};
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="flex items-center gap-3 cursor-pointer col-span-2">
|
||||
<button
|
||||
className={clsx(
|
||||
"text-white w-8 h-8 rounded-full z-10",
|
||||
!userSolution
|
||||
? "bg-mti-gray-davy"
|
||||
: userSolution.option.toString() === question.solution.toString()
|
||||
? "bg-mti-purple"
|
||||
: "bg-mti-rose",
|
||||
"transition duration-300 ease-in-out",
|
||||
)}>
|
||||
{question.id}
|
||||
</button>
|
||||
<span>{question.sentence}</span>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
"w-56 h-10 border rounded-xl items-center justify-center flex gap-3 px-2",
|
||||
!userSolution
|
||||
? "border-mti-gray-davy"
|
||||
: userSolution.option.toString() === question.solution.toString()
|
||||
? "border-mti-purple"
|
||||
: "border-mti-rose",
|
||||
)}>
|
||||
<span className="line-through">
|
||||
{userSolution && userSolution?.option.toString() !== question.solution.toString() && `Paragraph ${userSolution.option}`}
|
||||
</span>
|
||||
<span className="font-semibold">Paragraph {question.solution}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MatchSentencesSolutions({
|
||||
id,
|
||||
type,
|
||||
@@ -31,7 +73,7 @@ export default function MatchSentencesSolutions({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
||||
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||
<span className="text-sm w-full leading-6">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
@@ -40,57 +82,18 @@ export default function MatchSentencesSolutions({
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
<div className="flex gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
|
||||
<div className="flex flex-col gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
{sentences.map(({sentence, id, solution}) => (
|
||||
<div key={`question_${id}`} className="flex items-center justify-end gap-2 cursor-pointer">
|
||||
<span>{sentence} </span>
|
||||
<button
|
||||
id={id}
|
||||
className={clsx(
|
||||
"w-8 h-8 rounded-full z-10 text-white",
|
||||
"transition duration-300 ease-in-out",
|
||||
!userSolutions.find((x) => x.question.toString() === id.toString()) && "!bg-mti-gray-davy",
|
||||
userSolutions.find((x) => x.question.toString() === id.toString())?.option === solution && "bg-mti-purple",
|
||||
userSolutions.find((x) => x.question.toString() === id.toString())?.option !== solution && "bg-mti-rose",
|
||||
)}>
|
||||
{id}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{options.map(({sentence, id}) => (
|
||||
<div key={`answer_${id}`} className={clsx("flex items-center justify-start gap-2 cursor-pointer")}>
|
||||
<button
|
||||
id={id}
|
||||
className={clsx(
|
||||
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
||||
"transition duration-300 ease-in-out",
|
||||
)}>
|
||||
{id}
|
||||
</button>
|
||||
<span>{sentence}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{userSolutions &&
|
||||
sentences.map((sentence, index) => (
|
||||
<Xarrow
|
||||
key={index}
|
||||
start={sentence.id}
|
||||
end={sentence.solution}
|
||||
lineColor={
|
||||
!userSolutions.find((x) => x.question === sentence.id)
|
||||
? "#CC5454"
|
||||
: userSolutions.find((x) => x.question === sentence.id)?.option === sentence.solution
|
||||
? "#7872BF"
|
||||
: "#CC5454"
|
||||
}
|
||||
showHead={false}
|
||||
{sentences.map((question) => (
|
||||
<QuestionSolutionArea
|
||||
question={question}
|
||||
userSolution={userSolutions.find((x) => x.question.toString() === question.id.toString())}
|
||||
key={`question_${question.id}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-4 h-4 rounded-full bg-mti-purple" /> Correct
|
||||
|
||||
@@ -6,6 +6,7 @@ import {CommonProps} from ".";
|
||||
import Button from "../Low/Button";
|
||||
|
||||
function Question({
|
||||
id,
|
||||
variant,
|
||||
prompt,
|
||||
solution,
|
||||
@@ -26,7 +27,9 @@ function Question({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<span>{prompt}</span>
|
||||
<span>
|
||||
{id} - {prompt}
|
||||
</span>
|
||||
<div className="grid grid-cols-4 gap-4 place-items-center">
|
||||
{variant === "image" &&
|
||||
options.map((option) => (
|
||||
|
||||
@@ -8,14 +8,21 @@ import axios from "axios";
|
||||
import {speakingReverseMarking} from "@/utils/score";
|
||||
import {Tab} from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import Modal from "../Modal";
|
||||
import {BsQuestionCircleFill} from "react-icons/bs";
|
||||
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
|
||||
|
||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||
|
||||
export default function Speaking({id, type, title, video_url, text, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
|
||||
const [solutionURL, setSolutionURL] = useState<string>();
|
||||
const [showDiff, setShowDiff] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (userSolutions && userSolutions.length > 0) {
|
||||
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
|
||||
const solution = userSolutions[0].solution;
|
||||
|
||||
if (solution.startsWith("https://")) return setSolutionURL(solution);
|
||||
axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => {
|
||||
const blob = new Blob([data], {type: "audio/wav"});
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -27,6 +34,42 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal title="Correction" isOpen={showDiff} onClose={() => setShowDiff(false)}>
|
||||
<>
|
||||
{userSolutions &&
|
||||
userSolutions.length > 0 &&
|
||||
userSolutions[0].evaluation?.transcript_1 &&
|
||||
userSolutions[0].evaluation?.fixed_text_1 && (
|
||||
<div className="w-full h-full rounded-xl overflow-hidden flex flex-col mt-4">
|
||||
<div className="w-full grid grid-cols-2 bg-neutral-100">
|
||||
<span className="p-3 font-medium text-lg border-r border-r-neutral-200">Transcript</span>
|
||||
<span className="p-3 font-medium text-lg border-l border-l-neutral-200">Recommended Improvements</span>
|
||||
</div>
|
||||
<ReactDiffViewer
|
||||
styles={{
|
||||
contentText: {
|
||||
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
|
||||
padding: "32px 28px",
|
||||
},
|
||||
marker: {display: "none"},
|
||||
diffRemoved: {padding: "32px 28px"},
|
||||
diffAdded: {padding: "32px 28px"},
|
||||
|
||||
wordRemoved: {padding: "0px", display: "initial"},
|
||||
wordAdded: {padding: "0px", display: "initial"},
|
||||
wordDiff: {padding: "0px", display: "initial"},
|
||||
}}
|
||||
oldValue={userSolutions[0].evaluation.transcript_1.replaceAll("\\n", "\n")}
|
||||
newValue={userSolutions[0].evaluation.fixed_text_1.replaceAll("\\n", "\n")}
|
||||
splitView
|
||||
hideLineNumbers
|
||||
showDiffOnly={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</Modal>
|
||||
|
||||
<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 gap-3">
|
||||
@@ -65,10 +108,19 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full h-full flex flex-col gap-8">
|
||||
<div 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="w-full h-full flex flex-col gap-8 relative">
|
||||
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center relative">
|
||||
<div className="flex gap-8 items-center justify-center py-8">
|
||||
{solutionURL && <Waveform audio={solutionURL} waveColor="#FCDDEC" progressColor="#EF5DA8" />}
|
||||
|
||||
{userSolutions &&
|
||||
userSolutions.length > 0 &&
|
||||
userSolutions[0].evaluation?.transcript_1 &&
|
||||
userSolutions[0].evaluation?.fixed_text_1 && (
|
||||
<Button className="w-full max-w-[180px] !py-2" color="pink" variant="outline" onClick={() => setShowDiff(true)}>
|
||||
View Correction
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||
@@ -152,7 +204,11 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: userSolutions,
|
||||
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
||||
score: {
|
||||
total: 100,
|
||||
missing: 0,
|
||||
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
|
||||
},
|
||||
type,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
||||
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||
<span className="text-sm w-full leading-6">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
|
||||
@@ -107,7 +107,7 @@ export default function WriteBlanksSolutions({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
||||
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||
<span className="text-sm w-full leading-6">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
|
||||
@@ -6,30 +6,11 @@ import Button from "../Low/Button";
|
||||
import {Dialog, Tab, Transition} from "@headlessui/react";
|
||||
import {writingReverseMarking} from "@/utils/score";
|
||||
import clsx from "clsx";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
|
||||
|
||||
export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const formatSolution = (solution: string, errors: {correction: string | null; misspelled: string}[]) => {
|
||||
const errorRegex = new RegExp(errors.map((x) => `(${x.misspelled})`).join("|"));
|
||||
|
||||
return (
|
||||
<>
|
||||
{reactStringReplace(solution, errorRegex, (match) => {
|
||||
const correction = errors.find((x) => x.misspelled === match)?.correction;
|
||||
|
||||
return (
|
||||
<span
|
||||
data-tip={correction ? correction : undefined}
|
||||
className={clsx("text-mti-red-light font-medium underline underline-offset-2", correction && "tooltip")}>
|
||||
{match}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
const [showDiff, setShowDiff] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -86,16 +67,51 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
||||
|
||||
<div className="w-full h-full flex flex-col gap-8">
|
||||
{userSolutions && userSolutions.length > 0 && (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex flex-col gap-4 w-full relative">
|
||||
{!showDiff && (
|
||||
<>
|
||||
<span>Your answer:</span>
|
||||
<div className="w-full h-full min-h-[320px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl whitespace-pre-wrap">
|
||||
{userSolutions[0]!.evaluation && userSolutions[0]!.evaluation.misspelled_pairs
|
||||
? formatSolution(
|
||||
userSolutions[0]!.solution.replaceAll("\\n", "\n"),
|
||||
userSolutions[0]!.evaluation.misspelled_pairs,
|
||||
)
|
||||
: userSolutions[0]!.solution.replaceAll("\\n", "\n")}
|
||||
{userSolutions[0]!.solution.replaceAll("\\n", "\n")}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{showDiff && userSolutions[0].evaluation && (
|
||||
<>
|
||||
<span>Correction:</span>
|
||||
<div className="w-full h-full max-h-[320px] overflow-y-scroll scrollbar-hide cursor-text border-2 overflow-x-hidden border-mti-gray-platinum bg-white rounded-3xl">
|
||||
<ReactDiffViewer
|
||||
styles={{
|
||||
contentText: {
|
||||
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
|
||||
padding: "32px 28px",
|
||||
},
|
||||
marker: {display: "none"},
|
||||
diffRemoved: {padding: "32px 28px"},
|
||||
diffAdded: {padding: "32px 28px"},
|
||||
|
||||
wordRemoved: {padding: "0px", display: "initial"},
|
||||
wordAdded: {padding: "0px", display: "initial"},
|
||||
wordDiff: {padding: "0px", display: "initial"},
|
||||
}}
|
||||
oldValue={userSolutions[0].solution.replaceAll("\\n", "\n")}
|
||||
newValue={userSolutions[0].evaluation!.fixed_text!.replaceAll("\\n", "\n")}
|
||||
splitView
|
||||
hideLineNumbers
|
||||
showDiffOnly={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{userSolutions[0].solution && userSolutions[0].evaluation?.fixed_text && (
|
||||
<Button
|
||||
color="green"
|
||||
variant="outline"
|
||||
className="w-full max-w-[200px] self-end absolute -top-4 right-0 !py-2"
|
||||
onClick={() => setShowDiff((prev) => !prev)}>
|
||||
{showDiff ? "View answer" : "View correction"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||
@@ -175,7 +191,11 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: userSolutions,
|
||||
score: {total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
||||
score: {
|
||||
total: 100,
|
||||
missing: 0,
|
||||
correct: userSolutions[0]?.evaluation ? writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
|
||||
},
|
||||
type,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -30,20 +30,28 @@ export interface CommonProps {
|
||||
export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: () => void, updateIndex?: (internalIndex: number) => void) => {
|
||||
switch (exercise.type) {
|
||||
case "fillBlanks":
|
||||
return <FillBlanks {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "trueFalse":
|
||||
return <TrueFalseSolution {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <TrueFalseSolution key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "matchSentences":
|
||||
return <MatchSentences {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "multipleChoice":
|
||||
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} updateIndex={updateIndex} onNext={onNext} onBack={onBack} />;
|
||||
return (
|
||||
<MultipleChoice
|
||||
key={exercise.id}
|
||||
{...(exercise as MultipleChoiceExercise)}
|
||||
updateIndex={updateIndex}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
case "writeBlanks":
|
||||
return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "writing":
|
||||
return <Writing {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "speaking":
|
||||
return <Speaking {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "interactiveSpeaking":
|
||||
return <InteractiveSpeaking {...(exercise as InteractiveSpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <InteractiveSpeaking key={exercise.id} {...(exercise as InteractiveSpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import useStats from "@/hooks/useStats";
|
||||
import {EMPLOYMENT_STATUS, User} from "@/interfaces/user";
|
||||
import {CorporateInformation, CorporateUser, EMPLOYMENT_STATUS, User} from "@/interfaces/user";
|
||||
import {groupBySession, averageScore} from "@/utils/stats";
|
||||
import {RadioGroup} from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
@@ -8,7 +8,7 @@ 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 {BsFileEarmarkText, BsPencil, BsPerson, BsPersonAdd, BsStar} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import Button from "./Low/Button";
|
||||
import Checkbox from "./Low/Checkbox";
|
||||
@@ -19,6 +19,7 @@ import Select from "react-select";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||
import {CURRENCIES} from "@/resources/paypal";
|
||||
import useCodes from "@/hooks/useCodes";
|
||||
|
||||
const expirationDateColor = (date: Date) => {
|
||||
const momentDate = moment(date);
|
||||
@@ -37,6 +38,9 @@ interface Props {
|
||||
onViewTeachers?: () => void;
|
||||
onViewCorporate?: () => void;
|
||||
disabled?: boolean;
|
||||
disabledFields?: {
|
||||
countryManager?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const USER_STATUS_OPTIONS = [
|
||||
@@ -59,9 +63,12 @@ const USER_TYPE_OPTIONS = Object.keys(USER_TYPE_LABELS).map((type) => ({
|
||||
label: USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS],
|
||||
}));
|
||||
|
||||
const CURRENCIES_OPTIONS = CURRENCIES.map(({label, currency}) => ({value: currency, label}));
|
||||
const CURRENCIES_OPTIONS = CURRENCIES.map(({label, currency}) => ({
|
||||
value: currency,
|
||||
label,
|
||||
}));
|
||||
|
||||
const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers, onViewCorporate, disabled = false}: Props) => {
|
||||
const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers, onViewCorporate, disabled = false, disabledFields = {}}: Props) => {
|
||||
const [expiryDate, setExpiryDate] = useState<Date | null | undefined>(user.subscriptionExpirationDate);
|
||||
const [type, setType] = useState(user.type);
|
||||
const [status, setStatus] = useState(user.status);
|
||||
@@ -77,6 +84,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
? user.agentInformation?.companyName
|
||||
: undefined,
|
||||
);
|
||||
const [arabName, setArabName] = useState(user.type === "agent" ? user.agentInformation?.companyArabName : undefined);
|
||||
const [commercialRegistration, setCommercialRegistration] = useState(
|
||||
user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined,
|
||||
);
|
||||
@@ -87,6 +95,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
const [commissionValue, setCommission] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.commission : undefined);
|
||||
const {stats} = useStats(user.id);
|
||||
const {users} = useUsers();
|
||||
const {codes} = useCodes(user.id);
|
||||
|
||||
useEffect(() => {
|
||||
if (users && users.length > 0) {
|
||||
@@ -114,8 +123,9 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
agentInformation:
|
||||
type === "agent"
|
||||
? {
|
||||
name: companyName,
|
||||
companyName,
|
||||
commercialRegistration,
|
||||
arabName,
|
||||
}
|
||||
: undefined,
|
||||
corporateInformation:
|
||||
@@ -144,11 +154,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProfileSummary
|
||||
user={user}
|
||||
items={[
|
||||
const generalProfileItems = [
|
||||
{
|
||||
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||
value: Object.keys(groupBySession(stats)).length,
|
||||
@@ -157,25 +163,54 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
{
|
||||
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||
value: stats.length,
|
||||
label: "Exercises",
|
||||
label: "Modules",
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
];
|
||||
|
||||
const corporateProfileItems =
|
||||
user.type === "corporate"
|
||||
? [
|
||||
{
|
||||
icon: <BsPerson className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||
value: codes.length,
|
||||
label: "Users Used",
|
||||
},
|
||||
{
|
||||
icon: <BsPersonAdd className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||
value: user.corporateInformation.companyInformation.userAmount,
|
||||
label: "Number of Users",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProfileSummary user={user} items={user.type === "corporate" ? corporateProfileItems : generalProfileItems} />
|
||||
|
||||
{user.type === "agent" && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 w-full">
|
||||
<Input
|
||||
label="Corporate Name"
|
||||
label="Company Name (Arabic)"
|
||||
type="text"
|
||||
name="arabName"
|
||||
onChange={setArabName}
|
||||
placeholder="Enter their company's name in arabic"
|
||||
defaultValue={arabName}
|
||||
required
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
label="Company Name (English)"
|
||||
type="text"
|
||||
name="companyName"
|
||||
onChange={setCompanyName}
|
||||
placeholder="Enter corporate name"
|
||||
placeholder="Enter their company's name in english"
|
||||
defaultValue={companyName}
|
||||
required
|
||||
disabled={disabled}
|
||||
@@ -224,9 +259,9 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
defaultValue={monthlyDuration}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="flex flex-col gap-3 w-full lg:col-span-2">
|
||||
<div className="flex flex-col gap-3 w-full lg:col-span-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Pricing</label>
|
||||
<div className="w-full grid grid-cols-5 gap-2">
|
||||
<div className="w-full grid grid-cols-6 gap-2">
|
||||
<Input
|
||||
name="paymentValue"
|
||||
onChange={(e) => setPaymentValue(e ? parseInt(e) : undefined)}
|
||||
@@ -237,13 +272,15 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
/>
|
||||
<Select
|
||||
className={clsx(
|
||||
"px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
|
||||
"px-4 py-4 col-span-3 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
|
||||
disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
|
||||
)}
|
||||
options={CURRENCIES_OPTIONS}
|
||||
value={CURRENCIES_OPTIONS.find((c) => c.value === paymentCurrency)}
|
||||
onChange={(value) => setPaymentCurrency(value?.value)}
|
||||
menuPortalTarget={document?.body}
|
||||
styles={{
|
||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
@@ -271,19 +308,26 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
<Select
|
||||
className={clsx(
|
||||
"px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
|
||||
!["developer", "admin"].includes(loggedInUser.type) &&
|
||||
(!["developer", "admin"].includes(loggedInUser.type) || disabledFields.countryManager) &&
|
||||
"!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
|
||||
)}
|
||||
options={[
|
||||
{value: "", label: "No referral"},
|
||||
...users.filter((u) => u.type === "agent").map((x) => ({value: x.id, label: `${x.name} - ${x.email}`})),
|
||||
...users
|
||||
.filter((u) => u.type === "agent")
|
||||
.map((x) => ({
|
||||
value: x.id,
|
||||
label: `${x.name} - ${x.email}`,
|
||||
})),
|
||||
]}
|
||||
defaultValue={{
|
||||
value: referralAgent,
|
||||
label: referralAgentLabel,
|
||||
}}
|
||||
menuPortalTarget={document?.body}
|
||||
onChange={(value) => setReferralAgent(value?.value)}
|
||||
styles={{
|
||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
@@ -300,7 +344,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
}),
|
||||
}}
|
||||
// editing country manager should only be available for dev/admin
|
||||
isDisabled={!["developer", "admin"].includes(loggedInUser.type)}
|
||||
isDisabled={!["developer", "admin"].includes(loggedInUser.type) || disabledFields.countryManager}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -314,7 +358,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
type="number"
|
||||
defaultValue={commissionValue || 0}
|
||||
className="col-span-3"
|
||||
disabled={disabled}
|
||||
disabled={disabled || loggedInUser.type === "agent"}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@@ -520,6 +564,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
<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={USER_STATUS_OPTIONS}
|
||||
menuPortalTarget={document?.body}
|
||||
value={USER_STATUS_OPTIONS.find((o) => o.value === status)}
|
||||
onChange={(value) => setStatus(value?.value as typeof user.status)}
|
||||
styles={{
|
||||
@@ -532,6 +577,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
@@ -546,6 +592,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
<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={USER_TYPE_OPTIONS}
|
||||
menuPortalTarget={document?.body}
|
||||
value={USER_TYPE_OPTIONS.find((o) => o.value === type)}
|
||||
onChange={(value) => setType(value?.value as typeof user.type)}
|
||||
styles={{
|
||||
@@ -558,6 +605,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
@@ -574,17 +622,17 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
|
||||
<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 && (
|
||||
{onViewCorporate && ["student", "teacher"].includes(user.type) && (
|
||||
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewCorporate}>
|
||||
View Corporate
|
||||
</Button>
|
||||
)}
|
||||
{onViewStudents && (
|
||||
{onViewStudents && ["corporate", "teacher"].includes(user.type) && (
|
||||
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewStudents}>
|
||||
View Students
|
||||
</Button>
|
||||
)}
|
||||
{onViewTeachers && (
|
||||
{onViewTeachers && ["student", "corporate"].includes(user.type) && (
|
||||
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewTeachers}>
|
||||
View Teachers
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type Error = "E001" | "E002";
|
||||
export type Error = "E001" | "E002" | "E003";
|
||||
export interface ErrorMessage {
|
||||
error: Error;
|
||||
message: string;
|
||||
@@ -7,4 +7,5 @@ export interface ErrorMessage {
|
||||
export const errorMessages: {[key in Error]: string} = {
|
||||
E001: "Wrong password!",
|
||||
E002: "Invalid e-mail",
|
||||
E003: "E-mail already in use!",
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"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",
|
||||
"project_id": "storied-phalanx-349916",
|
||||
"private_key_id": "c9e05f6fe413b1031a71f981160075ff4b044444",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDdgavFB63nMHyb\n38ncwijTrUmqU9UyzNJ8wlZCWAWuoz25Gng988fkKNDXnHY+ap9esHyNYg9IdSA7\nAuZeHpzTZmKiWZzFWq61KWSTgIn1JwKHGHJJdmVhTYfCe9I51cFLa5q2lTFzJ0ce\nbP7/X/7kw53odgva+M8AhDTbe60akpemgZc+LFwO0Abm7erH2HiNyjoNZzNw525L\n933PCaQwhZan04s1u0oRdVlBIBwMk+J0ojgVEpUiJOzF7gkN+UpDXujalLYdlR4q\nhkGgScXQhDYJkECC3GuvOnEo1YXGNjW9D73S6sSH+Lvqta4wW1+sTn0kB6goiQBI\n7cA1G6x3AgMBAAECggEAZPMwAX/adb7XS4LWUNH8IVyccg/63kgSteErxtiu3kRv\nYOj7W+C6fPVNGLap/RBCybjNSvIh3PfkVICh1MtG1eGXmj4VAKyvaskOmVq/hQbe\nVAuEKo7W7V2UPcKIsOsGSQUlYYjlHIIOG4O5Q1HQrRmp4cPK62Txkl6uaEkZPz4u\nbvIK2BJI8aHRwxE3Phw09blwlLqQQQ8nrhK29x5puaN+ft++IlzIOVsLz+n4kTdB\n6qkG/dhenn3K8o3+NkmSN6eNRbdJd36zXTo4Oatbvqb7r0E8vYn/3Llawo2X75zn\nec7jMHrOmcwtiu9H3PsrTWtzdSjxPHy0UtEn1HWK4QKBgQD+c/V8tAvbaUGVoZf6\ntKtDSKF6IHuY2vUO33v950mVdjrTursqOG2d+SLfSnKpc+sjDlj7/S5u4uRP+qUN\ng1rb2U7oIA7tsDa2ZTSkIx6HkPUzS+fBOxELLrbgMoJ2RLzgkiPhS95YgXJ/rYG5\nWQTehzCT5roes0RvtgM0gl3EhQKBgQDe2m7PRIU4g3RJ8HTx92B4ja8W9FVCYDG5\nPOAdZB8WB6Bvu4BJHBDLr8vDi930pKj+vYObRqBDQuILW4t8wZQJ834dnoq6EpUz\nhbVEURVBP4A/nEHrQHfq0Lp+cxThy2rw7obRQOLPETtC7p3WFgSHT6PRTcpGzCCX\n+76a30yrywKBgC/5JNtyBppDaf4QDVtTHMb+tpMT9LmI7pLzR6lDJfhr5gNtPURk\nhyY1hoGaw6t3E2n0lopL3alCVdFObDfz//lbKylQggAGLQqOYjJf/K2KgvA862Df\nBgOZtxjl7PrnUsT0SJd9elotbazsxXxwcB6UVnBMG+MV4V0+b7RCr/MRAoGBAIfp\nTcVIs7roqOZjKN9dEE/VkR/9uXW2tvyS/NfP9Ql5c0ZRYwazgCbJOwsyZRZLyek6\naWYsp5b91mA435QhdwiuoI6t30tmA+qdNBTLIpxdfvjMcoNoGPpzfBmcU/L1HW58\n+mnqGalRiAPlBQvI99ASKQWAXMnaulIWrYNEhj0LAoGBALi+QZ2pp+hDeC59ezWr\nbP1zbbONceHKGgJcevChP2k1OJyIOIqmBYeTuM4cPc5ofZYQNaMC31cs8SVeSRX1\nNTxQZmvCjMyTe/WYWYNFXdgkVz4egFXbeochCGzMYo57HV1PCkPBrARRZO8OfdDD\n8sDu//ohb7nCzceEI0DnWs13\n-----END PRIVATE KEY-----\n",
|
||||
"client_email": "firebase-adminsdk-3ml0u@storied-phalanx-349916.iam.gserviceaccount.com",
|
||||
"client_id": "114163760341944984396",
|
||||
"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",
|
||||
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-3ml0u%40storied-phalanx-349916.iam.gserviceaccount.com",
|
||||
"universe_domain": "googleapis.com"
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export const PERMISSIONS = {
|
||||
developer: ["developer"],
|
||||
},
|
||||
deleteUser: {
|
||||
student: ["teacher", "corporate", "developer", "admin"],
|
||||
student: ["corporate", "developer", "admin"],
|
||||
teacher: ["corporate", "developer", "admin"],
|
||||
corporate: ["admin", "developer"],
|
||||
admin: ["developer", "admin"],
|
||||
@@ -18,8 +18,8 @@ export const PERMISSIONS = {
|
||||
developer: ["developer"],
|
||||
},
|
||||
updateUser: {
|
||||
student: ["teacher", "corporate", "developer", "admin"],
|
||||
teacher: ["corporate", "developer", "admin"],
|
||||
student: ["developer", "admin"],
|
||||
teacher: ["developer", "admin"],
|
||||
corporate: ["admin", "developer"],
|
||||
admin: ["developer", "admin"],
|
||||
agent: ["developer", "admin"],
|
||||
|
||||
@@ -7,13 +7,24 @@ 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, BsCurrencyDollar} from "react-icons/bs";
|
||||
import {
|
||||
BsArrowLeft,
|
||||
BsBriefcaseFill,
|
||||
BsGlobeCentralSouthAsia,
|
||||
BsPerson,
|
||||
BsPersonFill,
|
||||
BsPencilSquare,
|
||||
BsBank,
|
||||
BsCurrencyDollar,
|
||||
BsLayoutWtf,
|
||||
BsLayoutSidebar,
|
||||
} 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";
|
||||
import usePaymentStatusUsers from '@/hooks/usePaymentStatusUsers';
|
||||
import usePaymentStatusUsers from "@/hooks/usePaymentStatusUsers";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
@@ -27,7 +38,7 @@ export default function AdminDashboard({user}: Props) {
|
||||
const {stats} = useStats(user.id);
|
||||
const {users, reload} = useUsers();
|
||||
const {groups} = useGroups();
|
||||
const { pending, done } = usePaymentStatusUsers();
|
||||
const {pending, done} = usePaymentStatusUsers();
|
||||
|
||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||
const router = useRouter();
|
||||
@@ -148,7 +159,7 @@ export default function AdminDashboard({user}: Props) {
|
||||
</>
|
||||
);
|
||||
|
||||
const CorporatePaidStatusList = ({ paid }: {paid: Boolean}) => {
|
||||
const CorporatePaidStatusList = ({paid}: {paid: Boolean}) => {
|
||||
const list = paid ? done : pending;
|
||||
const filter = (x: User) => x.type === "corporate" && list.includes(x.id);
|
||||
|
||||
@@ -161,7 +172,9 @@ export default function AdminDashboard({user}: Props) {
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">{paid ? 'Payment Done' : 'Pending Payment'} ({list.length})</h2>
|
||||
<h2 className="text-2xl font-semibold">
|
||||
{paid ? "Payment Done" : "Pending Payment"} ({list.length})
|
||||
</h2>
|
||||
</div>
|
||||
<UserList user={user} filters={[filter]} />
|
||||
</>
|
||||
@@ -290,13 +303,7 @@ export default function AdminDashboard({user}: Props) {
|
||||
}
|
||||
color="rose"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("paymentdone")}
|
||||
Icon={BsCurrencyDollar}
|
||||
label="Payment Done"
|
||||
value={done.length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard onClick={() => setPage("paymentdone")} Icon={BsCurrencyDollar} label="Payment Done" value={done.length} color="purple" />
|
||||
<IconCard
|
||||
onClick={() => setPage("paymentpending")}
|
||||
Icon={BsCurrencyDollar}
|
||||
@@ -304,6 +311,12 @@ export default function AdminDashboard({user}: Props) {
|
||||
value={pending.length}
|
||||
color="rose"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => router.push("https://cms.encoach.com/admin")}
|
||||
Icon={BsLayoutSidebar}
|
||||
label="Content Management System (CMS)"
|
||||
color="green"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between">
|
||||
@@ -318,12 +331,27 @@ export default function AdminDashboard({user}: Props) {
|
||||
))}
|
||||
</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((x) => x.type === "teacher")
|
||||
.sort((a, b) => {
|
||||
return 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"))
|
||||
.sort((a, b) => {
|
||||
return dateSorter(a, b, "desc", "registrationDate");
|
||||
})
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
@@ -356,7 +384,7 @@ export default function AdminDashboard({user}: Props) {
|
||||
</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>
|
||||
<span className="p-4">Teachers expiring in 1 month</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(
|
||||
@@ -371,6 +399,22 @@ export default function AdminDashboard({user}: Props) {
|
||||
))}
|
||||
</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 === "agent" &&
|
||||
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">
|
||||
@@ -400,7 +444,7 @@ export default function AdminDashboard({user}: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Expired Country Manager</span>
|
||||
<span className="p-4">Expired Teachers</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(
|
||||
@@ -411,6 +455,18 @@ export default function AdminDashboard({user}: Props) {
|
||||
))}
|
||||
</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 === "agent" && 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">
|
||||
|
||||
@@ -6,16 +6,38 @@ import {calculateBandScore} from "@/utils/score";
|
||||
import clsx from "clsx";
|
||||
import moment from "moment";
|
||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||
import { usePDFDownload } from "@/hooks/usePDFDownload";
|
||||
import {usePDFDownload} from "@/hooks/usePDFDownload";
|
||||
import {useAssignmentArchive} from "@/hooks/useAssignmentArchive";
|
||||
import {uniqBy} from "lodash";
|
||||
import {useAssignmentUnarchive} from "@/hooks/useAssignmentUnarchive";
|
||||
|
||||
interface Props {
|
||||
onClick?: () => void;
|
||||
allowDownload?: boolean;
|
||||
reload?: Function;
|
||||
allowArchive?: boolean;
|
||||
allowUnarchive?: boolean;
|
||||
}
|
||||
|
||||
export default function AssignmentCard({id, name, assigner, startDate, endDate, assignees, results, exams, onClick, allowDownload}: Assignment & Props) {
|
||||
const {users} = useUsers();
|
||||
export default function AssignmentCard({
|
||||
id,
|
||||
name,
|
||||
assigner,
|
||||
startDate,
|
||||
endDate,
|
||||
assignees,
|
||||
results,
|
||||
exams,
|
||||
archived,
|
||||
onClick,
|
||||
allowDownload,
|
||||
reload,
|
||||
allowArchive,
|
||||
allowUnarchive,
|
||||
}: Assignment & Props) {
|
||||
const renderPdfIcon = usePDFDownload("assignments");
|
||||
const renderArchiveIcon = useAssignmentArchive(id, reload);
|
||||
const renderUnarchiveIcon = useAssignmentUnarchive(id, reload);
|
||||
|
||||
const calculateAverageModuleScore = (module: Module) => {
|
||||
const resultModuleBandScores = results.map((r) => {
|
||||
@@ -32,11 +54,15 @@ export default function AssignmentCard({id, name, assigner, startDate, endDate,
|
||||
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">
|
||||
className="border-mti-gray-platinum flex h-fit w-[350px] cursor-pointer flex-col gap-6 rounded-xl border bg-white p-4 transition duration-300 ease-in-out hover:drop-shadow">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-row justify-between">
|
||||
<h3 className="font-semibold text-xl">{name}</h3>
|
||||
<h3 className="text-xl font-semibold">{name}</h3>
|
||||
<div className="flex gap-2">
|
||||
{allowDownload && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
|
||||
{allowArchive && !archived && renderArchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
|
||||
{allowUnarchive && archived && renderUnarchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
|
||||
</div>
|
||||
</div>
|
||||
<ProgressBar
|
||||
color={results.length / assignees.length < 0.5 ? "red" : "purple"}
|
||||
@@ -46,28 +72,28 @@ export default function AssignmentCard({id, name, assigner, startDate, endDate,
|
||||
textClassName={results.length / assignees.length < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"}
|
||||
/>
|
||||
</div>
|
||||
<span className="flex gap-1 justify-between">
|
||||
<span className="flex justify-between gap-1">
|
||||
<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 className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
||||
{uniqBy(exams, (x) => x.module).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",
|
||||
"-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||
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" />}
|
||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||
{calculateAverageModuleScore(module) > -1 && (
|
||||
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
|
||||
)}
|
||||
|
||||
@@ -19,6 +19,8 @@ import {toast} from "react-toastify";
|
||||
import {uuidv4} from "@firebase/util";
|
||||
import {Assignment} from "@/interfaces/results";
|
||||
import Checkbox from "@/components/Low/Checkbox";
|
||||
import {InstructorGender, Variant} from "@/interfaces/exam";
|
||||
import Select from "@/components/Low/Select";
|
||||
|
||||
interface Props {
|
||||
isCreating: boolean;
|
||||
@@ -34,8 +36,12 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
const [assignees, setAssignees] = useState<string[]>(assignment?.assignees || []);
|
||||
const [name, setName] = useState(assignment?.name || generate({minLength: 6, maxLength: 8, min: 2, max: 3, join: " ", formatter: capitalize}));
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [startDate, setStartDate] = useState<Date | null>(assignment ? moment(assignment.startDate).toDate() : moment().add(1, "day").toDate());
|
||||
const [endDate, setEndDate] = useState<Date | null>(assignment ? moment(assignment.endDate).toDate() : moment().add(8, "day").toDate());
|
||||
const [startDate, setStartDate] = useState<Date | null>(assignment ? moment(assignment.startDate).toDate() : new Date());
|
||||
const [endDate, setEndDate] = useState<Date | null>(
|
||||
assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(),
|
||||
);
|
||||
const [variant, setVariant] = useState<Variant>("full");
|
||||
const [instructorGender, setInstructorGender] = useState<InstructorGender>(assignment?.instructorGender || "varied");
|
||||
// creates a new exam for each assignee or just one exam for all assignees
|
||||
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
|
||||
|
||||
@@ -51,23 +57,18 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
const createAssignment = () => {
|
||||
setIsLoading(true);
|
||||
|
||||
(assignment ? axios.patch : axios.post)(
|
||||
`/api/assignments${assignment ? `/${assignment.id}` : ""}`,
|
||||
{
|
||||
(assignment ? axios.patch : axios.post)(`/api/assignments${assignment ? `/${assignment.id}` : ""}`, {
|
||||
assignees,
|
||||
name,
|
||||
startDate,
|
||||
endDate,
|
||||
selectedModules,
|
||||
generateMultiple,
|
||||
}
|
||||
)
|
||||
variant,
|
||||
instructorGender,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
`The assignment "${name}" has been ${
|
||||
assignment ? "updated" : "created"
|
||||
} successfully!`
|
||||
);
|
||||
toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`);
|
||||
cancelCreation();
|
||||
})
|
||||
.catch((e) => {
|
||||
@@ -203,7 +204,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
"transition duration-300 ease-in-out",
|
||||
)}
|
||||
popperClassName="!z-20"
|
||||
filterDate={(date) => moment(date).isAfter(new Date())}
|
||||
filterTime={(date) => moment(date).isSameOrAfter(new Date())}
|
||||
dateFormat="dd/MM/yyyy HH:mm"
|
||||
selected={startDate}
|
||||
showTimeSelect
|
||||
@@ -219,7 +220,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
"transition duration-300 ease-in-out",
|
||||
)}
|
||||
popperClassName="!z-20"
|
||||
filterDate={(date) => moment(date).isAfter(startDate)}
|
||||
filterTime={(date) => moment(date).isAfter(startDate)}
|
||||
dateFormat="dd/MM/yyyy HH:mm"
|
||||
selected={endDate}
|
||||
showTimeSelect
|
||||
@@ -228,6 +229,20 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor's Gender</label>
|
||||
<Select
|
||||
value={{value: instructorGender, label: capitalize(instructorGender)}}
|
||||
onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)}
|
||||
disabled={!selectedModules.includes("speaking") || !!assignment}
|
||||
options={[
|
||||
{value: "male", label: "Male"},
|
||||
{value: "female", label: "Female"},
|
||||
{value: "varied", label: "Varied"},
|
||||
]}
|
||||
/>
|
||||
</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">
|
||||
@@ -284,8 +299,11 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<div className="flex gap-4 w-full justify-end">
|
||||
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple(d => !d)}>
|
||||
<div className="flex flex-col gap-4 w-full items-end">
|
||||
<Checkbox isChecked={variant === "full"} onChange={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
|
||||
Full length exams
|
||||
</Checkbox>
|
||||
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}>
|
||||
Generate different exams
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import Modal from "@/components/Modal";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
@@ -9,11 +10,13 @@ import {getExamById} from "@/utils/exams";
|
||||
import {sortByModule} from "@/utils/moduleUtils";
|
||||
import {calculateBandScore} from "@/utils/score";
|
||||
import {convertToUserSolutions} from "@/utils/stats";
|
||||
import axios from "axios";
|
||||
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";
|
||||
import {toast} from "react-toastify";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
@@ -30,6 +33,16 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
||||
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||
|
||||
const deleteAssignment = async () => {
|
||||
if (!confirm("Are you sure you want to delete this assignment?")) return;
|
||||
|
||||
axios
|
||||
.delete(`/api/assignments/${assignment?.id}`)
|
||||
.then(() => toast.success(`Successfully deleted the assignment "${assignment?.name}".`))
|
||||
.catch(() => toast.error("Something went wrong, please try again later."))
|
||||
.finally(onClose);
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
const date = moment(parseInt(timestamp));
|
||||
const formatter = "YYYY/MM/DD - HH:mm";
|
||||
@@ -52,7 +65,9 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
||||
};
|
||||
|
||||
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => {
|
||||
const scores: {[key in Module]: {total: number; missing: number; correct: number}} = {
|
||||
const scores: {
|
||||
[key in Module]: {total: number; missing: number; correct: number};
|
||||
} = {
|
||||
reading: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
@@ -126,8 +141,8 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
||||
|
||||
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">
|
||||
<div className="-md:items-center flex w-full justify-between 2xl:items-center">
|
||||
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
|
||||
<span className="font-medium">{formatTimestamp(stats[0].date.toString())}</span>
|
||||
{timeSpent && (
|
||||
<>
|
||||
@@ -147,24 +162,24 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
||||
</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">
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-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",
|
||||
"-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||
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" />}
|
||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||
<span className="text-sm">{level.toFixed(1)}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -184,7 +199,7 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
||||
<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",
|
||||
"border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
|
||||
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",
|
||||
@@ -196,7 +211,7 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
||||
<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",
|
||||
"border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out 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",
|
||||
@@ -211,7 +226,7 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}>
|
||||
<div className="mt-4 flex flex-col w-full gap-4">
|
||||
<div className="mt-4 flex w-full flex-col gap-4">
|
||||
<ProgressBar
|
||||
color="purple"
|
||||
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
|
||||
@@ -221,7 +236,7 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
||||
}
|
||||
percentage={((assignment?.results.length || 0) / (assignment?.assignees.length || 1)) * 100}
|
||||
/>
|
||||
<div className="flex gap-8 items-start">
|
||||
<div className="flex items-start gap-8">
|
||||
<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>
|
||||
@@ -236,24 +251,25 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
||||
</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 className="-md:mt-2 flex w-full items-center gap-4">
|
||||
{assignment &&
|
||||
uniqBy(assignment.exams, (x) => x.module).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",
|
||||
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||
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" />}
|
||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||
{calculateAverageModuleScore(module) > -1 && (
|
||||
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
|
||||
)}
|
||||
@@ -267,13 +283,24 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
||||
</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">
|
||||
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 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>}
|
||||
{assignment && assignment?.results.length === 0 && <span className="ml-1 font-semibold">No results yet...</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 w-full items-center justify-end">
|
||||
{assignment && (assignment.results.length === assignment.assignees.length || moment().isAfter(moment(assignment.endDate))) && (
|
||||
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={deleteAssignment}>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onClose} className="w-full max-w-[200px]">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import Modal from "@/components/Modal";
|
||||
import useStats from "@/hooks/useStats";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {Group, Stat, User} from "@/interfaces/user";
|
||||
import {CorporateUser, Group, Stat, User} from "@/interfaces/user";
|
||||
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||
import {dateSorter} from "@/utils";
|
||||
import moment from "moment";
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
BsPersonFillGear,
|
||||
BsPersonGear,
|
||||
BsPencilSquare,
|
||||
BsPersonBadge,
|
||||
BsPersonCheck,
|
||||
BsPeople,
|
||||
} from "react-icons/bs";
|
||||
import UserCard from "@/components/UserCard";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
@@ -31,9 +34,10 @@ import IconCard from "./IconCard";
|
||||
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
||||
import useFilterStore from "@/stores/listFilterStore";
|
||||
import {useRouter} from "next/router";
|
||||
import useCodes from "@/hooks/useCodes";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
user: CorporateUser;
|
||||
}
|
||||
|
||||
export default function CorporateDashboard({user}: Props) {
|
||||
@@ -43,6 +47,7 @@ export default function CorporateDashboard({user}: Props) {
|
||||
|
||||
const {stats} = useStats();
|
||||
const {users, reload} = useUsers();
|
||||
const {codes} = useCodes(user.id);
|
||||
const {groups} = useGroups(user.id);
|
||||
|
||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||
@@ -187,7 +192,13 @@ export default function CorporateDashboard({user}: Props) {
|
||||
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 onClick={() => setPage("groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
|
||||
<IconCard
|
||||
Icon={BsPersonCheck}
|
||||
label="User Balance"
|
||||
value={`${codes.length}/${user.corporateInformation?.companyInformation?.userAmount || 0}`}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsClock}
|
||||
label="Expiration Date"
|
||||
|
||||
@@ -4,22 +4,28 @@ import {IconType} from "react-icons";
|
||||
interface Props {
|
||||
Icon: IconType;
|
||||
label: string;
|
||||
value: string | number;
|
||||
color: "purple" | "rose" | "red";
|
||||
value?: string | number;
|
||||
color: "purple" | "rose" | "red" | "green";
|
||||
tooltip?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function IconCard({Icon, label, value, color, onClick}: Props) {
|
||||
export default function IconCard({Icon, label, value, color, tooltip, onClick}: Props) {
|
||||
const colorClasses: {[key in typeof color]: string} = {
|
||||
purple: "text-mti-purple-light",
|
||||
red: "text-mti-red-light",
|
||||
rose: "text-mti-rose-light",
|
||||
green: "text-mti-green-light",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center text-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
||||
className={clsx(
|
||||
"bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center text-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300",
|
||||
tooltip && "tooltip tooltip-bottom",
|
||||
)}
|
||||
data-tip={tooltip}>
|
||||
<Icon className={clsx("text-6xl", colorClasses[color])} />
|
||||
<span className="flex flex-col gap-1 items-center text-xl">
|
||||
<span className="text-lg">{label}</span>
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import InviteCard from "@/components/Medium/InviteCard";
|
||||
import PayPalPayment from "@/components/PayPalPayment";
|
||||
import ProfileSummary from "@/components/ProfileSummary";
|
||||
import useAssignments from "@/hooks/useAssignments";
|
||||
import useInvites from "@/hooks/useInvites";
|
||||
import useStats from "@/hooks/useStats";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {Invite} from "@/interfaces/invite";
|
||||
import {Assignment} from "@/interfaces/results";
|
||||
import {CorporateUser, User} from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {getUserCorporate} from "@/utils/groups";
|
||||
import {MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
|
||||
import {countExamModules, countFullExams, MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
|
||||
import {getLevelLabel, getLevelScore} from "@/utils/score";
|
||||
import {averageScore, groupBySession} from "@/utils/stats";
|
||||
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js";
|
||||
import {PayPalButtons} from "@paypal/react-paypal-js";
|
||||
@@ -30,8 +35,10 @@ interface Props {
|
||||
export default function StudentDashboard({user}: Props) {
|
||||
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
|
||||
|
||||
const {stats} = useStats(user.id);
|
||||
const {stats} = useStats(user.id, !user?.id);
|
||||
const {users} = useUsers();
|
||||
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id});
|
||||
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user.id});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -69,8 +76,8 @@ export default function StudentDashboard({user}: Props) {
|
||||
return (
|
||||
<>
|
||||
{corporateUserToShow && (
|
||||
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
|
||||
Linked to: <b>{corporateUserToShow?.corporateInformation.companyInformation.name || corporateUserToShow.name}</b>
|
||||
<div className="absolute right-4 top-4 rounded-lg bg-neutral-200 px-2 py-1">
|
||||
Linked to: <b>{corporateUserToShow?.corporateInformation?.companyInformation.name || corporateUserToShow.name}</b>
|
||||
</div>
|
||||
)}
|
||||
<ProfileSummary
|
||||
@@ -78,39 +85,44 @@ export default function StudentDashboard({user}: Props) {
|
||||
items={[
|
||||
{
|
||||
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||
value: Object.keys(groupBySession(stats)).length,
|
||||
value: countFullExams(stats),
|
||||
label: "Exams",
|
||||
tooltip: "Number of all conducted completed exams",
|
||||
},
|
||||
{
|
||||
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||
value: stats.length,
|
||||
label: "Exercises",
|
||||
value: countExamModules(stats),
|
||||
label: "Modules",
|
||||
tooltip: "Number of all exam modules performed including Level Test",
|
||||
},
|
||||
{
|
||||
icon: <BsStar className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||
icon: <BsStar className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
|
||||
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
|
||||
label: "Average Score",
|
||||
tooltip: "Average success rate for questions responded",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Bio */}
|
||||
<section className="flex flex-col gap-1 md:gap-3">
|
||||
<span className="font-bold text-lg">Bio</span>
|
||||
<span className="text-lg font-bold">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>
|
||||
|
||||
{/* Assignments */}
|
||||
<section className="flex flex-col gap-1 md:gap-3">
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<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>
|
||||
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out">
|
||||
<span className="text-mti-black text-lg font-bold">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">
|
||||
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
||||
{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
|
||||
@@ -119,20 +131,20 @@ export default function StudentDashboard({user}: Props) {
|
||||
.map((assignment) => (
|
||||
<div
|
||||
className={clsx(
|
||||
"border border-mti-gray-anti-flash rounded-xl flex flex-col gap-6 p-4 min-w-[300px]",
|
||||
"border-mti-gray-anti-flash flex min-w-[300px] flex-col gap-6 rounded-xl border p-4",
|
||||
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">
|
||||
<h3 className="text-mti-black/90 text-xl font-semibold">{assignment.name}</h3>
|
||||
<span className="flex justify-between gap-1">
|
||||
<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">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="-md:mt-2 grid w-fit min-w-[104px] grid-cols-2 place-items-center justify-center gap-2">
|
||||
{assignment.exams
|
||||
.filter((e) => e.assignee === user.id)
|
||||
.map((e) => e.module)
|
||||
@@ -142,36 +154,36 @@ export default function StudentDashboard({user}: Props) {
|
||||
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",
|
||||
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||
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" />}
|
||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||
{module === "level" && <BsClipboard className="h-4 w-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"
|
||||
className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden"
|
||||
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"
|
||||
className="h-full w-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"
|
||||
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
|
||||
onClick={() => startAssignment(assignment)}
|
||||
variant="outline">
|
||||
Start
|
||||
@@ -182,7 +194,7 @@ export default function StudentDashboard({user}: Props) {
|
||||
<Button
|
||||
onClick={() => router.push("/record")}
|
||||
color="green"
|
||||
className="w-full max-w-[50%] h-full !rounded-xl -md:hidden"
|
||||
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
|
||||
variant="outline">
|
||||
Submitted
|
||||
</Button>
|
||||
@@ -193,23 +205,47 @@ export default function StudentDashboard({user}: Props) {
|
||||
</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" />}
|
||||
{module === "level" && <BsClipboard className="text-ielts-level w-4 h-4 md:w-5 md:h-5" />}
|
||||
{/* Invites */}
|
||||
{invites.length > 0 && (
|
||||
<section className="flex flex-col gap-1 md:gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
onClick={reloadInvites}
|
||||
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out">
|
||||
<span className="text-mti-black text-lg font-bold">Invites</span>
|
||||
<BsArrowRepeat className={clsx("text-xl", isInvitesLoading && "animate-spin")} />
|
||||
</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] || 0} / Level {user.desiredLevels[module] || 9}
|
||||
</div>
|
||||
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
||||
{invites.map((invite) => (
|
||||
<InviteCard key={invite.id} invite={invite} users={users} reload={reloadInvites} />
|
||||
))}
|
||||
</span>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Score History */}
|
||||
<section className="flex flex-col gap-3">
|
||||
<span className="text-lg font-bold">Score History</span>
|
||||
<div className="-md:grid-rows-4 grid gap-6 md:grid-cols-2">
|
||||
{MODULE_ARRAY.map((module) => {
|
||||
const desiredLevel = user.desiredLevels[module] || 9;
|
||||
const level = user.levels[module] || 0;
|
||||
return (
|
||||
<div className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4" key={module}>
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
<div className="bg-mti-gray-smoke flex h-8 w-8 items-center justify-center rounded-lg md:h-12 md:w-12 md:rounded-xl">
|
||||
{module === "reading" && <BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" />}
|
||||
{module === "listening" && <BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />}
|
||||
{module === "writing" && <BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" />}
|
||||
{module === "speaking" && <BsMegaphone className="text-ielts-speaking h-4 w-4 md:h-5 md:w-5" />}
|
||||
{module === "level" && <BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" />}
|
||||
</div>
|
||||
<div className="flex w-full justify-between">
|
||||
<span className="text-sm font-bold md:font-extrabold">{capitalize(module)}</span>
|
||||
<span className="text-mti-gray-dim text-sm font-normal">
|
||||
{module === "level" && `English Level: ${getLevelLabel(level).join(" / ")}`}
|
||||
{module !== "level" && `Level ${level} / Level 9 (Desired Level: ${desiredLevel})`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,12 +253,15 @@ export default function StudentDashboard({user}: Props) {
|
||||
<ProgressBar
|
||||
color={module}
|
||||
label=""
|
||||
percentage={Math.round((user.levels[module] * 100) / user.desiredLevels[module])}
|
||||
className="w-full h-2"
|
||||
mark={Math.round((desiredLevel * 100) / 9)}
|
||||
markLabel={`Desired Level: ${desiredLevel}`}
|
||||
percentage={Math.round((level * 100) / 9)}
|
||||
className="h-2 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
BsEnvelopePaper,
|
||||
BsGlobeCentralSouthAsia,
|
||||
BsPaperclip,
|
||||
BsPeople,
|
||||
BsPerson,
|
||||
BsPersonAdd,
|
||||
BsPersonFill,
|
||||
@@ -150,8 +151,10 @@ export default function TeacherDashboard({user}: Props) {
|
||||
};
|
||||
|
||||
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 activeFilter = (a: Assignment) =>
|
||||
moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()) && a.assignees.length > a.results.length;
|
||||
const pastFilter = (a: Assignment) => (moment(a.endDate).isBefore(moment()) || a.assignees.length === a.results.length) && !a.archived;
|
||||
const archivedFilter = (a: Assignment) => a.archived;
|
||||
const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
|
||||
|
||||
return (
|
||||
@@ -161,6 +164,7 @@ export default function TeacherDashboard({user}: Props) {
|
||||
onClose={() => {
|
||||
setSelectedAssignment(undefined);
|
||||
setIsCreatingAssignment(false);
|
||||
reloadAssignments();
|
||||
}}
|
||||
assignment={selectedAssignment}
|
||||
/>
|
||||
@@ -232,7 +236,29 @@ export default function TeacherDashboard({user}: Props) {
|
||||
<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} allowDownload />
|
||||
<AssignmentCard
|
||||
{...a}
|
||||
onClick={() => setSelectedAssignment(a)}
|
||||
key={a.id}
|
||||
allowDownload
|
||||
reload={reloadAssignments}
|
||||
allowArchive
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-2xl font-semibold">Archived Assignments ({assignments.filter(archivedFilter).length})</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{assignments.filter(archivedFilter).map((a) => (
|
||||
<AssignmentCard
|
||||
{...a}
|
||||
onClick={() => setSelectedAssignment(a)}
|
||||
key={a.id}
|
||||
allowDownload
|
||||
reload={reloadAssignments}
|
||||
allowUnarchive
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -244,7 +270,7 @@ export default function TeacherDashboard({user}: Props) {
|
||||
<>
|
||||
{corporateUserToShow && (
|
||||
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
|
||||
Linked to: <b>{corporateUserToShow?.corporateInformation.companyInformation.name || corporateUserToShow.name}</b>
|
||||
Linked to: <b>{corporateUserToShow?.corporateInformation?.companyInformation.name || corporateUserToShow.name}</b>
|
||||
</div>
|
||||
)}
|
||||
<section
|
||||
@@ -271,14 +297,14 @@ export default function TeacherDashboard({user}: Props) {
|
||||
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")} />
|
||||
<IconCard Icon={BsPeople} 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 className="font-semibold text-mti-purple-light">{assignments.filter((a) => !a.archived).length}</span>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -40,3 +40,15 @@ export function prepareMailOptions(context: object, to: string[], subject: strin
|
||||
context,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendEmail(template: string, context: object, to: string[], subject: string): Promise<boolean> {
|
||||
try {
|
||||
const transport = prepareMailer(template);
|
||||
const mailOptions = prepareMailOptions(context, to, subject, template);
|
||||
|
||||
await transport.sendMail(mailOptions);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
28
src/email/templates/assignment.handlebars
Normal file
28
src/email/templates/assignment.handlebars
Normal file
@@ -0,0 +1,28 @@
|
||||
<!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 {{user.name}},</p>
|
||||
<br />
|
||||
<p>You have just been given the assignment <b>"{{assignment.name}}"</b> by your teacher {{assignment.assigner}}!</p>
|
||||
<br />
|
||||
<p>It's start date will be on <b>{{assignment.startDate}}</b> and will only last until <b>{{assignment.endDate}}</b>
|
||||
</p>
|
||||
<br />
|
||||
<p>For this assignment, you've been tasked with completing exams of the following modules:
|
||||
<b>{{assignment.modules}}</b>.
|
||||
</p>
|
||||
<br />
|
||||
<p>Don't forget to do it before its end date!</p>
|
||||
<p>Click <b><a href="https://{{environment}}.encoach.com">here</a></b> to open the EnCoach Platform!</p>
|
||||
<br />
|
||||
<p>Thanks,</p>
|
||||
<p>Your EnCoach team</p>
|
||||
</div>
|
||||
|
||||
</html>
|
||||
13
src/email/templates/assignment.handlebars.json
Normal file
13
src/email/templates/assignment.handlebars.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"user": {
|
||||
"name": "Tiago Ribeiro"
|
||||
},
|
||||
"assignment": {
|
||||
"name": "Final Exam",
|
||||
"assigner": "Teacher",
|
||||
"assignees": [],
|
||||
"modules": "Reading and Writing",
|
||||
"startDate": "24/12/2023",
|
||||
"endDate": "27/01/2024"
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,8 @@
|
||||
<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>
|
||||
<span>You have been invited to register at <a
|
||||
href="https://{{environment}}.encoach.com/register?code={{code}}">EnCoach</a>
|
||||
to
|
||||
become a
|
||||
{{type}}!</span><br />
|
||||
@@ -19,7 +20,7 @@
|
||||
</div>
|
||||
<br />
|
||||
<br />
|
||||
<a href="https://platform.encoach.com/register?code={{code}}"></a>
|
||||
<a href="https://{{environment}}.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>
|
||||
|
||||
28
src/email/templates/receivedInvite.handlebars
Normal file
28
src/email/templates/receivedInvite.handlebars
Normal file
@@ -0,0 +1,28 @@
|
||||
<!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 {{name}},</span>
|
||||
<br/>
|
||||
<br/>
|
||||
<span>You have been invited to join {{corporateName}}'s group!</span>
|
||||
<br />
|
||||
<br/>
|
||||
<span>Please access the platform to accept or decline the invite.</span>
|
||||
</div>
|
||||
<br />
|
||||
<br />
|
||||
<div>
|
||||
<span>Thanks, <br /> Your EnCoach team</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</html>
|
||||
25
src/email/templates/respondedInvite.handlebars
Normal file
25
src/email/templates/respondedInvite.handlebars
Normal file
@@ -0,0 +1,25 @@
|
||||
<!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 {{corporateName}},</span>
|
||||
<br />
|
||||
<br />
|
||||
<span>{{name}} has decided to {{decision}} your invite!</span>
|
||||
</div>
|
||||
<br />
|
||||
<br />
|
||||
<div>
|
||||
<span>Thanks, <br /> Your EnCoach team</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</html>
|
||||
35
src/email/templates/submittedFeedback.handlebars
Normal file
35
src/email/templates/submittedFeedback.handlebars
Normal file
@@ -0,0 +1,35 @@
|
||||
<!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>Thank you for your ticket submission!</span>
|
||||
<br/>
|
||||
<span>Here is the ticket's information:</span>
|
||||
<br/>
|
||||
<br/>
|
||||
<span><b>ID:</b> {{id}}</span><br/>
|
||||
<span><b>Subject:</b> {{subject}}</span><br/>
|
||||
<span><b>Reporter:</b> {{reporter.name}} - {{reporter.email}}</span><br/>
|
||||
<span><b>Date:</b> {{date}}</span><br/>
|
||||
<span><b>Type:</b> {{type}}</span><br/>
|
||||
<span><b>Page:</b> {{reportedFrom}}</span>
|
||||
<br/>
|
||||
<br/>
|
||||
<span><b>Description:</b> {{description}}</span><br/>
|
||||
</div>
|
||||
<br />
|
||||
<br />
|
||||
<div>
|
||||
<span>Thanks, <br /> Your EnCoach team</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</html>
|
||||
35
src/email/templates/ticketStatusCompleted.handlebars
Normal file
35
src/email/templates/ticketStatusCompleted.handlebars
Normal file
@@ -0,0 +1,35 @@
|
||||
<!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>Your ticket has been completed!</span>
|
||||
<br/>
|
||||
<span>Here is the ticket's information:</span>
|
||||
<br/>
|
||||
<br/>
|
||||
<span><b>ID:</b> {{id}}</span><br/>
|
||||
<span><b>Subject:</b> {{subject}}</span><br/>
|
||||
<span><b>Reporter:</b> {{reporter.name}} - {{reporter.email}}</span><br/>
|
||||
<span><b>Date:</b> {{date}}</span><br/>
|
||||
<span><b>Type:</b> {{type}}</span><br/>
|
||||
<span><b>Page:</b> {{reportedFrom}}</span>
|
||||
<br/>
|
||||
<br/>
|
||||
<span><b>Description:</b> {{description}}</span><br/>
|
||||
</div>
|
||||
<br />
|
||||
<br />
|
||||
<div>
|
||||
<span>Thanks, <br /> Your EnCoach team</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</html>
|
||||
@@ -10,7 +10,8 @@
|
||||
<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>
|
||||
<a href="https://{{environment}}.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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "Tiago Ribeiro",
|
||||
"email": "tiago.ribeiro@ecrop.dev",
|
||||
"code": "123"
|
||||
"code": "123",
|
||||
"environment": "platform"
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {Fragment, useEffect, useState} from "react";
|
||||
import {BsArrowCounterclockwise, BsBook, BsClipboard, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs";
|
||||
import {LevelScore} from "@/constants/ielts";
|
||||
import {getLevelScore} from "@/utils/score";
|
||||
import {capitalize} from "lodash";
|
||||
|
||||
interface Score {
|
||||
module: Module;
|
||||
@@ -25,7 +26,7 @@ interface Props {
|
||||
modules: Module[];
|
||||
scores: Score[];
|
||||
isLoading: boolean;
|
||||
onViewResults: () => void;
|
||||
onViewResults: (moduleIndex?: number) => void;
|
||||
}
|
||||
|
||||
export default function Finish({user, scores, modules, isLoading, onViewResults}: Props) {
|
||||
@@ -76,7 +77,6 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-1">
|
||||
<span className="text-xl font-bold">{levelStr}</span>
|
||||
<span className="text-xl">{grade}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -86,7 +86,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full min-h-full h-fit flex flex-col items-center justify-between gap-8">
|
||||
<div className="flex h-fit min-h-full w-full flex-col items-center justify-between gap-8">
|
||||
<ModuleTitle
|
||||
module={selectedModule}
|
||||
totalExercises={getTotalExercises()}
|
||||
@@ -99,10 +99,10 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
<div
|
||||
onClick={() => setSelectedModule("reading")}
|
||||
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-reading hover:text-white",
|
||||
"hover:bg-ielts-reading flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
|
||||
selectedModule === "reading" ? "bg-ielts-reading text-white" : "bg-mti-gray-smoke text-ielts-reading",
|
||||
)}>
|
||||
<BsBook className="w-6 h-6" />
|
||||
<BsBook className="h-6 w-6" />
|
||||
<span className="font-semibold">Reading</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -110,10 +110,10 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
<div
|
||||
onClick={() => setSelectedModule("listening")}
|
||||
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-listening hover:text-white",
|
||||
"hover:bg-ielts-listening flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
|
||||
selectedModule === "listening" ? "bg-ielts-listening text-white" : "bg-mti-gray-smoke text-ielts-listening",
|
||||
)}>
|
||||
<BsHeadphones className="w-6 h-6" />
|
||||
<BsHeadphones className="h-6 w-6" />
|
||||
<span className="font-semibold">Listening</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -121,10 +121,10 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
<div
|
||||
onClick={() => setSelectedModule("writing")}
|
||||
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-writing hover:text-white",
|
||||
"hover:bg-ielts-writing flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
|
||||
selectedModule === "writing" ? "bg-ielts-writing text-white" : "bg-mti-gray-smoke text-ielts-writing",
|
||||
)}>
|
||||
<BsPen className="w-6 h-6" />
|
||||
<BsPen className="h-6 w-6" />
|
||||
<span className="font-semibold">Writing</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -132,10 +132,10 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
<div
|
||||
onClick={() => setSelectedModule("speaking")}
|
||||
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-speaking hover:text-white",
|
||||
"hover:bg-ielts-speaking flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
|
||||
selectedModule === "speaking" ? "bg-ielts-speaking text-white" : "bg-mti-gray-smoke text-ielts-speaking",
|
||||
)}>
|
||||
<BsMegaphone className="w-6 h-6" />
|
||||
<BsMegaphone className="h-6 w-6" />
|
||||
<span className="font-semibold">Speaking</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -143,18 +143,18 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
<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",
|
||||
"hover:bg-ielts-level flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
|
||||
selectedModule === "level" ? "bg-ielts-level text-white" : "bg-mti-gray-smoke text-ielts-level",
|
||||
)}>
|
||||
<BsClipboard className="w-6 h-6" />
|
||||
<BsClipboard className="h-6 w-6" />
|
||||
<span className="font-semibold">Level</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{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="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
|
||||
<span className={clsx("loading loading-infinity w-32", moduleColors[selectedModule].progress)} />
|
||||
<span className={clsx("font-bold text-2xl text-center", moduleColors[selectedModule].progress)}>
|
||||
<span className={clsx("text-center text-2xl font-bold", moduleColors[selectedModule].progress)}>
|
||||
Evaluating your answers, please be patient...
|
||||
<br />
|
||||
You can also check it later on your records page!
|
||||
@@ -162,26 +162,31 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && (
|
||||
<div className="w-full flex gap-9 mt-32 items-center justify-between mb-20">
|
||||
<div className="mb-20 mt-32 flex w-full items-center justify-between gap-9">
|
||||
<span className="max-w-3xl">{moduleResultText(selectedModule, bandScore)}</span>
|
||||
<div className="flex gap-9 px-16">
|
||||
<div
|
||||
className={clsx("radial-progress overflow-hidden", moduleColors[selectedModule].progress)}
|
||||
style={
|
||||
{"--value": (selectedScore.correct / selectedScore.total) * 100, "--thickness": "12px", "--size": "13rem"} as any
|
||||
{
|
||||
"--value": (selectedScore.correct / selectedScore.total) * 100,
|
||||
"--thickness": "12px",
|
||||
"--size": "13rem",
|
||||
} as any
|
||||
}>
|
||||
<div
|
||||
className={clsx(
|
||||
"w-48 h-48 rounded-full flex flex-col items-center justify-center",
|
||||
"flex h-48 w-48 flex-col items-center justify-center rounded-full",
|
||||
moduleColors[selectedModule].inner,
|
||||
)}>
|
||||
<span className="text-xl">Level</span>
|
||||
{showLevel(bandScore)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
{!["writing", "speaking"].includes(selectedModule) ? (
|
||||
<div className="flex flex-col gap-5 w-28">
|
||||
<div className="flex gap-2">
|
||||
<div className="w-3 h-3 bg-mti-red-light rounded-full mt-1" />
|
||||
<div className="bg-mti-red-light mt-1 h-3 w-3 rounded-full" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-mti-red-light">
|
||||
{(((selectedScore.total - selectedScore.missing) / selectedScore.total) * 100).toFixed(0)}%
|
||||
@@ -190,14 +195,14 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="w-3 h-3 bg-mti-purple-light rounded-full mt-1" />
|
||||
<div className="bg-mti-purple-light mt-1 h-3 w-3 rounded-full" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-mti-purple-light">{selectedScore.correct.toString().padStart(2, "0")}</span>
|
||||
<span className="text-lg">Correct</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="w-3 h-3 bg-mti-rose-light rounded-full mt-1" />
|
||||
<div className="bg-mti-rose-light mt-1 h-3 w-3 rounded-full" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-mti-rose-light">
|
||||
{(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")}
|
||||
@@ -206,34 +211,46 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-28 h-full" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isLoading && (
|
||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||
<div className="absolute bottom-8 left-0 flex w-full justify-between gap-8 self-end px-8">
|
||||
<div className="flex gap-8">
|
||||
<div className="w-fit flex flex-col items-center gap-1 cursor-pointer">
|
||||
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="w-11 h-11 rounded-full bg-mti-purple-light hover:bg-mti-purple flex items-center justify-center transition duration-300 ease-in-out">
|
||||
<BsArrowCounterclockwise className="text-white w-7 h-7" />
|
||||
disabled={user.type === "admin"}
|
||||
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
||||
<BsArrowCounterclockwise className="h-7 w-7 text-white" />
|
||||
</button>
|
||||
<span>Play Again</span>
|
||||
</div>
|
||||
<div className="w-fit flex flex-col items-center gap-1 cursor-pointer">
|
||||
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
||||
<button
|
||||
onClick={onViewResults}
|
||||
className="w-11 h-11 rounded-full bg-mti-purple-light hover:bg-mti-purple flex items-center justify-center transition duration-300 ease-in-out">
|
||||
<BsEyeFill className="text-white w-7 h-7" />
|
||||
onClick={() => onViewResults()}
|
||||
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
||||
<BsEyeFill className="h-7 w-7 text-white" />
|
||||
</button>
|
||||
<span>Review Answers</span>
|
||||
<span>Review All</span>
|
||||
</div>
|
||||
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
||||
<button
|
||||
onClick={() => onViewResults(modules.findIndex((x) => x === selectedModule))}
|
||||
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
||||
<BsEyeFill className="h-7 w-7 text-white" />
|
||||
</button>
|
||||
<span>Review {capitalize(selectedModule)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link href="/" className="max-w-[200px] w-full self-end">
|
||||
<Button color="purple" className="max-w-[200px] self-end w-full">
|
||||
<Link href="/" className="w-full max-w-[200px] self-end">
|
||||
<Button color="purple" className="w-full max-w-[200px] self-end">
|
||||
Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
@@ -22,9 +22,9 @@ 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]);
|
||||
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentQuestionIndex(0);
|
||||
@@ -38,7 +38,7 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]);
|
||||
}
|
||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
||||
|
||||
@@ -52,17 +52,15 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
|
||||
setHasExamEnded(false);
|
||||
|
||||
if (solution) {
|
||||
onFinish(
|
||||
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "level", exam: exam.id})),
|
||||
);
|
||||
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]);
|
||||
} else {
|
||||
onFinish(userSolutions.map((x) => ({...x, module: "level", exam: exam.id})));
|
||||
onFinish(userSolutions);
|
||||
}
|
||||
};
|
||||
|
||||
const previousExercise = (solution?: UserSolution) => {
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]);
|
||||
}
|
||||
|
||||
if (exerciseIndex > 0) {
|
||||
@@ -91,7 +89,7 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
showSolutions &&
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ListeningExam, UserSolution} from "@/interfaces/exam";
|
||||
import {ListeningExam, MultipleChoiceExercise, UserSolution} from "@/interfaces/exam";
|
||||
import {useEffect, useState} from "react";
|
||||
import {renderExercise} from "@/components/Exercises";
|
||||
import {renderSolution} from "@/components/Solutions";
|
||||
@@ -7,7 +7,6 @@ import AudioPlayer from "@/components/Low/AudioPlayer";
|
||||
import Button from "@/components/Low/Button";
|
||||
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {defaultUserSolutions} from "@/utils/exams";
|
||||
import {countExercises} from "@/utils/moduleUtils";
|
||||
|
||||
interface Props {
|
||||
@@ -16,24 +15,54 @@ interface Props {
|
||||
onFinish: (userSolutions: UserSolution[]) => void;
|
||||
}
|
||||
|
||||
const INSTRUCTIONS_AUDIO_SRC =
|
||||
"https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/generic_listening_intro_v2.mp3?alt=media&token=16769f5f-1e9b-4a72-86a9-45a6f0fa9f82";
|
||||
|
||||
export default function Listening({exam, showSolutions = false, onFinish}: Props) {
|
||||
const [questionIndex, setQuestionIndex] = useState(0);
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [exerciseIndex, setExerciseIndex] = useState(showSolutions ? 0 : -1);
|
||||
const [partIndex, setPartIndex] = useState(0);
|
||||
const [timesListened, setTimesListened] = useState(0);
|
||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(
|
||||
exam.parts.flatMap((x) => x.exercises).map((x) => defaultUserSolutions(x, exam)),
|
||||
);
|
||||
const [showBlankModal, setShowBlankModal] = useState(false);
|
||||
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]);
|
||||
|
||||
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
|
||||
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
||||
const {partIndex, setPartIndex} = useExamStore((state) => state);
|
||||
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
||||
const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]);
|
||||
|
||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||
|
||||
useEffect(() => {
|
||||
if (showSolutions) return setExerciseIndex(-1);
|
||||
}, [setExerciseIndex, showSolutions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (partIndex === -1 && exam.variant === "partial") {
|
||||
setPartIndex(0);
|
||||
}
|
||||
}, [partIndex, exam, setPartIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
const previousParts = exam.parts.filter((_, index) => index < partIndex);
|
||||
let previousMultipleChoice = previousParts.flatMap((x) => x.exercises).filter((x) => x.type === "multipleChoice") as MultipleChoiceExercise[];
|
||||
|
||||
if (partIndex > -1 && exerciseIndex > -1) {
|
||||
const previousPartExercises = exam.parts[partIndex].exercises.filter((_, index) => index < exerciseIndex);
|
||||
const partMultipleChoice = previousPartExercises.filter((x) => x.type === "multipleChoice") as MultipleChoiceExercise[];
|
||||
|
||||
previousMultipleChoice = [...previousMultipleChoice, ...partMultipleChoice];
|
||||
}
|
||||
|
||||
setMultipleChoicesDone(previousMultipleChoice.map((x) => ({id: x.id, amount: x.questions.length - 1})));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded && exerciseIndex === -1) {
|
||||
setExerciseIndex((prev) => prev + 1);
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
}
|
||||
}, [hasExamEnded, exerciseIndex]);
|
||||
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentQuestionIndex(0);
|
||||
@@ -45,22 +74,28 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
return;
|
||||
}
|
||||
|
||||
onFinish(userSolutions.map((x) => ({...x, module: "listening", exam: exam.id})));
|
||||
onFinish(userSolutions);
|
||||
};
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "listening", exam: exam.id}]);
|
||||
}
|
||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
||||
if (storeQuestionIndex > 0) {
|
||||
const exercise = getExercise();
|
||||
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise.id), {id: exercise.id, amount: storeQuestionIndex}]);
|
||||
}
|
||||
setStoreQuestionIndex(0);
|
||||
|
||||
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
||||
setExerciseIndex((prev) => prev + 1);
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
|
||||
setPartIndex((prev) => prev + 1);
|
||||
setPartIndex(partIndex + 1);
|
||||
setTimesListened(0);
|
||||
setExerciseIndex(showSolutions ? 0 : -1);
|
||||
return;
|
||||
}
|
||||
@@ -80,20 +115,20 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
setHasExamEnded(false);
|
||||
|
||||
if (solution) {
|
||||
onFinish(
|
||||
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "listening", exam: exam.id})),
|
||||
);
|
||||
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "listening", exam: exam.id}]);
|
||||
} else {
|
||||
onFinish(userSolutions.map((x) => ({...x, module: "listening", exam: exam.id})));
|
||||
onFinish(userSolutions);
|
||||
}
|
||||
};
|
||||
|
||||
const previousExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "listening", exam: exam.id}]);
|
||||
}
|
||||
setStoreQuestionIndex(0);
|
||||
|
||||
setExerciseIndex((prev) => prev - 1);
|
||||
setExerciseIndex(exerciseIndex - 1);
|
||||
};
|
||||
|
||||
const getExercise = () => {
|
||||
@@ -104,6 +139,42 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (partIndex > -1 && exerciseIndex > -1) {
|
||||
const exercise = getExercise();
|
||||
setMultipleChoicesDone((prev) => prev.filter((x) => x.id !== exercise.id));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [exerciseIndex, partIndex]);
|
||||
|
||||
const calculateExerciseIndex = () => {
|
||||
if (partIndex === -1) return 0;
|
||||
if (partIndex === 0)
|
||||
return (
|
||||
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) + storeQuestionIndex + multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
|
||||
);
|
||||
|
||||
const exercisesPerPart = exam.parts.map((x) => x.exercises.length);
|
||||
const exercisesDone = exercisesPerPart.filter((_, index) => index < partIndex).reduce((acc, curr) => curr + acc, 0);
|
||||
return (
|
||||
exercisesDone +
|
||||
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) +
|
||||
storeQuestionIndex +
|
||||
multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
|
||||
);
|
||||
};
|
||||
|
||||
const renderAudioInstructionsPlayer = () => (
|
||||
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
|
||||
<div className="flex flex-col w-full gap-2">
|
||||
<h4 className="text-xl font-semibold">Please listen to the instructions audio attentively.</h4>
|
||||
</div>
|
||||
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
|
||||
<AudioPlayer key={partIndex} src={INSTRUCTIONS_AUDIO_SRC} color="listening" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderAudioPlayer = () => (
|
||||
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
|
||||
<div className="flex flex-col w-full gap-2">
|
||||
@@ -132,39 +203,43 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
||||
<div className="flex flex-col h-full w-full gap-8 justify-between">
|
||||
<ModuleTitle
|
||||
exerciseIndex={
|
||||
(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
|
||||
}
|
||||
exerciseIndex={calculateExerciseIndex()}
|
||||
minTimer={exam.minTimer}
|
||||
module="listening"
|
||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||
disableTimer={showSolutions}
|
||||
/>
|
||||
{renderAudioPlayer()}
|
||||
{/* Audio Player for the Instructions */}
|
||||
{partIndex === -1 && renderAudioInstructionsPlayer()}
|
||||
|
||||
{/* Part's audio player */}
|
||||
{partIndex > -1 && renderAudioPlayer()}
|
||||
|
||||
{/* Exercise renderer */}
|
||||
{exerciseIndex > -1 &&
|
||||
partIndex > -1 &&
|
||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
|
||||
{/* Solution renderer */}
|
||||
{exerciseIndex > -1 &&
|
||||
partIndex > -1 &&
|
||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||
showSolutions &&
|
||||
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
</div>
|
||||
{exerciseIndex === -1 && partIndex > 0 && (
|
||||
|
||||
{exerciseIndex === -1 && partIndex > -1 && exam.variant !== "partial" && (
|
||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (partIndex === 0) return setPartIndex(-1);
|
||||
|
||||
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
|
||||
setPartIndex((prev) => prev - 1);
|
||||
setPartIndex(partIndex - 1);
|
||||
}}
|
||||
className="max-w-[200px] w-full">
|
||||
Back
|
||||
@@ -175,7 +250,13 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{exerciseIndex === -1 && partIndex === 0 && (
|
||||
|
||||
{partIndex === -1 && exam.variant !== "partial" && (
|
||||
<Button color="purple" onClick={() => setPartIndex(0)} className="max-w-[200px] self-end w-full justify-self-end">
|
||||
Start now
|
||||
</Button>
|
||||
)}
|
||||
{exerciseIndex === -1 && partIndex === 0 && exam.variant === "partial" && (
|
||||
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end">
|
||||
Start now
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ReadingExam, UserSolution} from "@/interfaces/exam";
|
||||
import {MultipleChoiceExercise, ReadingExam, ReadingPart, UserSolution} from "@/interfaces/exam";
|
||||
import {Fragment, useEffect, useState} from "react";
|
||||
import Icon from "@mdi/react";
|
||||
import {mdiArrowRight, mdiNotebook} from "@mdi/js";
|
||||
@@ -10,7 +10,7 @@ import {renderExercise} from "@/components/Exercises";
|
||||
import {renderSolution} from "@/components/Solutions";
|
||||
import {Panel} from "primereact/panel";
|
||||
import {Steps} from "primereact/steps";
|
||||
import {BsAlarm, BsBook, BsClock, BsStopwatch} from "react-icons/bs";
|
||||
import {BsAlarm, BsBook, BsChevronDown, BsChevronUp, BsClock, BsStopwatch} from "react-icons/bs";
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||
import {Divider} from "primereact/divider";
|
||||
@@ -26,6 +26,8 @@ interface Props {
|
||||
onFinish: (userSolutions: UserSolution[]) => void;
|
||||
}
|
||||
|
||||
const numberToLetter = (number: number) => (number + 9).toString(36).toUpperCase();
|
||||
|
||||
function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: string; content: string; onClose: () => void}) {
|
||||
return (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
@@ -80,18 +82,64 @@ function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: s
|
||||
);
|
||||
}
|
||||
|
||||
function TextComponent({part, exerciseType}: {part: ReadingPart; exerciseType: string}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<h3 className="text-xl font-semibold">{part.text.title}</h3>
|
||||
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
|
||||
{part.text.content
|
||||
.split(/\n|(\\n)/g)
|
||||
.filter((x) => x && x.length > 0)
|
||||
.map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
{exerciseType === "matchSentences" && (
|
||||
<div className="flex gap-3 border border-transparent hover:border-mti-purple-light rounded-lg transition ease-in-out duration-300 p-2 px-3 cursor-pointer">
|
||||
<span className="font-bold text-mti-purple-dark">{numberToLetter(index + 1)}</span>
|
||||
<p>{line}</p>
|
||||
</div>
|
||||
)}
|
||||
{exerciseType !== "matchSentences" && <p key={index}>{line}</p>}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Reading({exam, showSolutions = false, onFinish}: Props) {
|
||||
const [questionIndex, setQuestionIndex] = useState(0);
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [exerciseIndex, setExerciseIndex] = useState(showSolutions ? 0 : -1);
|
||||
const [partIndex, setPartIndex] = useState(0);
|
||||
const [showTextModal, setShowTextModal] = useState(false);
|
||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(
|
||||
exam.parts.flatMap((x) => x.exercises).map((x) => defaultUserSolutions(x, exam)),
|
||||
);
|
||||
const [showBlankModal, setShowBlankModal] = useState(false);
|
||||
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]);
|
||||
const [isTextMinimized, setIsTextMinimzed] = useState(false);
|
||||
const [exerciseType, setExerciseType] = useState("");
|
||||
|
||||
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
|
||||
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
||||
const {partIndex, setPartIndex} = useExamStore((state) => state);
|
||||
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
||||
const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]);
|
||||
|
||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||
|
||||
useEffect(() => {
|
||||
if (showSolutions) setExerciseIndex(-1);
|
||||
}, [setExerciseIndex, showSolutions]);
|
||||
|
||||
useEffect(() => {
|
||||
const previousParts = exam.parts.filter((_, index) => index < partIndex);
|
||||
let previousMultipleChoice = previousParts.flatMap((x) => x.exercises).filter((x) => x.type === "multipleChoice") as MultipleChoiceExercise[];
|
||||
|
||||
if (partIndex > -1 && exerciseIndex > -1) {
|
||||
const previousPartExercises = exam.parts[partIndex].exercises.filter((_, index) => index < exerciseIndex);
|
||||
const partMultipleChoice = previousPartExercises.filter((x) => x.type === "multipleChoice") as MultipleChoiceExercise[];
|
||||
|
||||
previousMultipleChoice = [...previousMultipleChoice, ...partMultipleChoice];
|
||||
}
|
||||
|
||||
setMultipleChoicesDone(previousMultipleChoice.map((x) => ({id: x.id, amount: x.questions.length - 1})));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (e: KeyboardEvent) => {
|
||||
@@ -113,9 +161,9 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded && exerciseIndex === -1) {
|
||||
setExerciseIndex((prev) => prev + 1);
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
}
|
||||
}, [hasExamEnded, exerciseIndex]);
|
||||
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
||||
|
||||
const confirmFinishModule = (keepGoing?: boolean) => {
|
||||
if (!keepGoing) {
|
||||
@@ -123,22 +171,28 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
||||
return;
|
||||
}
|
||||
|
||||
onFinish(userSolutions.map((x) => ({...x, module: "reading", exam: exam.id})));
|
||||
onFinish(userSolutions);
|
||||
};
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "reading", exam: exam.id}]);
|
||||
}
|
||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
||||
if (storeQuestionIndex > 0) {
|
||||
const exercise = getExercise();
|
||||
setExerciseType(exercise.type);
|
||||
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise.id), {id: exercise.id, amount: storeQuestionIndex}]);
|
||||
}
|
||||
setStoreQuestionIndex(0);
|
||||
|
||||
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
||||
setExerciseIndex((prev) => prev + 1);
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
|
||||
setPartIndex((prev) => prev + 1);
|
||||
setPartIndex(partIndex + 1);
|
||||
setExerciseIndex(showSolutions ? 0 : -1);
|
||||
return;
|
||||
}
|
||||
@@ -158,20 +212,20 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
||||
setHasExamEnded(false);
|
||||
|
||||
if (solution) {
|
||||
onFinish(
|
||||
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "reading", exam: exam.id})),
|
||||
);
|
||||
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "reading", exam: exam.id}]);
|
||||
} else {
|
||||
onFinish(userSolutions.map((x) => ({...x, module: "reading", exam: exam.id})));
|
||||
onFinish(userSolutions);
|
||||
}
|
||||
};
|
||||
|
||||
const previousExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "reading", exam: exam.id}]);
|
||||
}
|
||||
setStoreQuestionIndex(0);
|
||||
|
||||
setExerciseIndex((prev) => prev - 1);
|
||||
setExerciseIndex(exerciseIndex - 1);
|
||||
};
|
||||
|
||||
const getExercise = () => {
|
||||
@@ -182,23 +236,56 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (partIndex > -1 && exerciseIndex > -1) {
|
||||
const exercise = getExercise();
|
||||
setExerciseType(exercise.type);
|
||||
setMultipleChoicesDone((prev) => prev.filter((x) => x.id !== exercise.id));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [exerciseIndex, partIndex]);
|
||||
|
||||
const calculateExerciseIndex = () => {
|
||||
if (partIndex === -1) return 0;
|
||||
if (partIndex === 0)
|
||||
return (
|
||||
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) + storeQuestionIndex + multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
|
||||
);
|
||||
|
||||
const exercisesPerPart = exam.parts.map((x) => x.exercises.length);
|
||||
const exercisesDone = exercisesPerPart.filter((_, index) => index < partIndex).reduce((acc, curr) => curr + acc, 0);
|
||||
return (
|
||||
exercisesDone +
|
||||
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) +
|
||||
storeQuestionIndex +
|
||||
multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
|
||||
);
|
||||
};
|
||||
|
||||
const renderText = () => (
|
||||
<div className="flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16 mt-4">
|
||||
<div className={clsx("flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl mt-4 relative", isTextMinimized ? "p-2 px-8" : "py-8 px-16")}>
|
||||
<button
|
||||
data-tip={isTextMinimized ? "Maximise" : "Minimize"}
|
||||
className={clsx("absolute right-8 tooltip", isTextMinimized ? "top-1/2 -translate-y-1/2" : "top-8")}
|
||||
onClick={() => setIsTextMinimzed((prev) => !prev)}>
|
||||
{isTextMinimized ? (
|
||||
<BsChevronDown className="text-mti-purple-dark text-lg" />
|
||||
) : (
|
||||
<BsChevronUp className="text-mti-purple-dark text-lg" />
|
||||
)}
|
||||
</button>
|
||||
{!isTextMinimized && (
|
||||
<>
|
||||
<div className="flex flex-col w-full gap-2">
|
||||
<h4 className="text-xl font-semibold">
|
||||
Please read the following excerpt attentively, you will then be asked questions about the text you've read.
|
||||
</h4>
|
||||
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<h3 className="text-xl font-semibold">{exam.parts[partIndex].text.title}</h3>
|
||||
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
|
||||
<span className="overflow-auto">
|
||||
{exam.parts[partIndex].text.content.split("\\n").map((line, index) => (
|
||||
<p key={index}>{line}</p>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<TextComponent part={exam.parts[partIndex]} exerciseType={exerciseType} />
|
||||
</>
|
||||
)}
|
||||
{isTextMinimized && <span className="font-semibold">Reading Passage</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -206,36 +293,36 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
||||
<>
|
||||
<div className="flex flex-col h-full w-full gap-8">
|
||||
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
||||
<TextModal {...exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />
|
||||
{partIndex > -1 && <TextModal {...exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />}
|
||||
<ModuleTitle
|
||||
minTimer={exam.minTimer}
|
||||
exerciseIndex={
|
||||
(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
|
||||
}
|
||||
exerciseIndex={calculateExerciseIndex()}
|
||||
module="reading"
|
||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||
disableTimer={showSolutions}
|
||||
label={exerciseIndex === -1 ? undefined : convertCamelCaseToReadable(exam.parts[partIndex].exercises[exerciseIndex].type)}
|
||||
/>
|
||||
<div className={clsx("mb-20 w-full", exerciseIndex > -1 && "grid grid-cols-2 gap-4")}>
|
||||
{renderText()}
|
||||
<div
|
||||
className={clsx(
|
||||
"mb-20 w-full",
|
||||
exerciseIndex > -1 && !isTextMinimized && "grid grid-cols-2 gap-4",
|
||||
exerciseIndex > -1 && isTextMinimized && "flex flex-col gap-2",
|
||||
)}>
|
||||
{partIndex > -1 && renderText()}
|
||||
|
||||
{exerciseIndex > -1 &&
|
||||
partIndex > -1 &&
|
||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
|
||||
{exerciseIndex > -1 &&
|
||||
partIndex > -1 &&
|
||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||
showSolutions &&
|
||||
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
</div>
|
||||
{exerciseIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
|
||||
{exerciseIndex > -1 && partIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
@@ -252,7 +339,7 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
|
||||
setPartIndex((prev) => prev - 1);
|
||||
setPartIndex(partIndex - 1);
|
||||
}}
|
||||
className="max-w-[200px] w-full">
|
||||
Back
|
||||
|
||||
@@ -1,237 +1,438 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {useState} from "react";
|
||||
import {Module} from "@/interfaces";
|
||||
import { useState } from "react";
|
||||
import { Module } from "@/interfaces";
|
||||
import clsx from "clsx";
|
||||
import {User} from "@/interfaces/user";
|
||||
import { User } from "@/interfaces/user";
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import {BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
||||
import {totalExamsByModule} from "@/utils/stats";
|
||||
import {
|
||||
BsArrowRepeat,
|
||||
BsBook,
|
||||
BsCheck,
|
||||
BsCheckCircle,
|
||||
BsClipboard,
|
||||
BsHeadphones,
|
||||
BsMegaphone,
|
||||
BsPen,
|
||||
BsXCircle,
|
||||
} from "react-icons/bs";
|
||||
import { totalExamsByModule } from "@/utils/stats";
|
||||
import useStats from "@/hooks/useStats";
|
||||
import Button from "@/components/Low/Button";
|
||||
import {calculateAverageLevel} from "@/utils/score";
|
||||
import {sortByModuleName} from "@/utils/moduleUtils";
|
||||
import {capitalize} from "lodash";
|
||||
import { calculateAverageLevel } from "@/utils/score";
|
||||
import { sortByModuleName } from "@/utils/moduleUtils";
|
||||
import { capitalize } from "lodash";
|
||||
import ProfileSummary from "@/components/ProfileSummary";
|
||||
import { Variant } from "@/interfaces/exam";
|
||||
import useSessions, { Session } from "@/hooks/useSessions";
|
||||
import SessionCard from "@/components/Medium/SessionCard";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import moment from "moment";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
page: "exercises" | "exams";
|
||||
onStart: (modules: Module[], avoidRepeated: boolean) => void;
|
||||
onStart: (
|
||||
modules: Module[],
|
||||
avoidRepeated: boolean,
|
||||
variant: Variant,
|
||||
) => void;
|
||||
disableSelection?: boolean;
|
||||
}
|
||||
|
||||
export default function Selection({user, page, onStart, disableSelection = false}: Props) {
|
||||
export default function Selection({
|
||||
user,
|
||||
page,
|
||||
onStart,
|
||||
disableSelection = false,
|
||||
}: Props) {
|
||||
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
|
||||
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
|
||||
const {stats} = useStats(user?.id);
|
||||
const [variant, setVariant] = useState<Variant>("full");
|
||||
|
||||
const { stats } = useStats(user?.id);
|
||||
const { sessions, isLoading, reload } = useSessions(user.id);
|
||||
|
||||
const state = useExamStore((state) => state);
|
||||
|
||||
const toggleModule = (module: Module) => {
|
||||
const modules = selectedModules.filter((x) => x !== module);
|
||||
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module]));
|
||||
setSelectedModules((prev) =>
|
||||
prev.includes(module) ? modules : [...modules, module],
|
||||
);
|
||||
};
|
||||
|
||||
const loadSession = async (session: Session) => {
|
||||
state.setSelectedModules(session.selectedModules);
|
||||
state.setExam(session.exam);
|
||||
state.setExams(session.exams);
|
||||
state.setSessionId(session.sessionId);
|
||||
state.setAssignment(session.assignment);
|
||||
state.setExerciseIndex(session.exerciseIndex);
|
||||
state.setPartIndex(session.partIndex);
|
||||
state.setModuleIndex(session.moduleIndex);
|
||||
state.setTimeSpent(session.timeSpent);
|
||||
state.setUserSolutions(session.userSolutions);
|
||||
state.setShowSolutions(false);
|
||||
state.setQuestionIndex(session.questionIndex);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full h-full relative flex flex-col gap-8 md:gap-16">
|
||||
<div className="relative flex h-full w-full flex-col gap-8 md:gap-16">
|
||||
{user && (
|
||||
<ProfileSummary
|
||||
user={user}
|
||||
items={[
|
||||
{
|
||||
icon: <BsBook className="text-ielts-reading w-6 h-6 md:w-8 md:h-8" />,
|
||||
icon: (
|
||||
<BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />
|
||||
),
|
||||
label: "Reading",
|
||||
value: totalExamsByModule(stats, "reading"),
|
||||
tooltip: "The amount of reading exams performed.",
|
||||
},
|
||||
{
|
||||
icon: <BsHeadphones className="text-ielts-listening w-6 h-6 md:w-8 md:h-8" />,
|
||||
icon: (
|
||||
<BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />
|
||||
),
|
||||
label: "Listening",
|
||||
value: totalExamsByModule(stats, "listening"),
|
||||
tooltip: "The amount of listening exams performed.",
|
||||
},
|
||||
{
|
||||
icon: <BsPen className="text-ielts-writing w-6 h-6 md:w-8 md:h-8" />,
|
||||
icon: (
|
||||
<BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />
|
||||
),
|
||||
label: "Writing",
|
||||
value: totalExamsByModule(stats, "writing"),
|
||||
tooltip: "The amount of writing exams performed.",
|
||||
},
|
||||
{
|
||||
icon: <BsMegaphone className="text-ielts-speaking w-6 h-6 md:w-8 md:h-8" />,
|
||||
icon: (
|
||||
<BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />
|
||||
),
|
||||
label: "Speaking",
|
||||
value: totalExamsByModule(stats, "speaking"),
|
||||
tooltip: "The amount of speaking exams performed.",
|
||||
},
|
||||
{
|
||||
icon: <BsClipboard className="text-ielts-level w-6 h-6 md:w-8 md:h-8" />,
|
||||
icon: (
|
||||
<BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />
|
||||
),
|
||||
label: "Level",
|
||||
value: totalExamsByModule(stats, "level"),
|
||||
tooltip: "The amount of level exams performed.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<span className="font-bold text-lg">About {capitalize(page)}</span>
|
||||
<span className="text-lg font-bold">About {capitalize(page)}</span>
|
||||
<span className="text-mti-gray-taupe">
|
||||
{page === "exercises" && (
|
||||
<>
|
||||
In the realm of language acquisition, practice makes perfect, and our exercises are the key to unlocking your full
|
||||
potential. Dive into a world of interactive and engaging exercises that cater to diverse learning styles. From grammar
|
||||
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!
|
||||
In the realm of language acquisition, practice makes perfect,
|
||||
and our exercises are the key to unlocking your full potential.
|
||||
Dive into a world of interactive and engaging exercises that
|
||||
cater to diverse learning styles. From grammar 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.
|
||||
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>
|
||||
</section>
|
||||
<section className="w-full flex -lg:flex-col -lg:items-center -lg:gap-12 justify-between gap-8 mt-8">
|
||||
|
||||
{sessions.length > 0 && (
|
||||
<section className="flex flex-col gap-3 md:gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
|
||||
onClick={reload}
|
||||
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out"
|
||||
>
|
||||
<span className="text-mti-black text-lg font-bold">
|
||||
Unfinished Sessions
|
||||
</span>
|
||||
<BsArrowRepeat
|
||||
className={clsx("text-xl", isLoading && "animate-spin")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
||||
{sessions
|
||||
.sort((a, b) => moment(b.date).diff(moment(a.date)))
|
||||
.map((session) => (
|
||||
<SessionCard
|
||||
session={session}
|
||||
key={session.sessionId}
|
||||
reload={reload}
|
||||
loadSession={loadSession}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8">
|
||||
<div
|
||||
onClick={
|
||||
!disableSelection && !selectedModules.includes("level")
|
||||
? () => toggleModule("reading")
|
||||
: 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("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">
|
||||
<BsBook className="text-white w-7 h-7" />
|
||||
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||
selectedModules.includes("reading") || disableSelection
|
||||
? "border-mti-purple-light"
|
||||
: "border-mti-gray-platinum",
|
||||
)}
|
||||
>
|
||||
<div className="bg-ielts-reading absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||
<BsBook className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Reading:</span>
|
||||
<p className="text-center text-xs">
|
||||
Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
|
||||
<p className="text-left text-xs">
|
||||
Expand your vocabulary, improve your reading comprehension and
|
||||
improve your ability to interpret texts in English.
|
||||
</p>
|
||||
{!selectedModules.includes("reading") && !selectedModules.includes("level") && !disableSelection && (
|
||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
||||
{!selectedModules.includes("reading") &&
|
||||
!selectedModules.includes("level") &&
|
||||
!disableSelection && (
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("reading") || disableSelection) && (
|
||||
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && (
|
||||
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
|
||||
</div>
|
||||
<div
|
||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
|
||||
onClick={
|
||||
!disableSelection && !selectedModules.includes("level")
|
||||
? () => toggleModule("listening")
|
||||
: 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("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">
|
||||
<BsHeadphones className="text-white w-7 h-7" />
|
||||
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||
selectedModules.includes("listening") || disableSelection
|
||||
? "border-mti-purple-light"
|
||||
: "border-mti-gray-platinum",
|
||||
)}
|
||||
>
|
||||
<div className="bg-ielts-listening absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||
<BsHeadphones className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Listening:</span>
|
||||
<p className="text-center text-xs">
|
||||
Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
|
||||
<p className="text-left text-xs">
|
||||
Improve your ability to follow conversations in English and your
|
||||
ability to understand different accents and intonations.
|
||||
</p>
|
||||
{!selectedModules.includes("listening") && !selectedModules.includes("level") && !disableSelection && (
|
||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
||||
{!selectedModules.includes("listening") &&
|
||||
!selectedModules.includes("level") &&
|
||||
!disableSelection && (
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("listening") || disableSelection) && (
|
||||
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && (
|
||||
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
|
||||
</div>
|
||||
<div
|
||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
|
||||
onClick={
|
||||
!disableSelection && !selectedModules.includes("level")
|
||||
? () => toggleModule("writing")
|
||||
: 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("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">
|
||||
<BsPen className="text-white w-7 h-7" />
|
||||
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||
selectedModules.includes("writing") || disableSelection
|
||||
? "border-mti-purple-light"
|
||||
: "border-mti-gray-platinum",
|
||||
)}
|
||||
>
|
||||
<div className="bg-ielts-writing absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||
<BsPen className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Writing:</span>
|
||||
<p className="text-center text-xs">
|
||||
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
|
||||
<p className="text-left text-xs">
|
||||
Allow you to practice writing in a variety of formats, from simple
|
||||
paragraphs to complex essays.
|
||||
</p>
|
||||
{!selectedModules.includes("writing") && !selectedModules.includes("level") && !disableSelection && (
|
||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
||||
{!selectedModules.includes("writing") &&
|
||||
!selectedModules.includes("level") &&
|
||||
!disableSelection && (
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("writing") || disableSelection) && (
|
||||
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && (
|
||||
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
|
||||
</div>
|
||||
<div
|
||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
|
||||
onClick={
|
||||
!disableSelection && !selectedModules.includes("level")
|
||||
? () => toggleModule("speaking")
|
||||
: 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("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">
|
||||
<BsMegaphone className="text-white w-7 h-7" />
|
||||
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||
selectedModules.includes("speaking") || disableSelection
|
||||
? "border-mti-purple-light"
|
||||
: "border-mti-gray-platinum",
|
||||
)}
|
||||
>
|
||||
<div className="bg-ielts-speaking absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||
<BsMegaphone className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Speaking:</span>
|
||||
<p className="text-center text-xs">
|
||||
You'll have access to interactive dialogs, pronunciation exercises and speech recordings.
|
||||
<p className="text-left text-xs">
|
||||
You'll have access to interactive dialogs, pronunciation
|
||||
exercises and speech recordings.
|
||||
</p>
|
||||
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && !disableSelection && (
|
||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
||||
{!selectedModules.includes("speaking") &&
|
||||
!selectedModules.includes("level") &&
|
||||
!disableSelection && (
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("speaking") || disableSelection) && (
|
||||
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && (
|
||||
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
|
||||
</div>
|
||||
{!disableSelection && (
|
||||
<div
|
||||
onClick={selectedModules.length === 0 || selectedModules.includes("level") ? () => toggleModule("level") : undefined}
|
||||
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" />
|
||||
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||
selectedModules.includes("level") || disableSelection
|
||||
? "border-mti-purple-light"
|
||||
: "border-mti-gray-platinum",
|
||||
)}
|
||||
>
|
||||
<div className="bg-ielts-level absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||
<BsClipboard className="h-7 w-7 text-white" />
|
||||
</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" />
|
||||
<p className="text-left 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-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("level") || disableSelection) && (
|
||||
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{!selectedModules.includes("level") && selectedModules.length > 0 && (
|
||||
<BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />
|
||||
{!selectedModules.includes("level") &&
|
||||
selectedModules.length > 0 && (
|
||||
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<div className="flex w-full -md:flex-col -md:gap-4 -md:justify-center md:justify-between items-center">
|
||||
<div className="-md:flex-col -md:gap-4 -md:justify-center flex w-full items-center md:justify-between">
|
||||
<div className="flex w-full flex-col items-center gap-3">
|
||||
<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)}>
|
||||
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
|
||||
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",
|
||||
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
|
||||
"transition duration-300 ease-in-out",
|
||||
avoidRepeatedExams && "!bg-mti-purple-light ",
|
||||
)}>
|
||||
<BsCheck color="white" className="w-full h-full" />
|
||||
)}
|
||||
>
|
||||
<BsCheck color="white" className="h-full w-full" />
|
||||
</div>
|
||||
<span>Avoid Repeated Questions</span>
|
||||
<span
|
||||
className="tooltip"
|
||||
data-tip="If possible, the platform will choose exams not yet done."
|
||||
>
|
||||
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>
|
||||
<div
|
||||
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
|
||||
// onClick={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
|
||||
>
|
||||
<input type="checkbox" className="hidden" disabled />
|
||||
<div
|
||||
className={clsx(
|
||||
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
|
||||
"transition duration-300 ease-in-out",
|
||||
variant === "full" && "!bg-mti-purple-light ",
|
||||
)}
|
||||
>
|
||||
<BsCheck color="white" className="h-full w-full" />
|
||||
</div>
|
||||
<span>Full length exams</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="tooltip w-full"
|
||||
data-tip={`Your screen size is too small to do ${page}`}
|
||||
>
|
||||
<Button
|
||||
color="purple"
|
||||
className="w-full max-w-xs px-12 md:hidden"
|
||||
disabled
|
||||
>
|
||||
Start Exam
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() =>
|
||||
onStart(
|
||||
!disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"],
|
||||
!disableSelection
|
||||
? selectedModules.sort(sortByModuleName)
|
||||
: ["reading", "listening", "writing", "speaking"],
|
||||
avoidRepeatedExams,
|
||||
variant,
|
||||
)
|
||||
}
|
||||
color="purple"
|
||||
className="px-12 w-full max-w-xs md:self-end -md:hidden"
|
||||
disabled={selectedModules.length === 0 && !disableSelection}>
|
||||
className="-md:hidden w-full max-w-xs px-12 md:self-end"
|
||||
disabled={selectedModules.length === 0 && !disableSelection}
|
||||
>
|
||||
Start Exam
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -22,22 +22,26 @@ interface 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 [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
|
||||
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
|
||||
|
||||
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
||||
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
||||
|
||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentQuestionIndex(0);
|
||||
}, [questionIndex]);
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "speaking", exam: exam.id}]);
|
||||
}
|
||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
||||
|
||||
if (exerciseIndex + 1 < exam.exercises.length) {
|
||||
setExerciseIndex((prev) => prev + 1);
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -46,21 +50,20 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
||||
setHasExamEnded(false);
|
||||
|
||||
if (solution) {
|
||||
onFinish(
|
||||
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "speaking", exam: exam.id})),
|
||||
);
|
||||
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "speaking", exam: exam.id}]);
|
||||
} else {
|
||||
onFinish(userSolutions.map((x) => ({...x, module: "speaking", exam: exam.id})));
|
||||
onFinish(userSolutions);
|
||||
}
|
||||
};
|
||||
|
||||
const previousExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "speaking", exam: exam.id}]);
|
||||
}
|
||||
|
||||
if (exerciseIndex > 0) {
|
||||
setExerciseIndex((prev) => prev - 1);
|
||||
setExerciseIndex(exerciseIndex - 1);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -86,7 +89,7 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
showSolutions &&
|
||||
|
||||
@@ -19,18 +19,20 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function Writing({exam, showSolutions = false, onFinish}: Props) {
|
||||
const [exerciseIndex, setExerciseIndex] = useState(0);
|
||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
|
||||
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
||||
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
||||
|
||||
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
|
||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "writing", exam: exam.id}]);
|
||||
}
|
||||
|
||||
if (exerciseIndex + 1 < exam.exercises.length) {
|
||||
setExerciseIndex((prev) => prev + 1);
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,21 +41,20 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
|
||||
setHasExamEnded(false);
|
||||
|
||||
if (solution) {
|
||||
onFinish(
|
||||
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "writing", exam: exam.id})),
|
||||
);
|
||||
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "writing", exam: exam.id}]);
|
||||
} else {
|
||||
onFinish(userSolutions.map((x) => ({...x, module: "writing", exam: exam.id})));
|
||||
onFinish(userSolutions);
|
||||
}
|
||||
};
|
||||
|
||||
const previousExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "writing", exam: exam.id}]);
|
||||
}
|
||||
|
||||
if (exerciseIndex > 0) {
|
||||
setExerciseIndex((prev) => prev - 1);
|
||||
setExerciseIndex(exerciseIndex - 1);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -78,7 +79,7 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), nextExercise, previousExercise)}
|
||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
showSolutions &&
|
||||
|
||||
@@ -25,7 +25,13 @@ const thresholds = [
|
||||
level: "High A2/Low B1",
|
||||
label: "Pre-Intermediate",
|
||||
minValue: 8,
|
||||
maxValue: 12,
|
||||
maxValue: 11,
|
||||
},
|
||||
{
|
||||
level: "High B1/Low B2",
|
||||
label: "Intermediate",
|
||||
minValue: 12,
|
||||
maxValue: 15,
|
||||
},
|
||||
{
|
||||
level: "High B2/Low C1",
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
/* eslint-disable jsx-a11y/alt-text */
|
||||
import React from "react";
|
||||
|
||||
import { View, Text, Image } from "@react-pdf/renderer";
|
||||
import { styles } from "../styles";
|
||||
import { ModuleScore } from "@/interfaces/module.scores";
|
||||
import {View, Text, Image} from "@react-pdf/renderer";
|
||||
import {styles} from "../styles";
|
||||
import {ModuleScore} from "@/interfaces/module.scores";
|
||||
import {calculateBandScore} from "@/utils/score";
|
||||
import {Module} from "@/interfaces";
|
||||
|
||||
export const RadialResult = ({
|
||||
module,
|
||||
score,
|
||||
total,
|
||||
png,
|
||||
}: ModuleScore) => (
|
||||
export const RadialResult = ({module, score, total, png}: ModuleScore) => (
|
||||
<View style={[styles.textFont, styles.radialContainer]}>
|
||||
<Text style={[styles.textColor, styles.textBold, { fontSize: 10 }]}>
|
||||
{module}
|
||||
</Text>
|
||||
<Text style={[styles.textColor, styles.textBold, {fontSize: 10}]}>{module}</Text>
|
||||
<Image src={png} style={styles.image64}></Image>
|
||||
<View style={[styles.textColor, styles.radialResultContainer]}>
|
||||
<Text style={styles.textBold}>{score}</Text>
|
||||
<Text style={{ fontSize: 8 }}>out of {total}</Text>
|
||||
<Text style={styles.textBold}>
|
||||
{module === "level" ? Math.floor(score) : calculateBandScore(score, total, module.toLowerCase() as Module | "overall", "general")}
|
||||
</Text>
|
||||
<Text style={{fontSize: 8}}>out of {module === "level" ? total : "9.0"}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import React from "react";
|
||||
import { View, StyleSheet } from "@react-pdf/renderer";
|
||||
import { ModuleScore } from "@/interfaces/module.scores";
|
||||
import { RadialResult } from "./radial.result";
|
||||
import {View, StyleSheet} from "@react-pdf/renderer";
|
||||
import {ModuleScore} from "@/interfaces/module.scores";
|
||||
import {RadialResult} from "./radial.result";
|
||||
interface Props {
|
||||
testDetails: ModuleScore[];
|
||||
}
|
||||
|
||||
const customStyles = StyleSheet.create({
|
||||
container: { display: "flex", flexDirection: "row", gap: 30 },
|
||||
container: {display: "flex", flexDirection: "row", gap: 30},
|
||||
});
|
||||
|
||||
export const SkillExamDetails = ({ testDetails }: Props) => (
|
||||
export const SkillExamDetails = ({testDetails}: Props) => (
|
||||
<View style={customStyles.container}>
|
||||
{testDetails.map((detail) => {
|
||||
const { module } = detail;
|
||||
const {module} = detail;
|
||||
return <RadialResult key={module} {...detail} />;
|
||||
})}
|
||||
</View>
|
||||
|
||||
@@ -112,11 +112,6 @@ const GroupTestReport = ({
|
||||
Candidate Information:
|
||||
</Text>
|
||||
<View style={styles.textMargin}>
|
||||
<Text style={defaultTextStyle}>Name: {name}</Text>
|
||||
<Text style={defaultTextStyle}>ID: {id}</Text>
|
||||
<Text style={defaultTextStyle}>Email: {email}</Text>
|
||||
<Text style={defaultTextStyle}>Gender: {gender}</Text>
|
||||
<Text style={defaultTextStyle}>Passport ID: {passportId}</Text>
|
||||
<Text style={defaultTextStyle}>
|
||||
Total Number of Students: {numberOfStudents}
|
||||
</Text>
|
||||
@@ -220,7 +215,7 @@ const GroupTestReport = ({
|
||||
</View>
|
||||
<View style={[{ paddingBottom: 30 }, styles.separator]}></View>
|
||||
<View style={{ flexGrow: 1 }}></View>
|
||||
<TestReportFooter />
|
||||
<TestReportFooter userId={id} />
|
||||
</Page>
|
||||
<Page style={styles.body}>
|
||||
<View
|
||||
@@ -242,10 +237,10 @@ const GroupTestReport = ({
|
||||
Sr
|
||||
</Text>
|
||||
<Text style={customStyles.tableCell}>Candidate Name</Text>
|
||||
<Text style={customStyles.tableCell}>Email ID</Text>
|
||||
<Text style={[customStyles.tableCell, { maxWidth: "48px" }]}>
|
||||
Gender
|
||||
<Text style={customStyles.tableCell}>
|
||||
Passport ID
|
||||
</Text>
|
||||
<Text style={customStyles.tableCell}>Email ID</Text>
|
||||
<Text style={[customStyles.tableCell, { maxWidth: "64px" }]}>
|
||||
Date of test
|
||||
</Text>
|
||||
@@ -255,7 +250,19 @@ const GroupTestReport = ({
|
||||
{showLevel && <Text style={customStyles.tableCell}>Level</Text>}
|
||||
</View>
|
||||
{studentsData.map(
|
||||
({ id, name, email, gender, date, result, level }, index) => (
|
||||
(
|
||||
{
|
||||
id,
|
||||
name,
|
||||
email,
|
||||
gender,
|
||||
date,
|
||||
result,
|
||||
level,
|
||||
passportId: studentPassportId,
|
||||
},
|
||||
index
|
||||
) => (
|
||||
<View
|
||||
style={[
|
||||
customStyles.tableRow,
|
||||
@@ -273,10 +280,8 @@ const GroupTestReport = ({
|
||||
{index + 1}
|
||||
</Text>
|
||||
<Text style={customStyles.tableCell}>{name}</Text>
|
||||
<Text style={customStyles.tableCell}>{studentPassportId}</Text>
|
||||
<Text style={customStyles.tableCell}>{email}</Text>
|
||||
<Text style={[customStyles.tableCell, { maxWidth: "48px" }]}>
|
||||
{gender}
|
||||
</Text>
|
||||
<Text style={[customStyles.tableCell, { maxWidth: "64px" }]}>
|
||||
{date}
|
||||
</Text>
|
||||
@@ -292,7 +297,7 @@ const GroupTestReport = ({
|
||||
</View>
|
||||
|
||||
<View style={{ flexGrow: 1 }}></View>
|
||||
<TestReportFooter />
|
||||
<TestReportFooter userId={id} />
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
|
||||
24
src/exams/pdf/list.item.tsx
Normal file
24
src/exams/pdf/list.item.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Text, View, StyleSheet } from "@react-pdf/renderer";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
},
|
||||
bullet: {
|
||||
height: "100%",
|
||||
},
|
||||
});
|
||||
|
||||
const ListItem = ({ text, textStyle }: { text: string, textStyle: any[] }) => {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={styles.bullet}>
|
||||
<Text style={textStyle}>{"\u2022" + " "}</Text>
|
||||
</View>
|
||||
<Text style={textStyle}>{text}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListItem;
|
||||
@@ -2,7 +2,11 @@ import React from "react";
|
||||
import { styles } from "./styles";
|
||||
import { View, Text } from "@react-pdf/renderer";
|
||||
|
||||
const TestReportFooter = () => (
|
||||
interface Props {
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
const TestReportFooter = ({ userId }: Props) => (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
@@ -25,10 +29,15 @@ const TestReportFooter = () => (
|
||||
</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.textBold}>Confidential – <Text style={[styles.textFont, styles.textNormal]}>circulated for concern people</Text></Text>
|
||||
<Text style={styles.textBold}>
|
||||
Confidential –{" "}
|
||||
<Text style={[styles.textFont, styles.textNormal]}>
|
||||
circulated for concern people
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={{ paddingTop: 10 }}>
|
||||
<View>
|
||||
<Text style={styles.textBold}>Declaration</Text>
|
||||
<Text style={{ paddingTop: 5 }}>
|
||||
We hereby declare that exam results on our platform, assessed by AI, are
|
||||
@@ -40,11 +49,26 @@ const TestReportFooter = () => (
|
||||
continuously enhance our system to ensure accuracy and reliability.
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ paddingTop: 4 }}>
|
||||
<Text style={styles.textBold}>
|
||||
PDF Version:{" "}
|
||||
<Text style={[styles.textFont, styles.textNormal]}>
|
||||
{process.env.PDF_VERSION}
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
{userId && (
|
||||
<View>
|
||||
<Text style={styles.textBold}>
|
||||
User ID:{" "}
|
||||
<Text style={[styles.textFont, styles.textNormal]}>{userId}</Text>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={[styles.textColor, { paddingTop: 5 }]}>
|
||||
<Text style={styles.textUnderline}>info@encoach.com</Text>
|
||||
<Text>https://encoach.com</Text>
|
||||
<View style={styles.spacedRow}>
|
||||
<Text>Group ID: TRI64BNBOIU5043</Text>
|
||||
<Text
|
||||
// style={styles.pageNumber}
|
||||
render={({ pageNumber, totalPages }) =>
|
||||
|
||||
@@ -6,11 +6,17 @@ import { styles } from "./styles";
|
||||
|
||||
import { StyleSheet } from "@react-pdf/renderer";
|
||||
import TestReportFooter from "./test.report.footer";
|
||||
import ListItem from "./list.item";
|
||||
|
||||
const customStyles = StyleSheet.create({
|
||||
testDetails: {
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
},
|
||||
testDetailsContainer: {
|
||||
display: "flex",
|
||||
gap: 16,
|
||||
},
|
||||
});
|
||||
|
||||
interface Props {
|
||||
@@ -82,7 +88,6 @@ const TestReport = ({
|
||||
</Text>
|
||||
<View style={styles.textMargin}>
|
||||
<Text style={defaultTextStyle}>Name: {name}</Text>
|
||||
<Text style={defaultTextStyle}>ID: {id}</Text>
|
||||
<Text style={defaultTextStyle}>Email: {email}</Text>
|
||||
<Text style={defaultTextStyle}>Gender: {gender}</Text>
|
||||
<Text style={defaultTextStyle}>Passport ID: {passportId}</Text>
|
||||
@@ -124,7 +129,7 @@ const TestReport = ({
|
||||
</View>
|
||||
</View>
|
||||
<View style={[{ paddingTop: 30 }, styles.separator]}></View>
|
||||
<TestReportFooter />
|
||||
<TestReportFooter userId={id} />
|
||||
</Page>
|
||||
<Page style={styles.body}>
|
||||
<View>
|
||||
@@ -149,23 +154,54 @@ const TestReport = ({
|
||||
.filter(
|
||||
({ suggestions, evaluation }) => suggestions || evaluation
|
||||
)
|
||||
.map(({ module, suggestions, evaluation }) => (
|
||||
<View key={module} style={customStyles.testDetails}>
|
||||
<Text style={[...defaultSkillsTitleStyle, styles.textBold]}>
|
||||
.map(
|
||||
({
|
||||
module,
|
||||
suggestions,
|
||||
evaluation,
|
||||
bullet_points = [],
|
||||
}) => (
|
||||
<View key={module} style={customStyles.testDetailsContainer}>
|
||||
<View style={customStyles.testDetails}>
|
||||
<Text
|
||||
style={[...defaultSkillsTitleStyle, styles.textBold]}
|
||||
>
|
||||
{module}
|
||||
</Text>
|
||||
<Text style={defaultSkillsTextStyle}>{evaluation}</Text>
|
||||
<Text style={defaultSkillsTextStyle}>{suggestions}</Text>
|
||||
</View>
|
||||
<View style={customStyles.testDetails}>
|
||||
{bullet_points.length > 0 && (
|
||||
<>
|
||||
<Text
|
||||
style={defaultSkillsTitleStyle}
|
||||
>
|
||||
How to Improve:
|
||||
</Text>
|
||||
<View>
|
||||
{bullet_points.map((text: string) => (
|
||||
<ListItem
|
||||
key={text}
|
||||
text={text}
|
||||
textStyle={defaultSkillsTextStyle}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.alignRightRow}>
|
||||
<Image src={qrcode} style={styles.qrcode} />
|
||||
</View>
|
||||
</View>
|
||||
<View style={[{ paddingBottom: 30 }, styles.separator]}></View>
|
||||
<View style={{ flexGrow: 1 }}></View>
|
||||
<TestReportFooter />
|
||||
<TestReportFooter userId={id} />
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
|
||||
26
src/hooks/useAcceptedTerms.tsx
Normal file
26
src/hooks/useAcceptedTerms.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import Checkbox from "@/components/Low/Checkbox";
|
||||
|
||||
const useAcceptedTerms = () => {
|
||||
const [acceptedTerms, setAcceptedTerms] = React.useState(false);
|
||||
|
||||
const renderCheckbox = () => (
|
||||
<Checkbox isChecked={acceptedTerms} onChange={setAcceptedTerms}>
|
||||
I agree to the
|
||||
<Link href={`https://encoach.com/terms`} className="text-mti-purple-light">
|
||||
{" "}
|
||||
Terms and Conditions
|
||||
</Link>{" "}
|
||||
and
|
||||
<Link href={`https://encoach.com/privacy-policy`} className="text-mti-purple-light">
|
||||
{" "}
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</Checkbox>
|
||||
);
|
||||
|
||||
return {acceptedTerms, renderCheckbox};
|
||||
};
|
||||
|
||||
export default useAcceptedTerms;
|
||||
42
src/hooks/useAssignmentArchive.tsx
Normal file
42
src/hooks/useAssignmentArchive.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
import axios from "axios";
|
||||
import {toast} from "react-toastify";
|
||||
import {BsArchive} from "react-icons/bs";
|
||||
|
||||
export const useAssignmentArchive = (assignmentId: string, reload?: Function) => {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const archive = () => {
|
||||
// archive assignment
|
||||
setLoading(true);
|
||||
axios
|
||||
.post(`/api/assignments/${assignmentId}/archive`)
|
||||
.then((res) => {
|
||||
toast.success("Assignment archived!");
|
||||
if (reload) reload();
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error("Failed to archive the assignment!");
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const renderIcon = (downloadClasses: string, loadingClasses: string) => {
|
||||
if (loading) {
|
||||
return <span className={`${loadingClasses} loading loading-infinity w-6`} />;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="tooltip flex items-center justify-center w-fit h-fit"
|
||||
data-tip="Archive assignment"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
archive();
|
||||
}}>
|
||||
<BsArchive className={`${downloadClasses} text-2xl cursor-pointer tooltip`} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return renderIcon;
|
||||
};
|
||||
42
src/hooks/useAssignmentUnarchive.tsx
Normal file
42
src/hooks/useAssignmentUnarchive.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
import axios from "axios";
|
||||
import {toast} from "react-toastify";
|
||||
import {BsArchive, BsFileEarmarkCheck, BsFileEarmarkCheckFill} from "react-icons/bs";
|
||||
|
||||
export const useAssignmentUnarchive = (assignmentId: string, reload?: Function) => {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const archive = () => {
|
||||
// archive assignment
|
||||
setLoading(true);
|
||||
axios
|
||||
.post(`/api/assignments/${assignmentId}/unarchive`)
|
||||
.then((res) => {
|
||||
toast.success("Assignment unarchived!");
|
||||
if (reload) reload();
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error("Failed to unarchive the assignment!");
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const renderIcon = (downloadClasses: string, loadingClasses: string) => {
|
||||
if (loading) {
|
||||
return <span className={`${loadingClasses} loading loading-infinity w-6`} />;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="tooltip flex items-center justify-center w-fit h-fit"
|
||||
data-tip="Unarchive assignment"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
archive();
|
||||
}}>
|
||||
<BsFileEarmarkCheck className={`${downloadClasses} text-2xl cursor-pointer tooltip`} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return renderIcon;
|
||||
};
|
||||
21
src/hooks/useCodes.tsx
Normal file
21
src/hooks/useCodes.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import {Code, Group, User} from "@/interfaces/user";
|
||||
import axios from "axios";
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
export default function useCodes(creator?: string) {
|
||||
const [codes, setCodes] = useState<Code[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const getData = () => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<Code[]>(`/api/code${creator ? `?creator=${creator}` : ""}`)
|
||||
.then((response) => setCodes(response.data))
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
useEffect(getData, [creator]);
|
||||
|
||||
return {codes, isLoading, isError, reload: getData};
|
||||
}
|
||||
22
src/hooks/useDiscounts.tsx
Normal file
22
src/hooks/useDiscounts.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Discount } from "@/interfaces/paypal";
|
||||
import { Code, Group, User } from "@/interfaces/user";
|
||||
import axios from "axios";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function useDiscounts(creator?: string) {
|
||||
const [discounts, setDiscounts] = useState<Discount[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const getData = () => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<Discount[]>("/api/discounts")
|
||||
.then((response) => setDiscounts(response.data))
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
useEffect(getData, [creator]);
|
||||
|
||||
return { discounts, isLoading, isError, reload: getData };
|
||||
}
|
||||
35
src/hooks/useInvites.tsx
Normal file
35
src/hooks/useInvites.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Invite } from "@/interfaces/invite";
|
||||
import { Ticket } from "@/interfaces/ticket";
|
||||
import { Code, Group, User } from "@/interfaces/user";
|
||||
import axios from "axios";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function useInvites({
|
||||
from,
|
||||
to,
|
||||
}: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
}) {
|
||||
const [invites, setInvites] = useState<Invite[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const getData = () => {
|
||||
const filters: ((i: Invite) => boolean)[] = [];
|
||||
if (from) filters.push((i: Invite) => i.from === from);
|
||||
if (to) filters.push((i: Invite) => i.to === to);
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<Invite[]>(`/api/invites`)
|
||||
.then((response) =>
|
||||
setInvites(filters.reduce((d, f) => d.filter(f), response.data)),
|
||||
)
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
useEffect(getData, [to, from]);
|
||||
|
||||
return { invites, isLoading, isError, reload: getData };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useState, useMemo} from 'react';
|
||||
import {useState, useMemo} from "react";
|
||||
import Input from "@/components/Low/Input";
|
||||
|
||||
/*fields example = [
|
||||
@@ -6,43 +6,33 @@ import Input from "@/components/Low/Input";
|
||||
['companyInformation', 'companyInformation', 'name']
|
||||
]*/
|
||||
|
||||
|
||||
const getFieldValue = (fields: string[], data: any): string => {
|
||||
if(fields.length === 0) return data;
|
||||
if (fields.length === 0) return data;
|
||||
const [key, ...otherFields] = fields;
|
||||
|
||||
if(data[key]) return getFieldValue(otherFields, data[key]);
|
||||
if (data[key]) return getFieldValue(otherFields, data[key]);
|
||||
return data;
|
||||
}
|
||||
};
|
||||
|
||||
export const useListSearch = (fields: string[][], rows: any[]) => {
|
||||
const [text, setText] = useState('');
|
||||
export function useListSearch<T>(fields: string[][], rows: T[]) {
|
||||
const [text, setText] = useState("");
|
||||
|
||||
const renderSearch = () => (
|
||||
<Input
|
||||
label="Search"
|
||||
type="text"
|
||||
name="search"
|
||||
onChange={setText}
|
||||
placeholder="Enter search text"
|
||||
value={text}
|
||||
/>
|
||||
)
|
||||
const renderSearch = () => <Input label="Search" type="text" name="search" onChange={setText} placeholder="Enter search text" value={text} />;
|
||||
|
||||
const updatedRows = useMemo(() => {
|
||||
const searchText = text.toLowerCase();
|
||||
return rows.filter((row) => {
|
||||
return fields.some((fieldsKeys) => {
|
||||
const value = getFieldValue(fieldsKeys, row);
|
||||
if(typeof value === 'string') {
|
||||
if (typeof value === "string") {
|
||||
return value.toLowerCase().includes(searchText);
|
||||
}
|
||||
})
|
||||
})
|
||||
}, [fields, rows, text])
|
||||
});
|
||||
});
|
||||
}, [fields, rows, text]);
|
||||
|
||||
return {
|
||||
rows: updatedRows,
|
||||
renderSearch,
|
||||
}
|
||||
};
|
||||
}
|
||||
23
src/hooks/usePaypalPayments.tsx
Normal file
23
src/hooks/usePaypalPayments.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import {PaypalPayment} from "@/interfaces/paypal";
|
||||
import axios from "axios";
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
export default function usePaypalPayments() {
|
||||
const [payments, setPayments] = useState<PaypalPayment[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const getData = () => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<PaypalPayment[]>("/api/payments/paypal")
|
||||
.then((response) => {
|
||||
return setPayments(response.data);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
useEffect(getData, []);
|
||||
|
||||
return {payments, isLoading, isError, reload: getData};
|
||||
}
|
||||
20
src/hooks/usePaypalTracking.tsx
Normal file
20
src/hooks/usePaypalTracking.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import axios from "axios";
|
||||
|
||||
export const usePaypalTracking = () => {
|
||||
const [trackingId, setTrackingId] = useState<string>();
|
||||
useEffect(() => {
|
||||
axios
|
||||
.put<{ ok: boolean; trackingId: string }>("/api/paypal/raas")
|
||||
.then((response) => {
|
||||
if (response.data.ok) {
|
||||
setTrackingId(response.data.trackingId);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return trackingId;
|
||||
};
|
||||
24
src/hooks/useSessions.tsx
Normal file
24
src/hooks/useSessions.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import {Exam} from "@/interfaces/exam";
|
||||
import {ExamState} from "@/stores/examStore";
|
||||
import axios from "axios";
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
export type Session = ExamState & {user: string; id: string; date: string};
|
||||
|
||||
export default function useSessions(user?: string) {
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const getData = () => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<Session[]>(`/api/sessions${user ? `?user=${user}` : ""}`)
|
||||
.then((response) => setSessions(response.data))
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
useEffect(getData, [user]);
|
||||
|
||||
return {sessions, isLoading, isError, reload: getData};
|
||||
}
|
||||
@@ -2,18 +2,22 @@ import {Stat, User} from "@/interfaces/user";
|
||||
import axios from "axios";
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
export default function useStats(id?: string) {
|
||||
export default function useStats(id?: string, shouldNotQuery?: boolean) {
|
||||
const [stats, setStats] = useState<Stat[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const getData = () => {
|
||||
if (shouldNotQuery) return;
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<Stat[]>(!id ? "/api/stats" : `/api/stats/user/${id}`)
|
||||
.then((response) => setStats(response.data))
|
||||
.then((response) => setStats(response.data.filter((x) => (id ? x.user === id : true))))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
};
|
||||
|
||||
return {stats, isLoading, isError};
|
||||
useEffect(getData, [id, shouldNotQuery]);
|
||||
|
||||
return {stats, reload: getData, isLoading, isError};
|
||||
}
|
||||
|
||||
22
src/hooks/useTickets.tsx
Normal file
22
src/hooks/useTickets.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { TicketWithCorporate } from "@/interfaces/ticket";
|
||||
import { Code, Group, User } from "@/interfaces/user";
|
||||
import axios from "axios";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
|
||||
export default function useTickets() {
|
||||
const [tickets, setTickets] = useState<TicketWithCorporate[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const getData = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<TicketWithCorporate[]>(`/api/tickets`)
|
||||
.then((response) => setTickets(response.data))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(getData, [getData]);
|
||||
|
||||
return { tickets, isLoading, isError, reload: getData };
|
||||
}
|
||||
29
src/hooks/useTicketsListener.tsx
Normal file
29
src/hooks/useTicketsListener.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
import useTickets from "./useTickets";
|
||||
|
||||
const useTicketsListener = (userId?: string) => {
|
||||
const { tickets, reload } = useTickets();
|
||||
|
||||
React.useEffect(() => {
|
||||
const intervalId = setInterval(() => {
|
||||
reload();
|
||||
}, 60 * 1000);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [reload]);
|
||||
|
||||
if (userId) {
|
||||
const assignedTickets = tickets.filter(
|
||||
(ticket) => ticket.assignedTo === userId && ticket.status === "submitted"
|
||||
);
|
||||
|
||||
return {
|
||||
assignedTickets,
|
||||
totalAssignedTickets: assignedTickets.length,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
export default useTicketsListener;
|
||||
@@ -15,10 +15,10 @@ export default function useUser({redirectTo = "", redirectIfFound = false} = {})
|
||||
if (!redirectTo || !user) return;
|
||||
|
||||
if (
|
||||
// If redirectTo is set, redirect if the user was not found.
|
||||
(redirectTo && !redirectIfFound && (!user || (user && !user.isVerified))) ||
|
||||
// If redirectIfFound is also set, redirect if the user was found
|
||||
(redirectIfFound && user && user.isVerified)
|
||||
(redirectIfFound && user && user.isVerified) ||
|
||||
// If redirectTo is set, redirect if the user was not found.
|
||||
(redirectTo && !redirectIfFound && (!user || (user && !user.isVerified)))
|
||||
) {
|
||||
Router.push(redirectTo);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {Module} from ".";
|
||||
|
||||
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
|
||||
export type Variant = "diagnostic" | "partial";
|
||||
export type Variant = "full" | "partial";
|
||||
export type InstructorGender = "male" | "female" | "varied";
|
||||
export type Difficulty = "easy" | "medium" | "hard";
|
||||
|
||||
export interface ReadingExam {
|
||||
parts: ReadingPart[];
|
||||
@@ -11,6 +13,7 @@ export interface ReadingExam {
|
||||
type: "academic" | "general";
|
||||
isDiagnostic: boolean;
|
||||
variant?: Variant;
|
||||
difficulty?: Difficulty;
|
||||
}
|
||||
|
||||
export interface ReadingPart {
|
||||
@@ -28,6 +31,7 @@ export interface LevelExam {
|
||||
minTimer: number;
|
||||
isDiagnostic: boolean;
|
||||
variant?: Variant;
|
||||
difficulty?: Difficulty;
|
||||
}
|
||||
|
||||
export interface ListeningExam {
|
||||
@@ -37,6 +41,7 @@ export interface ListeningExam {
|
||||
minTimer: number;
|
||||
isDiagnostic: boolean;
|
||||
variant?: Variant;
|
||||
difficulty?: Difficulty;
|
||||
}
|
||||
|
||||
export interface ListeningPart {
|
||||
@@ -59,15 +64,17 @@ export interface UserSolution {
|
||||
missing: number;
|
||||
};
|
||||
exercise: string;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export interface WritingExam {
|
||||
module: "writing";
|
||||
id: string;
|
||||
exercises: Exercise[];
|
||||
exercises: WritingExercise[];
|
||||
minTimer: number;
|
||||
isDiagnostic: boolean;
|
||||
variant?: Variant;
|
||||
difficulty?: Difficulty;
|
||||
}
|
||||
|
||||
interface WordCounter {
|
||||
@@ -78,10 +85,12 @@ interface WordCounter {
|
||||
export interface SpeakingExam {
|
||||
id: string;
|
||||
module: "speaking";
|
||||
exercises: Exercise[];
|
||||
exercises: (SpeakingExercise | InteractiveSpeakingExercise)[];
|
||||
minTimer: number;
|
||||
isDiagnostic: boolean;
|
||||
variant?: Variant;
|
||||
instructorGender: InstructorGender;
|
||||
difficulty?: Difficulty;
|
||||
}
|
||||
|
||||
export type Exercise =
|
||||
@@ -103,13 +112,25 @@ export interface Evaluation {
|
||||
|
||||
interface InteractiveSpeakingEvaluation extends Evaluation {
|
||||
perfect_answer_1?: string;
|
||||
transcript_1?: string;
|
||||
fixed_text_1?: string;
|
||||
perfect_answer_2?: string;
|
||||
transcript_2?: string;
|
||||
fixed_text_2?: string;
|
||||
perfect_answer_3?: string;
|
||||
transcript_3?: string;
|
||||
fixed_text_3?: string;
|
||||
}
|
||||
|
||||
interface SpeakingEvaluation extends CommonEvaluation {
|
||||
perfect_answer_1?: string;
|
||||
transcript_1?: string;
|
||||
fixed_text_1?: string;
|
||||
}
|
||||
|
||||
interface CommonEvaluation extends Evaluation {
|
||||
perfect_answer?: string;
|
||||
perfect_answer_1?: string;
|
||||
fixed_text?: string;
|
||||
}
|
||||
|
||||
export interface WritingExercise {
|
||||
@@ -128,6 +149,7 @@ export interface WritingExercise {
|
||||
solution: string;
|
||||
evaluation?: CommonEvaluation;
|
||||
}[];
|
||||
topic?: string;
|
||||
}
|
||||
|
||||
export interface SpeakingExercise {
|
||||
@@ -140,8 +162,9 @@ export interface SpeakingExercise {
|
||||
userSolutions: {
|
||||
id: string;
|
||||
solution: string;
|
||||
evaluation?: CommonEvaluation;
|
||||
evaluation?: SpeakingEvaluation;
|
||||
}[];
|
||||
topic?: string;
|
||||
}
|
||||
|
||||
export interface InteractiveSpeakingExercise {
|
||||
@@ -152,9 +175,10 @@ export interface InteractiveSpeakingExercise {
|
||||
prompts: {text: string; video_url: string}[];
|
||||
userSolutions: {
|
||||
id: string;
|
||||
solution: {question: string; answer: string}[];
|
||||
solution: {questionIndex: number; question: string; answer: string}[];
|
||||
evaluation?: InteractiveSpeakingEvaluation;
|
||||
}[];
|
||||
topic?: string;
|
||||
}
|
||||
|
||||
export interface FillBlanksExercise {
|
||||
@@ -209,17 +233,21 @@ export interface MatchSentencesExercise {
|
||||
id: string;
|
||||
prompt: string;
|
||||
userSolutions: {question: string; option: string}[];
|
||||
sentences: {
|
||||
sentences: MatchSentenceExerciseSentence[];
|
||||
allowRepetition: boolean;
|
||||
options: MatchSentenceExerciseOption[];
|
||||
}
|
||||
|
||||
export interface MatchSentenceExerciseSentence {
|
||||
id: string;
|
||||
sentence: string;
|
||||
solution: string;
|
||||
color: string;
|
||||
}[];
|
||||
allowRepetition: boolean;
|
||||
options: {
|
||||
}
|
||||
|
||||
export interface MatchSentenceExerciseOption {
|
||||
id: string;
|
||||
sentence: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface MultipleChoiceExercise {
|
||||
|
||||
5
src/interfaces/invite.ts
Normal file
5
src/interfaces/invite.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface Invite {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
@@ -4,13 +4,14 @@ export interface ModuleScore {
|
||||
score: number;
|
||||
total: number;
|
||||
code: Module;
|
||||
module: Module | 'Overall';
|
||||
png?: string,
|
||||
evaluation?: string,
|
||||
suggestions?: string,
|
||||
}
|
||||
module: Module | "Overall";
|
||||
png?: string;
|
||||
evaluation?: string;
|
||||
suggestions?: string;
|
||||
bullet_points?: string[];
|
||||
}
|
||||
|
||||
export interface StudentData {
|
||||
export interface StudentData {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
@@ -19,4 +20,5 @@ export interface ModuleScore {
|
||||
result: string;
|
||||
level?: string;
|
||||
bandScore: number;
|
||||
}
|
||||
passportId?: string;
|
||||
}
|
||||
|
||||
118
src/interfaces/paymob.ts
Normal file
118
src/interfaces/paymob.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
export interface PaymentIntention {
|
||||
amount: number;
|
||||
currency: string;
|
||||
payment_methods: number[];
|
||||
items: any[];
|
||||
billing_data: BillingData;
|
||||
customer: Customer;
|
||||
extras: IntentionExtras;
|
||||
}
|
||||
|
||||
interface BillingData {
|
||||
apartment: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
street: string;
|
||||
building: string;
|
||||
phone_number: string;
|
||||
country: string;
|
||||
email: string;
|
||||
floor: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
interface Customer {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
extras: IntentionExtras;
|
||||
}
|
||||
|
||||
type IntentionExtras = {[key: string]: string | number};
|
||||
|
||||
export interface IntentionResult {
|
||||
payment_keys: PaymentKeysItem[];
|
||||
id: string;
|
||||
intention_detail: IntentionDetail;
|
||||
client_secret: string;
|
||||
payment_methods: PaymentMethodsItem[];
|
||||
special_reference: null;
|
||||
extras: Extras;
|
||||
confirmed: boolean;
|
||||
status: string;
|
||||
created: string;
|
||||
card_detail: null;
|
||||
object: string;
|
||||
}
|
||||
|
||||
interface PaymentKeysItem {
|
||||
integration: number;
|
||||
key: string;
|
||||
gateway_type: string;
|
||||
iframe_id: null;
|
||||
}
|
||||
|
||||
interface IntentionDetail {
|
||||
amount: number;
|
||||
items: ItemsItem[];
|
||||
currency: string;
|
||||
}
|
||||
|
||||
interface ItemsItem {
|
||||
name: string;
|
||||
amount: number;
|
||||
description: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
interface PaymentMethodsItem {
|
||||
integration_id: number;
|
||||
alias: null;
|
||||
name: null;
|
||||
method_type: string;
|
||||
currency: string;
|
||||
live: boolean;
|
||||
use_cvc_with_moto: boolean;
|
||||
}
|
||||
|
||||
interface Extras {
|
||||
creation_extras: IntentionExtras;
|
||||
confirmation_extras: null;
|
||||
}
|
||||
|
||||
export interface TransactionResult {
|
||||
paymob_request_id: null;
|
||||
intention: IntentionResult;
|
||||
hmac: string;
|
||||
transaction: Transaction;
|
||||
}
|
||||
|
||||
interface Transaction {
|
||||
amount_cents: number;
|
||||
created_at: string;
|
||||
currency: string;
|
||||
error_occured: boolean;
|
||||
has_parent_transaction: boolean;
|
||||
id: number;
|
||||
integration_id: number;
|
||||
is_3d_secure: boolean;
|
||||
is_auth: boolean;
|
||||
is_capture: boolean;
|
||||
is_refunded: boolean;
|
||||
is_standalone_payment: boolean;
|
||||
is_voided: boolean;
|
||||
order: Order;
|
||||
owner: number;
|
||||
pending: boolean;
|
||||
source_data: Source_data;
|
||||
success: boolean;
|
||||
receipt: string;
|
||||
}
|
||||
interface Order {
|
||||
id: number;
|
||||
}
|
||||
interface Source_data {
|
||||
pan: string;
|
||||
sub_type: string;
|
||||
type: string;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user