Compare commits
388 Commits
feature/le
...
feature/ex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
6bcc303b74 | ||
|
|
8002c71b91 | ||
|
|
31d3232f19 | ||
|
|
4448c2019e | ||
|
|
01a9da3a5b | ||
|
|
d0b0dfb16f | ||
|
|
c5007a316f | ||
|
|
c68e206aae | ||
|
|
2bad3ad09f | ||
|
|
f9e037bd7b | ||
|
|
ccde1c84b7 | ||
|
|
367553eb44 | ||
|
|
576d2ac29d | ||
|
|
e13af65d88 | ||
|
|
294d319ab3 | ||
|
|
7572909b13 | ||
|
|
46b9fe50ef | ||
|
|
1335c14acc | ||
|
|
e47607597c | ||
|
|
b7b2dca2dd | ||
|
|
a14c9f8b3c | ||
|
|
e59d36e892 | ||
|
|
f5bdedee2f | ||
|
|
3f0821eb33 | ||
|
|
31e09c94c7 | ||
|
|
404e5a8a0c | ||
|
|
b7a3778f01 | ||
|
|
24ec336dca | ||
|
|
e324b37942 | ||
|
|
066baa9492 | ||
|
|
08aec9b54c | ||
|
|
10a480aa81 | ||
|
|
9baf3109c9 | ||
|
|
360e6f8f60 | ||
|
|
eadddbf505 | ||
|
|
be03760cb9 | ||
|
|
99758d860d | ||
|
|
8aca34e8b5 | ||
|
|
aaaf7f646d | ||
|
|
51dcb69b81 | ||
|
|
580ddfd9e6 | ||
|
|
9e6dc4b4c2 | ||
|
|
72b9e1f11d | ||
|
|
ad1dbaef27 | ||
|
|
6cdee9b268 | ||
|
|
7f4d82072f | ||
|
|
e365640620 | ||
|
|
27a4014f63 | ||
|
|
cb91acdded | ||
|
|
7714854338 | ||
|
|
5379cdb0d2 | ||
|
|
39ea11bc9b | ||
|
|
bb1a2e477a | ||
|
|
34c1041182 | ||
|
|
b2690f748b | ||
|
|
edbf405c30 | ||
|
|
84c42ccf3e | ||
|
|
5e283e358b | ||
|
|
c9ed3b5a72 | ||
|
|
3dfd65e161 | ||
|
|
040102c835 | ||
|
|
c781c10fe9 | ||
|
|
a91539ec61 | ||
|
|
f79857fabe | ||
|
|
14d8c1e294 | ||
|
|
fd1af3efee | ||
|
|
0c9f0b3dbd | ||
|
|
93d5015c99 | ||
|
|
356d7e6a9d | ||
|
|
2a4b7ed82d | ||
|
|
2ec7e85ace | ||
|
|
174398b4f7 | ||
|
|
b00bf19620 | ||
|
|
744aa1e788 | ||
|
|
cc0f9712d6 | ||
|
|
418221427a | ||
|
|
6c741f944d | ||
|
|
1aadc4647c | ||
|
|
4e378f0c71 | ||
|
|
f8bf58e57c | ||
|
|
271364a939 | ||
|
|
f8f8ee5e13 | ||
|
|
3b35a899e0 | ||
|
|
59d1a12439 | ||
|
|
e100c401e9 | ||
|
|
7b0f8c1c20 | ||
|
|
db2f5f2c0b | ||
|
|
0ed843125a | ||
|
|
14d19257df | ||
|
|
bdf65a7215 | ||
|
|
2540398ab0 | ||
|
|
cd8860f6ac | ||
|
|
2cd18376f2 | ||
|
|
0694950bba | ||
|
|
c6b15eaca1 | ||
|
|
647807a07c | ||
|
|
094fd05df7 | ||
|
|
1ea9d8e60f | ||
|
|
63998b50d6 | ||
|
|
0f029a21f7 | ||
|
|
7328f5c57f | ||
|
|
12d608879d | ||
|
|
9ceb71ae2f | ||
|
|
e6c82412bf | ||
|
|
5e8e46ff09 | ||
|
|
957400cb82 | ||
|
|
e687a2b3e5 | ||
|
|
7a297a6f6c | ||
|
|
432f4a735f | ||
|
|
a4f79d236d | ||
|
|
a4771d5d29 | ||
|
|
227de4ffc4 | ||
|
|
42fe650ae6 | ||
|
|
026730c077 | ||
|
|
35d1157b0c | ||
|
|
06dc92fdaa | ||
|
|
c9cac3539c | ||
|
|
d2276eba1d | ||
|
|
1c2c3fe402 | ||
|
|
d4b90b5fa4 | ||
|
|
383ddde7b5 | ||
|
|
e56636ca1f | ||
|
|
0b6a66b12d | ||
|
|
e0be2fd222 | ||
|
|
9e23e3e608 | ||
|
|
47ecc2be27 | ||
|
|
3ca0ad353e | ||
|
|
5447c89da4 | ||
|
|
c88757c869 | ||
|
|
8831729470 | ||
|
|
b3bb5a2337 | ||
|
|
b7ddee1db2 | ||
|
|
d85b9db535 | ||
|
|
d03d790327 | ||
|
|
79b159f948 | ||
|
|
3a0a9e1e99 | ||
|
|
cc2d0bf1b0 | ||
|
|
03a199983b | ||
|
|
a07e5a7312 | ||
|
|
fe5833b061 | ||
|
|
0c2200f49f | ||
|
|
cb73196503 | ||
|
|
c5fe405389 | ||
|
|
fddc3ff2f3 | ||
|
|
9dbe876d65 | ||
|
|
fd402bbd32 | ||
|
|
f2aa377cfe | ||
|
|
0f0223725e | ||
|
|
3ef29e43f5 | ||
|
|
60a7835040 | ||
|
|
1c645fcba2 | ||
|
|
938a5e9c7c | ||
|
|
cc655fed6c | ||
|
|
7f9692a3d9 | ||
|
|
cf90cae4eb | ||
|
|
fea8e0672e | ||
|
|
359748841f | ||
|
|
438778a03c | ||
|
|
c37bb2691b | ||
|
|
6c49409de8 | ||
|
|
2a335026de | ||
|
|
7712e5c71d | ||
|
|
861d97222a | ||
|
|
de862f635c | ||
|
|
ae058422aa | ||
|
|
44454d1e05 | ||
|
|
a2b9ba17a7 | ||
|
|
6f61fe1564 | ||
|
|
73d7ddc4af | ||
|
|
263f4afa82 | ||
|
|
45cf2dc279 | ||
|
|
786a425d85 | ||
|
|
d57223bd01 | ||
|
|
fbc2cff3f1 | ||
|
|
9ad4f077d1 | ||
|
|
e2b6061310 | ||
|
|
b77e97a9d2 | ||
|
|
67925c8a9e | ||
|
|
020ecff29c | ||
|
|
964660ed5d | ||
|
|
1390af62ab | ||
|
|
15947f942c | ||
|
|
7b3c3d15db | ||
|
|
1cff6fe242 | ||
|
|
4cbd045502 | ||
|
|
21b612eaa4 | ||
|
|
ef18e304a1 | ||
|
|
8e4223a9e7 | ||
|
|
7d696735ba | ||
|
|
e0ecc5be05 | ||
|
|
77af0b3495 | ||
|
|
e2e38284a7 | ||
|
|
ccd2560451 | ||
|
|
390658f2b0 | ||
|
|
450a4e9fe3 | ||
|
|
dfbbf0456d | ||
|
|
d46f92edb2 | ||
|
|
26c4368f31 | ||
|
|
ec56a5426b | ||
|
|
fe32584ff9 | ||
|
|
db7762c6e2 | ||
|
|
e70e26f84c | ||
|
|
7dc9d568d1 | ||
|
|
0049ab272b | ||
|
|
f48885bba6 | ||
|
|
5eaa0ac269 | ||
|
|
f7af21878e | ||
|
|
9d4071d4cd | ||
|
|
6f5dd86cd1 | ||
|
|
8b9537b272 | ||
|
|
a526e76c70 | ||
|
|
62b2f477f4 | ||
|
|
f36384fdb4 | ||
|
|
9c8d7988c5 | ||
|
|
18f163768c | ||
|
|
72083439af | ||
|
|
523149327b | ||
|
|
58c18133ec | ||
|
|
03520b650b | ||
|
|
556884058b | ||
|
|
73b0d5d41d | ||
|
|
7c589327f7 | ||
|
|
5c8867555d | ||
|
|
36be5267a2 | ||
|
|
4ebfd49cb9 | ||
|
|
96fe83de14 | ||
|
|
1746db3752 | ||
|
|
58b4883236 | ||
|
|
a3864eb7d3 | ||
|
|
1f0e5f4a08 | ||
|
|
c90234cefc | ||
|
|
f354a4f4fe | ||
|
|
7e0c071eee | ||
|
|
9bed726062 | ||
|
|
3878d4761e | ||
|
|
81f5af5629 | ||
|
|
5f76e430af | ||
|
|
facac33a89 | ||
|
|
f36c63f1b2 | ||
|
|
b1f07b877c | ||
|
|
70611305a7 | ||
|
|
fdedc2c5d3 | ||
|
|
75875b49e6 | ||
|
|
37e52886b5 | ||
|
|
a5dfe69220 | ||
|
|
1c36c7f1e1 | ||
|
|
9de39485de | ||
|
|
0fe2e0d393 | ||
|
|
dbb5e131fc | ||
|
|
ebda1e1717 | ||
|
|
8cbec131fe | ||
|
|
472d4a3331 | ||
|
|
c2f83d996a | ||
|
|
43bd6b24c5 | ||
|
|
ca89261e10 | ||
|
|
a9bbbe8b52 | ||
|
|
fa544bf4e8 | ||
|
|
7e91a989b3 | ||
|
|
c312260721 | ||
|
|
23f2bace5d | ||
|
|
7e2f1fcf9d | ||
|
|
6e420a8a82 | ||
|
|
cd81547022 | ||
|
|
a2baedb80c | ||
|
|
8072cefbe6 | ||
|
|
6bf666d01c | ||
|
|
7672e29063 | ||
|
|
51e7c535df | ||
|
|
d0f89cfe01 | ||
|
|
8de60aeb32 | ||
|
|
0e28473c31 | ||
|
|
52d4b831ae | ||
|
|
cdc8cfe46e | ||
|
|
4c7e8f56d8 | ||
|
|
4753b85ab5 |
1
.gitignore
vendored
@@ -38,3 +38,4 @@ next-env.d.ts
|
|||||||
.env
|
.env
|
||||||
.yarn/*
|
.yarn/*
|
||||||
.history*
|
.history*
|
||||||
|
__ENV.js
|
||||||
28
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Next.js: debug server-side",
|
||||||
|
"type": "node-terminal",
|
||||||
|
"request": "launch",
|
||||||
|
"command": "npm run dev"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Next.js: debug client-side",
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"url": "http://localhost:3000"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Next.js: debug full stack",
|
||||||
|
"type": "node-terminal",
|
||||||
|
"request": "launch",
|
||||||
|
"command": "npm run dev",
|
||||||
|
"serverReadyAction": {
|
||||||
|
"pattern": "- Local:.+(https?://.+)",
|
||||||
|
"uriFormat": "%s",
|
||||||
|
"action": "debugWithChrome"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -2,6 +2,25 @@
|
|||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/api/packages",
|
||||||
|
headers: [
|
||||||
|
{key: "Access-Control-Allow-Credentials", value: "false"},
|
||||||
|
{key: "Access-Control-Allow-Origin", value: "https://encoach.com"},
|
||||||
|
{
|
||||||
|
key: "Access-Control-Allow-Methods",
|
||||||
|
value: "GET",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Access-Control-Allow-Headers",
|
||||||
|
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = nextConfig;
|
module.exports = nextConfig;
|
||||||
|
|||||||
19
package.json
@@ -10,10 +10,14 @@
|
|||||||
"prepare": "husky install"
|
"prepare": "husky install"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@beam-australia/react-env": "^3.1.1",
|
||||||
"@headlessui/react": "^1.7.13",
|
"@headlessui/react": "^1.7.13",
|
||||||
"@mdi/js": "^7.1.96",
|
"@mdi/js": "^7.1.96",
|
||||||
"@mdi/react": "^1.6.1",
|
"@mdi/react": "^1.6.1",
|
||||||
"@next/font": "13.1.6",
|
"@next/font": "13.1.6",
|
||||||
|
"@paypal/paypal-js": "^7.1.0",
|
||||||
|
"@paypal/react-paypal-js": "^8.1.3",
|
||||||
|
"@react-pdf/renderer": "^3.1.14",
|
||||||
"@tanstack/react-table": "^8.10.1",
|
"@tanstack/react-table": "^8.10.1",
|
||||||
"@types/node": "18.13.0",
|
"@types/node": "18.13.0",
|
||||||
"@types/react": "18.0.27",
|
"@types/react": "18.0.27",
|
||||||
@@ -24,6 +28,7 @@
|
|||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
"countries-list": "^3.0.1",
|
"countries-list": "^3.0.1",
|
||||||
"country-codes-list": "^1.6.11",
|
"country-codes-list": "^1.6.11",
|
||||||
|
"currency-symbol-map": "^5.1.0",
|
||||||
"daisyui": "^3.1.5",
|
"daisyui": "^3.1.5",
|
||||||
"eslint": "8.33.0",
|
"eslint": "8.33.0",
|
||||||
"eslint-config-next": "13.1.6",
|
"eslint-config-next": "13.1.6",
|
||||||
@@ -33,18 +38,24 @@
|
|||||||
"formidable": "^3.5.0",
|
"formidable": "^3.5.0",
|
||||||
"formidable-serverless": "^1.1.1",
|
"formidable-serverless": "^1.1.1",
|
||||||
"framer-motion": "^9.0.2",
|
"framer-motion": "^9.0.2",
|
||||||
|
"howler": "^2.2.4",
|
||||||
"iron-session": "^6.3.1",
|
"iron-session": "^6.3.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
|
"moment-timezone": "^0.5.44",
|
||||||
"next": "13.1.6",
|
"next": "13.1.6",
|
||||||
"nodemailer": "^6.9.5",
|
"nodemailer": "^6.9.5",
|
||||||
"nodemailer-express-handlebars": "^6.1.0",
|
"nodemailer-express-handlebars": "^6.1.0",
|
||||||
"primeicons": "^6.0.1",
|
"primeicons": "^6.0.1",
|
||||||
"primereact": "^9.2.3",
|
"primereact": "^9.2.3",
|
||||||
|
"qrcode": "^1.5.3",
|
||||||
"random-words": "^2.0.0",
|
"random-words": "^2.0.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-chartjs-2": "^5.2.0",
|
"react-chartjs-2": "^5.2.0",
|
||||||
|
"react-csv": "^2.2.2",
|
||||||
|
"react-currency-input-field": "^3.6.12",
|
||||||
"react-datepicker": "^4.18.0",
|
"react-datepicker": "^4.18.0",
|
||||||
|
"react-diff-viewer": "^3.1.1",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-firebase-hooks": "^5.1.1",
|
"react-firebase-hooks": "^5.1.1",
|
||||||
"react-icons": "^4.8.0",
|
"react-icons": "^4.8.0",
|
||||||
@@ -56,6 +67,7 @@
|
|||||||
"react-string-replace": "^1.1.0",
|
"react-string-replace": "^1.1.0",
|
||||||
"react-toastify": "^9.1.2",
|
"react-toastify": "^9.1.2",
|
||||||
"react-xarrows": "^2.0.2",
|
"react-xarrows": "^2.0.2",
|
||||||
|
"read-excel-file": "^5.7.1",
|
||||||
"short-unique-id": "^5.0.2",
|
"short-unique-id": "^5.0.2",
|
||||||
"stripe": "^13.10.0",
|
"stripe": "^13.10.0",
|
||||||
"swr": "^2.1.3",
|
"swr": "^2.1.3",
|
||||||
@@ -67,10 +79,14 @@
|
|||||||
"zustand": "^4.3.6"
|
"zustand": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/blob-stream": "^0.1.33",
|
||||||
"@types/formidable": "^3.4.0",
|
"@types/formidable": "^3.4.0",
|
||||||
|
"@types/howler": "^2.2.11",
|
||||||
"@types/lodash": "^4.14.191",
|
"@types/lodash": "^4.14.191",
|
||||||
"@types/nodemailer": "^6.4.11",
|
"@types/nodemailer": "^6.4.11",
|
||||||
"@types/nodemailer-express-handlebars": "^4.0.3",
|
"@types/nodemailer-express-handlebars": "^4.0.3",
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
|
"@types/react-csv": "^1.1.10",
|
||||||
"@types/react-datepicker": "^4.15.1",
|
"@types/react-datepicker": "^4.15.1",
|
||||||
"@types/uuid": "^9.0.1",
|
"@types/uuid": "^9.0.1",
|
||||||
"@types/wavesurfer.js": "^6.0.6",
|
"@types/wavesurfer.js": "^6.0.6",
|
||||||
@@ -78,6 +94,7 @@
|
|||||||
"autoprefixer": "^10.4.13",
|
"autoprefixer": "^10.4.13",
|
||||||
"husky": "^8.0.3",
|
"husky": "^8.0.3",
|
||||||
"postcss": "^8.4.21",
|
"postcss": "^8.4.21",
|
||||||
"tailwindcss": "^3.2.4"
|
"tailwindcss": "^3.2.4",
|
||||||
|
"types/": "paypal/react-paypal-js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/audio/check.mp3
Normal file
BIN
public/audio/error.mp3
Normal file
BIN
public/audio/sent.mp3
Normal file
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 48 KiB |
BIN
public/radial_progress/azul_0.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
public/radial_progress/azul_10.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
public/radial_progress/azul_100.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/radial_progress/azul_20.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
public/radial_progress/azul_30.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/radial_progress/azul_40.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
public/radial_progress/azul_50.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/radial_progress/azul_60.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
public/radial_progress/azul_70.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
public/radial_progress/azul_80.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
public/radial_progress/azul_90.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
public/radial_progress/laranja_0.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/radial_progress/laranja_10.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
public/radial_progress/laranja_100.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/radial_progress/laranja_20.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/radial_progress/laranja_30.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/radial_progress/laranja_40.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/radial_progress/laranja_50.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/radial_progress/laranja_60.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
public/radial_progress/laranja_70.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
public/radial_progress/laranja_80.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/radial_progress/laranja_90.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
@@ -1,5 +1,5 @@
|
|||||||
import {EmploymentStatus, EMPLOYMENT_STATUS, Gender, User} from "@/interfaces/user";
|
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 countryCodes from "country-codes-list";
|
||||||
import {RadioGroup} from "@headlessui/react";
|
import {RadioGroup} from "@headlessui/react";
|
||||||
import Input from "./Low/Input";
|
import Input from "./Low/Input";
|
||||||
@@ -10,18 +10,29 @@ import axios from "axios";
|
|||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import {KeyedMutator} from "swr";
|
import {KeyedMutator} from "swr";
|
||||||
import CountrySelect from "./Low/CountrySelect";
|
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 {
|
interface Props {
|
||||||
|
user: User;
|
||||||
mutateUser: KeyedMutator<User>;
|
mutateUser: KeyedMutator<User>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DemographicInformationInput({mutateUser}: Props) {
|
export default function DemographicInformationInput({user, mutateUser}: Props) {
|
||||||
const [country, setCountry] = useState<string>();
|
const [country, setCountry] = useState<string>();
|
||||||
const [phone, setPhone] = useState<string>();
|
const [phone, setPhone] = useState<string>();
|
||||||
|
const [passport_id, setPassportID] = useState<string | undefined>(user.type === "student" ? user.demographicInformation?.passport_id : undefined);
|
||||||
const [gender, setGender] = useState<Gender>();
|
const [gender, setGender] = useState<Gender>();
|
||||||
const [employment, setEmployment] = useState<EmploymentStatus>();
|
const [employment, setEmployment] = useState<EmploymentStatus>();
|
||||||
|
const [position, setPosition] = useState<string>();
|
||||||
|
const [timezone, setTimezone] = useState<string>(moment.tz.guess());
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const [companyName, setCompanyName] = useState<string>();
|
||||||
|
const [commercialRegistration, setCommercialRegistration] = useState<string>();
|
||||||
|
|
||||||
const save = (e?: FormEvent) => {
|
const save = (e?: FormEvent) => {
|
||||||
if (e) e.preventDefault();
|
if (e) e.preventDefault();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -32,8 +43,12 @@ export default function DemographicInformationInput({mutateUser}: Props) {
|
|||||||
country,
|
country,
|
||||||
phone: `+${countryCodes.findOne("countryCode" as any, country!).countryCallingCode}${phone}`,
|
phone: `+${countryCodes.findOne("countryCode" as any, country!).countryCallingCode}${phone}`,
|
||||||
gender,
|
gender,
|
||||||
employment,
|
employment: user.type === "corporate" ? undefined : employment,
|
||||||
|
position: user.type === "corporate" ? position : undefined,
|
||||||
|
passport_id,
|
||||||
|
timezone,
|
||||||
},
|
},
|
||||||
|
agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined,
|
||||||
})
|
})
|
||||||
.then((response) => mutateUser((response.data as {user: User}).user))
|
.then((response) => mutateUser((response.data as {user: User}).user))
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -53,71 +68,47 @@ export default function DemographicInformationInput({mutateUser}: Props) {
|
|||||||
about yourself.
|
about yourself.
|
||||||
</h2>
|
</h2>
|
||||||
<form className="flex flex-col items-center justify-items-center gap-6 w-full h-full -md:px-4 lg:w-1/2 mb-32" onSubmit={save}>
|
<form className="flex flex-col items-center justify-items-center gap-6 w-full h-full -md:px-4 lg:w-1/2 mb-32" onSubmit={save}>
|
||||||
|
{user.type === "agent" && (
|
||||||
|
<div className="w-full flex gap-8">
|
||||||
|
<Input type="text" onChange={setCompanyName} name="companyName" label="Corporate Name" required />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
onChange={setCommercialRegistration}
|
||||||
|
name="commercialRegistration"
|
||||||
|
label="Commercial Registration"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="w-full grid grid-cols-2 gap-6">
|
||||||
<div className="relative flex flex-col gap-3 w-full">
|
<div className="relative flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
||||||
<CountrySelect value={country} onChange={setCountry} />
|
<CountrySelect value={country} onChange={setCountry} />
|
||||||
</div>
|
</div>
|
||||||
<Input type="tel" name="phone" label="Phone number" onChange={(e) => setPhone(e)} placeholder="Enter phone number" required />
|
<Input type="tel" name="phone" label="Phone number" onChange={(e) => setPhone(e)} placeholder="Enter phone number" required />
|
||||||
<div className="relative flex flex-col gap-3 w-full">
|
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Gender *</label>
|
|
||||||
<RadioGroup value={gender} onChange={setGender} className="flex flex-row justify-between">
|
|
||||||
<RadioGroup.Option value="male">
|
|
||||||
{({checked}) => (
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
|
||||||
)}>
|
|
||||||
Male
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</RadioGroup.Option>
|
|
||||||
<RadioGroup.Option value="female">
|
|
||||||
{({checked}) => (
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
|
||||||
)}>
|
|
||||||
Female
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</RadioGroup.Option>
|
|
||||||
<RadioGroup.Option value="other">
|
|
||||||
{({checked}) => (
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
|
||||||
)}>
|
|
||||||
Other
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</RadioGroup.Option>
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex flex-col gap-3 w-full">
|
{user.type === "student" && (
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Employment Status *</label>
|
<Input
|
||||||
<RadioGroup value={employment} onChange={setEmployment} className="grid grid-cols-2 items-center gap-4 place-items-center">
|
type="text"
|
||||||
{EMPLOYMENT_STATUS.map(({status, label}) => (
|
name="passport_id"
|
||||||
<RadioGroup.Option value={status} key={status}>
|
label="Passport/National ID"
|
||||||
{({checked}) => (
|
onChange={(e) => setPassportID(e)}
|
||||||
<span
|
value={passport_id}
|
||||||
className={clsx(
|
placeholder="Enter National ID or Passport number"
|
||||||
"px-6 py-4 w-44 md:w-48 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
required
|
||||||
"transition duration-300 ease-in-out",
|
/>
|
||||||
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
|
||||||
)}>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</RadioGroup.Option>
|
|
||||||
))}
|
<div className="flex flex-col gap-3 w-full">
|
||||||
</RadioGroup>
|
<label className="font-normal text-base text-mti-gray-dim">Timezone</label>
|
||||||
|
<TimezoneSelect value={timezone} onChange={setTimezone} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<GenderInput value={gender} onChange={setGender} />
|
||||||
|
{user.type === "corporate" && (
|
||||||
|
<Input name="position" onChange={setPosition} type="text" label="Position" placeholder="CEO, Head of Marketing..." required />
|
||||||
|
)}
|
||||||
|
{user.type !== "corporate" && <EmploymentStatusInput value={employment} onChange={setEmployment} />}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="self-end flex justify-end w-full gap-8 absolute bottom-8 left-0 px-8">
|
<div className="self-end flex justify-end w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
@@ -125,7 +116,14 @@ export default function DemographicInformationInput({mutateUser}: Props) {
|
|||||||
className="lg:mt-8 max-w-[400px] w-full self-end"
|
className="lg:mt-8 max-w-[400px] w-full self-end"
|
||||||
color="purple"
|
color="purple"
|
||||||
onClick={save}
|
onClick={save}
|
||||||
disabled={isLoading || !country || !phone || !gender || !employment}>
|
disabled={
|
||||||
|
isLoading ||
|
||||||
|
!country ||
|
||||||
|
!phone ||
|
||||||
|
!gender ||
|
||||||
|
(user.type === "corporate" ? !position : !employment) ||
|
||||||
|
(user.type === "agent" ? !companyName || !commercialRegistration : false)
|
||||||
|
}>
|
||||||
{!isLoading && "Save information"}
|
{!isLoading && "Save information"}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
import {infoButtonStyle} from "@/constants/buttonStyles";
|
||||||
import {BAND_SCORES} from "@/constants/ielts";
|
|
||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
@@ -15,6 +14,7 @@ import {useEffect, useState} from "react";
|
|||||||
import {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen, BsQuestionSquare} from "react-icons/bs";
|
import {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen, BsQuestionSquare} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import Button from "./Low/Button";
|
import Button from "./Low/Button";
|
||||||
|
import ModuleLevelSelector from "./Medium/ModuleLevelSelector";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -23,8 +23,8 @@ interface Props {
|
|||||||
|
|
||||||
export default function Diagnostic({onFinish}: Props) {
|
export default function Diagnostic({onFinish}: Props) {
|
||||||
const [focus, setFocus] = useState<"academic" | "general">();
|
const [focus, setFocus] = useState<"academic" | "general">();
|
||||||
const [levels, setLevels] = useState({reading: -1, listening: -1, writing: -1, speaking: -1});
|
const [levels, setLevels] = useState({reading: -1, listening: -1, writing: -1, speaking: -1, level: 0});
|
||||||
const [desiredLevels, setDesiredLevels] = useState({reading: 9, listening: 9, writing: 9, speaking: 9});
|
const [desiredLevels, setDesiredLevels] = useState({reading: 9, listening: 9, writing: 9, speaking: 9, level: 9});
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ export default function Diagnostic({onFinish}: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectExam = () => {
|
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) => {
|
Promise.all(examPromises).then((exams) => {
|
||||||
if (exams.every((x) => !!x)) {
|
if (exams.every((x) => !!x)) {
|
||||||
@@ -52,7 +52,7 @@ export default function Diagnostic({onFinish}: Props) {
|
|||||||
axios
|
axios
|
||||||
.patch("/api/users/update", {
|
.patch("/api/users/update", {
|
||||||
focus,
|
focus,
|
||||||
levels: Object.values(levels).includes(-1) ? {reading: 0, listening: 0, writing: 0, speaking: 0} : levels,
|
levels: Object.values(levels).includes(-1) ? {reading: 0, listening: 0, writing: 0, speaking: 0, level: 0} : levels,
|
||||||
desiredLevels,
|
desiredLevels,
|
||||||
isFirstLogin: false,
|
isFirstLogin: false,
|
||||||
})
|
})
|
||||||
@@ -91,111 +91,17 @@ export default function Diagnostic({onFinish}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center justify-center gap-8 w-full">
|
<div className="flex flex-col items-center justify-center gap-8 w-full">
|
||||||
<h2 className="font-semibold text-xl">What is your current IELTS level?</h2>
|
<h2 className="font-semibold text-xl">What is your current IELTS level?</h2>
|
||||||
<div className="flex flex-col gap-32 w-full mb-20">
|
<ModuleLevelSelector levels={levels} setLevels={setLevels} />
|
||||||
<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>
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<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="md:self-end flex -md:flex-col justify-between w-full gap-8 absolute bottom-8 left-0 px-4 md:px-8">
|
||||||
<div className="w-full tooltip" data-tip="Your screen size is too small to perform a diagnostic test">
|
<div className="w-full tooltip" data-tip="Your screen size is too small to perform a diagnostic test">
|
||||||
<Button
|
<Button
|
||||||
@@ -225,7 +131,5 @@ export default function Diagnostic({onFinish}: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ function WordsDrawer({words, isOpen, blankId, previouslySelectedWord, onCancel,
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between w-full">
|
<div className="flex justify-between w-full">
|
||||||
<Button color="purple" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
|
<Button color="purple" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
|
||||||
Back
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="purple" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
|
<Button color="purple" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
|
||||||
Confirm
|
Confirm
|
||||||
|
|||||||
@@ -5,26 +5,132 @@ import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill}
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import {downloadBlob} from "@/utils/evaluation";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||||
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function InteractiveSpeaking({id, title, text, type, prompts, onNext, onBack}: InteractiveSpeakingExercise & CommonProps) {
|
export default function InteractiveSpeaking({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
examID,
|
||||||
|
text,
|
||||||
|
type,
|
||||||
|
prompts,
|
||||||
|
userSolutions,
|
||||||
|
updateIndex,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
}: InteractiveSpeakingExercise & CommonProps) {
|
||||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [mediaBlob, setMediaBlob] = useState<string>();
|
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||||
const [promptIndex, setPromptIndex] = useState(0);
|
const [answers, setAnswers] = useState<{prompt: string; blob: string; questionIndex: number}[]>([]);
|
||||||
const [answers, setAnswers] = useState<{prompt: string; blob: string}[]>([]);
|
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 hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
useEffect(() => {
|
const saveToStorage = async (previousURL?: string) => {
|
||||||
if (hasExamEnded) {
|
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: 1, total: 1, 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({
|
onNext({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||||
|
score: {correct: 1, total: 1, missing: 0},
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (userSolutions.length > 0 && answers.length === 0) {
|
||||||
|
console.log(userSolutions);
|
||||||
|
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(() => {
|
||||||
|
console.log({answers});
|
||||||
|
}, [answers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (updateIndex) updateIndex(questionIndex);
|
||||||
|
}, [questionIndex, updateIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasExamEnded) {
|
||||||
|
const answer = {
|
||||||
|
questionIndex,
|
||||||
|
prompt: prompts[questionIndex].text,
|
||||||
|
blob: mediaBlob!,
|
||||||
|
};
|
||||||
|
|
||||||
|
onNext({
|
||||||
|
exercise: id,
|
||||||
|
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||||
score: {correct: 1, total: 1, missing: 0},
|
score: {correct: 1, total: 1, missing: 0},
|
||||||
type,
|
type,
|
||||||
});
|
});
|
||||||
@@ -46,19 +152,38 @@ export default function InteractiveSpeaking({id, title, text, type, prompts, onN
|
|||||||
}, [isRecording]);
|
}, [isRecording]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (promptIndex === answers.length - 1) {
|
if (questionIndex <= answers.length - 1) {
|
||||||
setMediaBlob(answers[promptIndex].blob);
|
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 = {
|
const answer = {
|
||||||
prompt: prompts[promptIndex].text,
|
questionIndex,
|
||||||
blob: mediaBlob!,
|
prompt: prompts[questionIndex].text,
|
||||||
|
blob: audioPath ? audioPath : mediaBlob!,
|
||||||
};
|
};
|
||||||
|
|
||||||
setAnswers((prev) => [...prev, answer]);
|
setAnswers((prev) => [...prev.filter((x) => x.questionIndex !== index), answer]);
|
||||||
setMediaBlob(undefined);
|
setMediaBlob(undefined);
|
||||||
|
|
||||||
|
setUserSolutions([
|
||||||
|
...storeUserSolutions.filter((x) => x.exercise !== id),
|
||||||
|
{
|
||||||
|
exercise: id,
|
||||||
|
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||||
|
score: {correct: 1, total: 1, missing: 0},
|
||||||
|
module: "speaking",
|
||||||
|
exam: examID,
|
||||||
|
type,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return answer;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -69,8 +194,8 @@ export default function InteractiveSpeaking({id, title, text, type, prompts, onN
|
|||||||
</div>
|
</div>
|
||||||
{prompts && prompts.length > 0 && (
|
{prompts && prompts.length > 0 && (
|
||||||
<div className="flex flex-col gap-4 w-full items-center">
|
<div className="flex flex-col gap-4 w-full items-center">
|
||||||
<video key={promptIndex} autoPlay controls className="max-w-3xl rounded-xl">
|
<video key={questionIndex} autoPlay controls className="max-w-3xl rounded-xl">
|
||||||
<source src={prompts[promptIndex].video_url} />
|
<source src={prompts[questionIndex].video_url} />
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -78,13 +203,13 @@ export default function InteractiveSpeaking({id, title, text, type, prompts, onN
|
|||||||
|
|
||||||
<ReactMediaRecorder
|
<ReactMediaRecorder
|
||||||
audio
|
audio
|
||||||
key={promptIndex}
|
key={questionIndex}
|
||||||
onStop={(blob) => setMediaBlob(blob)}
|
onStop={(blob) => setMediaBlob(blob)}
|
||||||
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => (
|
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">
|
<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>
|
<p className="text-base font-normal">Record your answer:</p>
|
||||||
<div className="flex gap-8 items-center justify-center py-8">
|
<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" />
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
{status === "idle" && (
|
{status === "idle" && (
|
||||||
@@ -163,9 +288,9 @@ export default function InteractiveSpeaking({id, title, text, type, prompts, onN
|
|||||||
</div>
|
</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">
|
<div className="flex gap-4 items-center">
|
||||||
<BsTrashFill
|
<BsTrashFill
|
||||||
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||||
@@ -195,44 +320,11 @@ export default function InteractiveSpeaking({id, title, text, type, prompts, onN
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8">
|
<div className="self-end flex justify-between w-full gap-8">
|
||||||
<Button
|
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() =>
|
|
||||||
onBack({
|
|
||||||
exercise: id,
|
|
||||||
solutions: answers,
|
|
||||||
score: {correct: 1, total: 1, missing: 0},
|
|
||||||
type,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button color="purple" disabled={!mediaBlob} isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
|
||||||
color="purple"
|
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
|
||||||
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,17 +48,38 @@ function Question({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MultipleChoice({id, prompt, type, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
|
export default function MultipleChoice({
|
||||||
|
id,
|
||||||
|
prompt,
|
||||||
|
type,
|
||||||
|
questions,
|
||||||
|
userSolutions,
|
||||||
|
updateIndex,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
}: MultipleChoiceExercise & CommonProps) {
|
||||||
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
||||||
const [questionIndex, setQuestionIndex] = useState(0);
|
|
||||||
|
|
||||||
|
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
|
||||||
|
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
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(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [hasExamEnded]);
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (updateIndex) updateIndex(questionIndex);
|
||||||
|
}, [questionIndex, updateIndex]);
|
||||||
|
|
||||||
const onSelectOption = (option: string) => {
|
const onSelectOption = (option: string) => {
|
||||||
const question = questions[questionIndex];
|
const question = questions[questionIndex];
|
||||||
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);
|
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);
|
||||||
@@ -78,16 +99,20 @@ export default function MultipleChoice({id, prompt, type, questions, userSolutio
|
|||||||
if (questionIndex === questions.length - 1) {
|
if (questionIndex === questions.length - 1) {
|
||||||
onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
} else {
|
} else {
|
||||||
setQuestionIndex((prev) => prev + 1);
|
setQuestionIndex(questionIndex + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scrollToTop();
|
||||||
};
|
};
|
||||||
|
|
||||||
const back = () => {
|
const back = () => {
|
||||||
if (questionIndex === 0) {
|
if (questionIndex === 0) {
|
||||||
onBack({exercise: id, solutions: answers, score: calculateScore(), type});
|
onBack({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
} else {
|
} else {
|
||||||
setQuestionIndex((prev) => prev - 1);
|
setQuestionIndex(questionIndex - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scrollToTop();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,28 +5,58 @@ import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill}
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import {downloadBlob} from "@/utils/evaluation";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||||
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function Speaking({id, title, text, video_url, type, prompts, onNext, onBack}: SpeakingExercise & CommonProps) {
|
export default function Speaking({id, title, text, video_url, type, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
|
||||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [mediaBlob, setMediaBlob] = useState<string>();
|
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||||
|
const [audioURL, setAudioURL] = useState<string>();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
useEffect(() => {
|
const saveToStorage = async () => {
|
||||||
if (hasExamEnded) {
|
if (mediaBlob && mediaBlob.startsWith("blob")) {
|
||||||
onNext({
|
const blobBuffer = await downloadBlob(mediaBlob);
|
||||||
exercise: id,
|
const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"});
|
||||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
|
||||||
score: {correct: 1, total: 1, missing: 0},
|
const seed = Math.random().toString().replace("0.", "");
|
||||||
type,
|
|
||||||
});
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [hasExamEnded]);
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
@@ -43,6 +73,32 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
|
|||||||
};
|
};
|
||||||
}, [isRecording]);
|
}, [isRecording]);
|
||||||
|
|
||||||
|
const next = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
const storagePath = await saveToStorage();
|
||||||
|
setIsLoading(false);
|
||||||
|
|
||||||
|
onNext({
|
||||||
|
exercise: id,
|
||||||
|
solutions: storagePath ? [{id, solution: storagePath}] : [],
|
||||||
|
score: {correct: 1, total: 1, missing: 0},
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const back = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
const storagePath = await saveToStorage();
|
||||||
|
setIsLoading(false);
|
||||||
|
|
||||||
|
onBack({
|
||||||
|
exercise: id,
|
||||||
|
solutions: storagePath ? [{id, solution: storagePath}] : [],
|
||||||
|
score: {correct: 1, total: 1, missing: 0},
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-full gap-9">
|
<div className="flex flex-col h-full w-full gap-9">
|
||||||
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
|
<div className="flex flex-col w-full gap-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">
|
<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>
|
<p className="text-base font-normal">Record your answer:</p>
|
||||||
<div className="flex gap-8 items-center justify-center py-8">
|
<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" />
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
{status === "idle" && (
|
{status === "idle" && (
|
||||||
@@ -168,9 +224,9 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
|
|||||||
</div>
|
</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">
|
<div className="flex gap-4 items-center">
|
||||||
<BsTrashFill
|
<BsTrashFill
|
||||||
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
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">
|
<div className="self-end flex justify-between w-full gap-8">
|
||||||
<Button
|
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
||||||
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">
|
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
|
||||||
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">
|
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
|||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = questions.length || 0;
|
const total = questions.length || 0;
|
||||||
const correct = answers.filter(
|
const correct = answers.filter(
|
||||||
(x) => questions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false,
|
(x) =>
|
||||||
|
questions
|
||||||
|
.find((y) => x.id.toString() === y.id.toString())
|
||||||
|
?.solution?.toString()
|
||||||
|
.toLowerCase() === x.solution.toLowerCase() || false,
|
||||||
).length;
|
).length;
|
||||||
const missing = total - answers.filter((x) => questions.find((y) => x.id.toString() === y.id.toString())).length;
|
const missing = total - answers.filter((x) => questions.find((y) => x.id.toString() === y.id.toString())).length;
|
||||||
|
|
||||||
@@ -62,41 +66,37 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
|||||||
</div>
|
</div>
|
||||||
<span className="text-sm w-full leading-6">You can click a selected option again to deselect it.</span>
|
<span className="text-sm w-full leading-6">You can click a selected option again to deselect it.</span>
|
||||||
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-8">
|
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-8">
|
||||||
{questions.map((question, index) => (
|
{questions.map((question, index) => {
|
||||||
|
const id = question.id.toString();
|
||||||
|
|
||||||
|
return (
|
||||||
<div key={question.id.toString()} className="flex flex-col gap-4">
|
<div key={question.id.toString()} className="flex flex-col gap-4">
|
||||||
<span>
|
<span>
|
||||||
{index + 1}. {question.prompt}
|
{index + 1}. {question.prompt}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<Button
|
<Button
|
||||||
variant={
|
variant={answers.find((x) => x.id.toString() === id)?.solution === "true" ? "solid" : "outline"}
|
||||||
answers.find((x) => x.id.toString() === question.id.toString())?.solution === "true" ? "solid" : "outline"
|
onClick={() => toggleAnswer("true", id)}
|
||||||
}
|
|
||||||
onClick={() => toggleAnswer("true", question.id.toString())}
|
|
||||||
className="!py-2">
|
className="!py-2">
|
||||||
True
|
True
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={
|
variant={answers.find((x) => x.id.toString() === id)?.solution === "false" ? "solid" : "outline"}
|
||||||
answers.find((x) => x.id.toString() === question.id.toString())?.solution === "false" ? "solid" : "outline"
|
onClick={() => toggleAnswer("false", id)}
|
||||||
}
|
|
||||||
onClick={() => toggleAnswer("false", question.id.toString())}
|
|
||||||
className="!py-2">
|
className="!py-2">
|
||||||
False
|
False
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={
|
variant={answers.find((x) => x.id.toString() === id)?.solution === "not_given" ? "solid" : "outline"}
|
||||||
answers.find((x) => x.id.toString() === question.id.toString())?.solution === "not_given"
|
onClick={() => toggleAnswer("not_given", id)}
|
||||||
? "solid"
|
|
||||||
: "outline"
|
|
||||||
}
|
|
||||||
onClick={() => toggleAnswer("not_given", question.id.toString())}
|
|
||||||
className="!py-2">
|
className="!py-2">
|
||||||
Not Given
|
Not Given
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ function Blank({
|
|||||||
const [userInput, setUserInput] = useState(userSolution || "");
|
const [userInput, setUserInput] = useState(userSolution || "");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const words = userInput.split(" ").filter((x) => x !== "");
|
const words = userInput.split(" ");
|
||||||
if (words.length >= maxWords) {
|
if (words.length > maxWords) {
|
||||||
toast.warning(`You have reached your word limit of ${maxWords} words!`, {toastId: "word-limit"});
|
toast.warning(`You have reached your word limit of ${maxWords} words!`, {toastId: "word-limit"});
|
||||||
setUserInput(words.join(" ").trim());
|
setUserInput(words.join(" ").trim());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,34 @@ export default function Writing({
|
|||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : "");
|
const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : "");
|
||||||
const [isSubmitEnabled, setIsSubmitEnabled] = useState(false);
|
const [isSubmitEnabled, setIsSubmitEnabled] = useState(false);
|
||||||
|
const [saveTimer, setSaveTimer] = useState(0);
|
||||||
|
|
||||||
|
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
useEffect(() => {
|
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: 1, total: 1, missing: 0}, type},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [saveTimer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (localStorage.getItem("enable_paste")) return;
|
||||||
|
|
||||||
const listener = (e: KeyboardEvent) => {
|
const listener = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "v") {
|
if ((e.ctrlKey || e.metaKey) && e.key === "v") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -93,22 +117,8 @@ export default function Writing({
|
|||||||
)}
|
)}
|
||||||
<div className="flex flex-col h-full w-full gap-9 mb-20">
|
<div className="flex flex-col h-full w-full gap-9 mb-20">
|
||||||
<div className="flex flex-col w-full gap-7 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
<div className="flex flex-col w-full gap-7 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
||||||
<span>
|
<span className="whitespace-pre-wrap">{prefix.replaceAll("\\n", "\n")}</span>
|
||||||
{prefix.split("\\n").map((line, index) => (
|
<span className="font-semibold whitespace-pre-wrap">{prompt.replaceAll("\\n", "\n")}</span>
|
||||||
<React.Fragment key={index}>
|
|
||||||
{line}
|
|
||||||
<br />
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
<span className="font-semibold">
|
|
||||||
{prompt.split("\\n").map((line, index) => (
|
|
||||||
<Fragment key={index}>
|
|
||||||
<p>{line}</p>
|
|
||||||
<br />
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
{attachment && (
|
{attachment && (
|
||||||
<img
|
<img
|
||||||
onClick={() => setIsModalOpen(true)}
|
onClick={() => setIsModalOpen(true)}
|
||||||
@@ -120,14 +130,7 @@ export default function Writing({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full h-full flex flex-col gap-4">
|
<div className="w-full h-full flex flex-col gap-4">
|
||||||
<span>
|
<span className="whitespace-pre-wrap">{suffix}</span>
|
||||||
{suffix.split("\\n").map((line, index) => (
|
|
||||||
<React.Fragment key={index}>
|
|
||||||
{line}
|
|
||||||
<br />
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
<textarea
|
<textarea
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
className="w-full h-full min-h-[300px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
|
className="w-full h-full min-h-[300px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
|
||||||
@@ -151,7 +154,14 @@ export default function Writing({
|
|||||||
<Button
|
<Button
|
||||||
color="purple"
|
color="purple"
|
||||||
disabled={!isSubmitEnabled}
|
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: 1, total: 1, missing: 0},
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
}
|
||||||
className="max-w-[200px] self-end w-full">
|
className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -22,27 +22,53 @@ import InteractiveSpeaking from "./InteractiveSpeaking";
|
|||||||
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false});
|
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false});
|
||||||
|
|
||||||
export interface CommonProps {
|
export interface CommonProps {
|
||||||
|
examID?: string;
|
||||||
|
updateIndex?: (internalIndex: number) => void;
|
||||||
onNext: (userSolutions: UserSolution) => void;
|
onNext: (userSolutions: UserSolution) => void;
|
||||||
onBack: (userSolutions: UserSolution) => void;
|
onBack: (userSolutions: UserSolution) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const renderExercise = (exercise: Exercise, onNext: (userSolutions: UserSolution) => void, onBack: (userSolutions: UserSolution) => void) => {
|
export const renderExercise = (
|
||||||
|
exercise: Exercise,
|
||||||
|
examID: string,
|
||||||
|
onNext: (userSolutions: UserSolution) => void,
|
||||||
|
onBack: (userSolutions: UserSolution) => void,
|
||||||
|
updateIndex?: (internalIndex: number) => void,
|
||||||
|
) => {
|
||||||
switch (exercise.type) {
|
switch (exercise.type) {
|
||||||
case "fillBlanks":
|
case "fillBlanks":
|
||||||
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||||
case "trueFalse":
|
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":
|
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":
|
case "multipleChoice":
|
||||||
return <MultipleChoice key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
|
return (
|
||||||
|
<MultipleChoice
|
||||||
|
key={exercise.id}
|
||||||
|
{...(exercise as MultipleChoiceExercise)}
|
||||||
|
updateIndex={updateIndex}
|
||||||
|
onNext={onNext}
|
||||||
|
onBack={onBack}
|
||||||
|
examID={examID}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "writeBlanks":
|
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":
|
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":
|
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":
|
case "interactiveSpeaking":
|
||||||
return <InteractiveSpeaking key={exercise.id} {...(exercise as InteractiveSpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
return (
|
||||||
|
<InteractiveSpeaking
|
||||||
|
key={exercise.id}
|
||||||
|
{...(exercise as InteractiveSpeakingExercise)}
|
||||||
|
examID={examID}
|
||||||
|
updateIndex={updateIndex}
|
||||||
|
onNext={onNext}
|
||||||
|
onBack={onBack}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
32
src/components/High/EmploymentStatusInput.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import {EmploymentStatus, EMPLOYMENT_STATUS} from "@/interfaces/user";
|
||||||
|
import {RadioGroup} from "@headlessui/react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value?: EmploymentStatus;
|
||||||
|
onChange: (value?: EmploymentStatus) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmploymentStatusInput({value, onChange}: Props) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Employment Status *</label>
|
||||||
|
<RadioGroup value={value} onChange={onChange} className="grid grid-cols-2 items-center gap-4 place-items-center">
|
||||||
|
{EMPLOYMENT_STATUS.map(({status, label}) => (
|
||||||
|
<RadioGroup.Option value={status} key={status}>
|
||||||
|
{({checked}) => (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"px-6 py-4 w-40 md:w-48 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||||
|
)}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</RadioGroup.Option>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/components/High/GenderInput.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import {Gender} from "@/interfaces/user";
|
||||||
|
import {RadioGroup} from "@headlessui/react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value?: Gender;
|
||||||
|
onChange: (value?: Gender) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GenderInput({value, onChange}: Props) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Gender *</label>
|
||||||
|
<RadioGroup value={value} onChange={onChange} className="flex flex-row gap-4 justify-between">
|
||||||
|
<RadioGroup.Option value="male">
|
||||||
|
{({checked}) => (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||||
|
)}>
|
||||||
|
Male
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</RadioGroup.Option>
|
||||||
|
<RadioGroup.Option value="female">
|
||||||
|
{({checked}) => (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||||
|
)}>
|
||||||
|
Female
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</RadioGroup.Option>
|
||||||
|
<RadioGroup.Option value="other">
|
||||||
|
{({checked}) => (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||||
|
)}>
|
||||||
|
Other
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</RadioGroup.Option>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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"].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>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
src/components/High/TicketSubmission.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import {Ticket, TicketType, TicketTypeLabel} from "@/interfaces/ticket";
|
||||||
|
import {User} from "@/interfaces/user";
|
||||||
|
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 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,
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/components/Low/Badge.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import {Module} from "@/interfaces";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
module: Module;
|
||||||
|
children: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Badge({module, children}: Props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={module}
|
||||||
|
className={clsx(
|
||||||
|
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl",
|
||||||
|
module === "reading" && "bg-ielts-reading",
|
||||||
|
module === "listening" && "bg-ielts-listening",
|
||||||
|
module === "writing" && "bg-ielts-writing",
|
||||||
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
|
module === "level" && "bg-ielts-level",
|
||||||
|
)}>
|
||||||
|
{module === "reading" && <BsBook className="w-4 h-4" />}
|
||||||
|
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
||||||
|
{module === "writing" && <BsPen className="w-4 h-4" />}
|
||||||
|
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
||||||
|
{module === "level" && <BsClipboard className="w-4 h-4" />}
|
||||||
|
<span className="text-sm">{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import {BsArrowRepeat} from "react-icons/bs";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
color?: "rose" | "purple" | "red" | "green";
|
color?: "rose" | "purple" | "red" | "green" | "gray" | "pink";
|
||||||
variant?: "outline" | "solid";
|
variant?: "outline" | "solid";
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -39,11 +39,21 @@ export default function Button({
|
|||||||
outline:
|
outline:
|
||||||
"bg-transparent text-mti-red-light border border-mti-red-light hover:bg-mti-red-light disabled:text-mti-red disabled:bg-mti-red-ultralight disabled:border-none selection:bg-mti-red-dark hover:text-white selection:text-white",
|
"bg-transparent text-mti-red-light border border-mti-red-light hover:bg-mti-red-light disabled:text-mti-red disabled:bg-mti-red-ultralight disabled:border-none selection:bg-mti-red-dark hover:text-white selection:text-white",
|
||||||
},
|
},
|
||||||
|
gray: {
|
||||||
|
solid: "bg-mti-gray-davy text-white border border-mti-gray-davy hover:bg-mti-gray-davy disabled:text-mti-gray-davy disabled:bg-mti-gray-davy selection:bg-mti-gray-davy",
|
||||||
|
outline:
|
||||||
|
"bg-transparent text-mti-gray-davy border border-mti-gray-davy hover:bg-mti-gray-davy disabled:text-mti-gray-davy disabled:bg-mti-gray-davy disabled:border-none selection:bg-mti-gray-davy hover:text-white selection:text-white",
|
||||||
|
},
|
||||||
rose: {
|
rose: {
|
||||||
solid: "bg-mti-rose-light text-white border border-mti-rose-light hover:bg-mti-rose disabled:text-mti-rose disabled:bg-mti-rose-ultralight selection:bg-mti-rose-dark",
|
solid: "bg-mti-rose-light text-white border border-mti-rose-light hover:bg-mti-rose disabled:text-mti-rose disabled:bg-mti-rose-ultralight selection:bg-mti-rose-dark",
|
||||||
outline:
|
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",
|
"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 (
|
return (
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ interface Props {
|
|||||||
isChecked: boolean;
|
isChecked: boolean;
|
||||||
onChange: (isChecked: boolean) => void;
|
onChange: (isChecked: boolean) => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Checkbox({isChecked, onChange, children}: Props) {
|
export default function Checkbox({isChecked, onChange, children, disabled}: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer" onClick={() => onChange(!isChecked)}>
|
<div className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer" onClick={() => {
|
||||||
|
if(disabled) return;
|
||||||
|
onChange(!isChecked);
|
||||||
|
}}>
|
||||||
<input type="checkbox" className="hidden" />
|
<input type="checkbox" className="hidden" />
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ export default function CountrySelect({value, disabled = false, onChange}: Props
|
|||||||
displayValue={(code: string) => {
|
displayValue={(code: string) => {
|
||||||
const country = countries[code as unknown as keyof TCountries];
|
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">
|
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-8">
|
||||||
|
|||||||
@@ -3,17 +3,31 @@ import {useState} from "react";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
type: "email" | "text" | "password" | "tel" | "number";
|
type: "email" | "text" | "password" | "tel" | "number";
|
||||||
|
roundness?: "full" | "xl";
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
defaultValue?: string | number;
|
defaultValue?: string | number;
|
||||||
|
value?: string | number;
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Input({type, label, placeholder, name, required = false, defaultValue, className, disabled = false, onChange}: Props) {
|
export default function Input({
|
||||||
|
type,
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
name,
|
||||||
|
required = false,
|
||||||
|
value,
|
||||||
|
defaultValue,
|
||||||
|
className,
|
||||||
|
roundness = "full",
|
||||||
|
disabled = false,
|
||||||
|
onChange,
|
||||||
|
}: Props) {
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
if (type === "password") {
|
if (type === "password") {
|
||||||
@@ -57,9 +71,15 @@ export default function Input({type, label, placeholder, name, required = false,
|
|||||||
type={type}
|
type={type}
|
||||||
name={name}
|
name={name}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
min={type === "number" ? 0 : undefined}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className="px-8 py-6 text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
className={clsx(
|
||||||
|
"px-8 py-6 text-sm font-normal bg-white border border-mti-gray-platinum focus:outline-none",
|
||||||
|
"placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed",
|
||||||
|
roundness === "full" ? "rounded-full" : "rounded-xl",
|
||||||
|
)}
|
||||||
required={required}
|
required={required}
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ interface Props {
|
|||||||
label: string;
|
label: string;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
color: "red" | "rose" | "purple" | Module;
|
color: "red" | "rose" | "purple" | Module;
|
||||||
|
mark?: number;
|
||||||
|
markLabel?: string;
|
||||||
useColor?: boolean;
|
useColor?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
textClassName?: 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} = {
|
const progressColorClass: {[key in typeof color]: string} = {
|
||||||
red: "bg-mti-red-light",
|
red: "bg-mti-red-light",
|
||||||
rose: "bg-mti-rose-light",
|
rose: "bg-mti-rose-light",
|
||||||
@@ -30,6 +32,9 @@ export default function ProgressBar({label, percentage, color, useColor = false,
|
|||||||
!useColor ? "bg-mti-gray-anti-flash" : progressColorClass[color],
|
!useColor ? "bg-mti-gray-anti-flash" : progressColorClass[color],
|
||||||
useColor && "bg-opacity-20",
|
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
|
<div
|
||||||
style={{width: `${percentage}%`}}
|
style={{width: `${percentage}%`}}
|
||||||
className={clsx("absolute transition-all duration-300 ease-in-out top-0 left-0 h-full overflow-hidden", progressColorClass[color])}
|
className={clsx("absolute transition-all duration-300 ease-in-out top-0 left-0 h-full overflow-hidden", progressColorClass[color])}
|
||||||
|
|||||||
68
src/components/Low/Select.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import { ComponentProps } from "react";
|
||||||
|
import ReactSelect 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Select({
|
||||||
|
value,
|
||||||
|
defaultValue,
|
||||||
|
options,
|
||||||
|
placeholder,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
isClearable,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<ReactSelect
|
||||||
|
className={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",
|
||||||
|
)}
|
||||||
|
options={options}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
menuPortalTarget={document?.body}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
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
@@ -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
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import {BsShield, BsShieldFill, BsXLg} from "react-icons/bs";
|
import { BsXLg } from "react-icons/bs";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -34,7 +34,8 @@ export default function MobileMenu({isOpen, onClose, path, user}: Props) {
|
|||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="ease-in duration-200"
|
leave="ease-in duration-200"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0">
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
|
|
||||||
@@ -47,42 +48,65 @@ export default function MobileMenu({isOpen, onClose, path, user}: Props) {
|
|||||||
enterTo="opacity-100 scale-100"
|
enterTo="opacity-100 scale-100"
|
||||||
leave="ease-in duration-200"
|
leave="ease-in duration-200"
|
||||||
leaveFrom="opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="opacity-0 scale-95">
|
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">
|
<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="/">
|
<Link href="/">
|
||||||
<Image src="/logo_title.png" alt="EnCoach logo" width={69} height={69} />
|
<Image
|
||||||
|
src="/logo_title.png"
|
||||||
|
alt="EnCoach logo"
|
||||||
|
width={69}
|
||||||
|
height={69}
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="cursor-pointer" onClick={onClose} tabIndex={0}>
|
<div
|
||||||
<BsXLg className="text-2xl text-mti-purple-light" onClick={onClose} />
|
className="cursor-pointer"
|
||||||
|
onClick={onClose}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<BsXLg
|
||||||
|
className="text-mti-purple-light text-2xl"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Title>
|
</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
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"transition ease-in-out duration-300 w-fit",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
path === "/" &&
|
||||||
)}>
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
|
)}
|
||||||
|
>
|
||||||
Dashboard
|
Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
{(user.type === "student" || user.type === "teacher" || user.type === "developer") && (
|
{(user.type === "student" ||
|
||||||
|
user.type === "teacher" ||
|
||||||
|
user.type === "developer") && (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
href="/exam"
|
href="/exam"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"transition ease-in-out duration-300 w-fit",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/exam" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
path === "/exam" &&
|
||||||
)}>
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
|
)}
|
||||||
|
>
|
||||||
Exams
|
Exams
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/exercises"
|
href="/exercises"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"transition ease-in-out duration-300 w-fit",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/exercises" &&
|
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
|
Exercises
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
@@ -90,41 +114,80 @@ export default function MobileMenu({isOpen, onClose, path, user}: Props) {
|
|||||||
<Link
|
<Link
|
||||||
href="/stats"
|
href="/stats"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"transition ease-in-out duration-300 w-fit",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/stats" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
path === "/stats" &&
|
||||||
)}>
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
|
)}
|
||||||
|
>
|
||||||
Stats
|
Stats
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/record"
|
href="/record"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"transition ease-in-out duration-300 w-fit",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/record" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
path === "/record" &&
|
||||||
)}>
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
|
)}
|
||||||
|
>
|
||||||
Record
|
Record
|
||||||
</Link>
|
</Link>
|
||||||
{user.type !== "student" && (
|
{["admin", "developer", "agent", "corporate"].includes(
|
||||||
|
user.type,
|
||||||
|
) && (
|
||||||
<Link
|
<Link
|
||||||
href="/admin"
|
href="/payment-record"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"transition ease-in-out duration-300 w-fit",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/admin" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
path === "/payment-record" &&
|
||||||
)}>
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
Admin
|
)}
|
||||||
|
>
|
||||||
|
Payment Record
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{["admin", "developer", "corporate", "teacher"].includes(
|
||||||
|
user.type,
|
||||||
|
) && (
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
className={clsx(
|
||||||
|
"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="/tickets"
|
||||||
|
className={clsx(
|
||||||
|
"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>
|
||||||
)}
|
)}
|
||||||
<Link
|
<Link
|
||||||
href="/profile"
|
href="/profile"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"transition ease-in-out duration-300 w-fit",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/profile" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
path === "/profile" &&
|
||||||
)}>
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
|
)}
|
||||||
|
>
|
||||||
Profile
|
Profile
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
className={clsx("transition ease-in-out duration-300 w-fit justify-self-end cursor-pointer")}
|
className={clsx(
|
||||||
onClick={logout}>
|
"w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out",
|
||||||
|
)}
|
||||||
|
onClick={logout}
|
||||||
|
>
|
||||||
Logout
|
Logout
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ interface Props {
|
|||||||
export default function Modal({isOpen, title, onClose, children}: Props) {
|
export default function Modal({isOpen, title, onClose, children}: Props) {
|
||||||
return (
|
return (
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<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
|
<Transition.Child
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="ease-out duration-300"
|
enter="ease-out duration-300"
|
||||||
|
|||||||
@@ -3,11 +3,19 @@ import Link from "next/link";
|
|||||||
import FocusLayer from "@/components/FocusLayer";
|
import FocusLayer from "@/components/FocusLayer";
|
||||||
import {preventNavigation} from "@/utils/navigation.disabled";
|
import {preventNavigation} from "@/utils/navigation.disabled";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {BsList} from "react-icons/bs";
|
import {BsList, BsQuestionCircle, BsQuestionCircleFill} from "react-icons/bs";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import MobileMenu from "./MobileMenu";
|
import MobileMenu from "./MobileMenu";
|
||||||
import {useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
|
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 {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -20,9 +28,10 @@ interface Props {
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
export default function Navbar({user, path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
export default function Navbar({user, path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
|
||||||
|
const [isTicketOpen, setIsTicketOpen] = useState(false);
|
||||||
|
|
||||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const expirationDateColor = (date: Date) => {
|
const expirationDateColor = (date: Date) => {
|
||||||
const momentDate = moment(date);
|
const momentDate = moment(date);
|
||||||
@@ -42,37 +51,60 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
|
|||||||
return today.add(7, "days").isAfter(momentDate);
|
return today.add(7, "days").isAfter(momentDate);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user.type !== "student" && user.type !== "teacher") return setDisablePaymentPage(false);
|
||||||
|
isUserFromCorporate(user.id).then((result) => setDisablePaymentPage(result));
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Modal isOpen={isTicketOpen} onClose={() => setIsTicketOpen(false)} title="Submit a ticket">
|
||||||
|
<TicketSubmission user={user} page={window.location.href} onClose={() => setIsTicketOpen(false)} />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{user && <MobileMenu path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />}
|
{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">
|
<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=" md:px-8 flex gap-8 items-center">
|
<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" />
|
<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>
|
</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",
|
||||||
|
)}
|
||||||
|
data-tip="Submit a help/feedback ticket"
|
||||||
|
onClick={() => setIsTicketOpen(true)}>
|
||||||
|
<BsQuestionCircleFill />
|
||||||
|
</button>
|
||||||
|
|
||||||
{showExpirationDate() && (
|
{showExpirationDate() && (
|
||||||
<Link
|
<Link
|
||||||
href="https://encoach.com/join"
|
href={disablePaymentPage ? "/payment" : ""}
|
||||||
data-tip="Expiry date"
|
data-tip="Expiry date"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"py-2 px-6 w-fit flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
"flex w-fit cursor-pointer justify-center rounded-full border px-6 py-2 text-sm font-normal focus:outline-none",
|
||||||
"transition duration-300 ease-in-out tooltip tooltip-bottom",
|
"tooltip tooltip-bottom transition duration-300 ease-in-out",
|
||||||
!user.subscriptionExpirationDate
|
!user.subscriptionExpirationDate
|
||||||
? "bg-mti-green-ultralight border-mti-green-light"
|
? "bg-mti-green-ultralight border-mti-green-light"
|
||||||
: expirationDateColor(user.subscriptionExpirationDate),
|
: expirationDateColor(user.subscriptionExpirationDate),
|
||||||
"bg-white border-mti-gray-platinum",
|
"border-mti-gray-platinum bg-white",
|
||||||
)}>
|
)}>
|
||||||
{!user.subscriptionExpirationDate && "Unlimited"}
|
{!user.subscriptionExpirationDate && "Unlimited"}
|
||||||
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
|
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<Link href={disableNavigation ? "" : "/profile"} className="flex gap-6 items-center justify-end -md:hidden">
|
<Link href={disableNavigation ? "" : "/profile"} className="-md:hidden flex items-center justify-end gap-6">
|
||||||
<img src={user.profilePicture} alt={user.name} className="w-10 h-10 rounded-full object-cover" />
|
<img src={user.profilePicture} alt={user.name} className="h-10 w-10 rounded-full object-cover" />
|
||||||
<span className="text-right -md:hidden">{user.name}</span>
|
<span className="-md:hidden text-right">
|
||||||
|
{user.type === "corporate" ? `${user.corporateInformation?.companyInformation.name} |` : ""} {user.name} |{" "}
|
||||||
|
{USER_TYPE_LABELS[user.type]}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
||||||
|
|||||||
77
src/components/PayPalPayment.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import {DurationUnit} from "@/interfaces/paypal";
|
||||||
|
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OnCancelledActions, OrderResponseBody} from "@paypal/paypal-js";
|
||||||
|
import {PayPalButtons, PayPalScriptProvider, usePayPalScriptReducer} from "@paypal/react-paypal-js";
|
||||||
|
import axios from "axios";
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
import {toast} from "react-toastify";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
clientID: string;
|
||||||
|
currency: string;
|
||||||
|
price: number;
|
||||||
|
duration: number;
|
||||||
|
duration_unit: DurationUnit;
|
||||||
|
loadScript?: boolean;
|
||||||
|
setIsLoading: (isLoading: boolean) => void;
|
||||||
|
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PayPalPayment({clientID, price, currency, duration, duration_unit, loadScript, setIsLoading, onSuccess}: Props) {
|
||||||
|
const createOrder = async (data: CreateOrderData, actions: CreateOrderActions): Promise<string> => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
return axios
|
||||||
|
.post<OrderResponseBody>("/api/paypal", {currencyCode: currency, price})
|
||||||
|
.then((response) => response.data)
|
||||||
|
.then((data) => data.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onApprove = async (data: OnApproveData, actions: OnApproveActions) => {
|
||||||
|
const request = await axios.post<{ok: boolean; reason?: string}>("/api/paypal/approve", {id: data.orderID, duration, duration_unit});
|
||||||
|
|
||||||
|
if (request.status !== 200) {
|
||||||
|
toast.error("Something went wrong, please try again later");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Your account has been credited more time!");
|
||||||
|
return onSuccess(duration, duration_unit);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = async (data: Record<string, unknown>) => {
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCancel = async (data: Record<string, unknown>, actions: OnCancelledActions) => {
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return loadScript ? (
|
||||||
|
<PayPalScriptProvider
|
||||||
|
options={{
|
||||||
|
clientId: clientID,
|
||||||
|
currency,
|
||||||
|
intent: "capture",
|
||||||
|
commit: true,
|
||||||
|
vault: true,
|
||||||
|
}}>
|
||||||
|
<PayPalButtons
|
||||||
|
className="w-full"
|
||||||
|
style={{layout: "vertical"}}
|
||||||
|
createOrder={createOrder}
|
||||||
|
onApprove={onApprove}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
</PayPalScriptProvider>
|
||||||
|
) : (
|
||||||
|
<PayPalButtons
|
||||||
|
className="w-full"
|
||||||
|
style={{layout: "vertical"}}
|
||||||
|
createOrder={createOrder}
|
||||||
|
onApprove={onApprove}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
src/components/PaymentAssetManager.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import React, {ChangeEvent} from "react";
|
||||||
|
import {BsUpload, BsDownload, BsTrash, BsArrowRepeat, BsXCircleFill} from "react-icons/bs";
|
||||||
|
import {FilesStorage} from "@/interfaces/storage.files";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
interface Asset {
|
||||||
|
file: string | File;
|
||||||
|
complete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PaymentAssetManager = (props: {
|
||||||
|
asset: string | undefined;
|
||||||
|
permissions: "read" | "write";
|
||||||
|
type: FilesStorage;
|
||||||
|
reload: () => void;
|
||||||
|
paymentId: string;
|
||||||
|
}) => {
|
||||||
|
const {asset, permissions, type, paymentId} = props;
|
||||||
|
|
||||||
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
const fileInputReplaceRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [managingAsset, setManagingAsset] = React.useState<Asset>({
|
||||||
|
file: asset || "",
|
||||||
|
complete: asset ? true : false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {file, complete} = managingAsset;
|
||||||
|
|
||||||
|
const deleteAsset = () => {
|
||||||
|
if (confirm("Are you sure you want to delete this document?")) {
|
||||||
|
axios
|
||||||
|
.delete(`/api/payments/files/${type}/${paymentId}`)
|
||||||
|
.then((response) => {
|
||||||
|
if (response.status === 200) {
|
||||||
|
console.log("File deleted successfully!");
|
||||||
|
setManagingAsset({
|
||||||
|
file: "",
|
||||||
|
complete: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("File deletion failed");
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error occurred during file deletion:", error);
|
||||||
|
})
|
||||||
|
.finally(props.reload);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFileInput = (onChange: any, ref: React.RefObject<HTMLInputElement>) => (
|
||||||
|
<input type="file" ref={ref} style={{display: "none"}} onChange={onChange} multiple={false} accept="application/pdf" />
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFileChange = async (e: Event, method: "post" | "patch") => {
|
||||||
|
const newFile = (e.target as HTMLInputElement).files?.[0];
|
||||||
|
if (newFile) {
|
||||||
|
setManagingAsset({
|
||||||
|
file: newFile,
|
||||||
|
complete: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", newFile);
|
||||||
|
|
||||||
|
axios[method](`/api/payments/files/${type}/${paymentId}`, formData, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (response.status === 200) {
|
||||||
|
console.log("File uploaded successfully!");
|
||||||
|
console.log("Uploaded File URL:", response.data.ref);
|
||||||
|
// Further actions upon successful upload
|
||||||
|
setManagingAsset({
|
||||||
|
file: response.data.ref,
|
||||||
|
complete: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("File upload failed");
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error occurred during file upload:", error);
|
||||||
|
})
|
||||||
|
.finally(props.reload);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadAsset = () => {
|
||||||
|
axios
|
||||||
|
.get(`/api/payments/files/${type}/${paymentId}`)
|
||||||
|
.then((response) => {
|
||||||
|
if (response.status === 200) {
|
||||||
|
console.log("Uploaded File URL:", response.data.url);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.download = response.data.filename;
|
||||||
|
link.href = response.data.url;
|
||||||
|
link.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("Failed to download file");
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error occurred during file upload:", error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (permissions === "read") {
|
||||||
|
if (file) return <BsDownload onClick={downloadAsset} />;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
if (complete) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BsDownload onClick={downloadAsset} />
|
||||||
|
<BsArrowRepeat onClick={() => fileInputReplaceRef.current?.click()} />
|
||||||
|
<BsTrash onClick={deleteAsset} />
|
||||||
|
{renderFileInput((e: Event) => handleFileChange(e, "patch"), fileInputReplaceRef)}
|
||||||
|
{renderFileInput((e: Event) => handleFileChange(e, "post"), fileInputRef)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span className="loading loading-infinity w-8" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return permissions === "write" ? (
|
||||||
|
<>
|
||||||
|
<BsUpload onClick={() => fileInputRef.current?.click()} />
|
||||||
|
{renderFileInput((e: Event) => handleFileChange(e, "post"), fileInputRef)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<BsXCircleFill />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PaymentAssetManager;
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
|
||||||
import {User} from "@/interfaces/user";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import LevelLabel from "./LevelLabel";
|
|
||||||
import LevelProgressBar from "./LevelProgressBar";
|
|
||||||
import {Avatar} from "primereact/avatar";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
user: User;
|
|
||||||
className: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProfileCard({user, className}: Props) {
|
|
||||||
return (
|
|
||||||
<div className={clsx("bg-white drop-shadow-xl p-4 md:p-8 rounded-xl w-full flex flex-col gap-6", className)}>
|
|
||||||
<div className="flex w-full items-center gap-8">
|
|
||||||
<div className="w-16 md:w-24 h-16 md:h-24 rounded-full border-2 md:border-4 border-white drop-shadow-md md:drop-shadow-xl">
|
|
||||||
{user.profilePicture.length > 0 && <img src={user.profilePicture} alt="Profile picture" className="rounded-full object-cover" />}
|
|
||||||
{user.profilePicture.length === 0 && (
|
|
||||||
<Avatar size="xlarge" style={{width: "100%", height: "100%"}} label={user.name.slice(0, 1)} shape="circle" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col justify-center">
|
|
||||||
<span className="text-neutral-600 font-bold text-xl lg:text-2xl">{user.name}</span>
|
|
||||||
<LevelLabel experience={user.experience} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<LevelProgressBar experience={user.experience} progressBarWidth="w-32 md:w-96" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
|
||||||
import {User} from "@/interfaces/user";
|
|
||||||
import {levelCalculator} from "@/resources/level";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import LevelLabel from "./LevelLabel";
|
|
||||||
import LevelProgressBar from "./LevelProgressBar";
|
|
||||||
import {Avatar} from "primereact/avatar";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
user: User;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProfileLevel({user, className}: Props) {
|
|
||||||
const levelResult = levelCalculator(user.experience);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={clsx("flex flex-col items-center justify-center gap-4", className)}>
|
|
||||||
<div className="w-16 md:w-24 h-16 md:h-24 rounded-full">
|
|
||||||
{user.profilePicture.length > 0 && <img src={user.profilePicture} alt="Profile picture" className="rounded-full object-cover" />}
|
|
||||||
{user.profilePicture.length === 0 && (
|
|
||||||
<Avatar size="xlarge" style={{width: "100%", height: "100%"}} label={user.name.slice(0, 1)} shape="circle" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1 items-center">
|
|
||||||
<LevelLabel experience={user.experience} />
|
|
||||||
<LevelProgressBar experience={user.experience} className="text-black" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
import {calculateAverageLevel} from "@/utils/score";
|
import {calculateAverageLevel} from "@/utils/score";
|
||||||
import {capitalize} from "lodash";
|
import {capitalize} from "lodash";
|
||||||
import {ReactElement} from "react";
|
import {ReactElement} from "react";
|
||||||
@@ -11,6 +12,7 @@ interface Props {
|
|||||||
icon: ReactElement;
|
icon: ReactElement;
|
||||||
value: string | number;
|
value: string | number;
|
||||||
label: string;
|
label: string;
|
||||||
|
tooltip?: string;
|
||||||
}[];
|
}[];
|
||||||
children?: ReactElement;
|
children?: ReactElement;
|
||||||
}
|
}
|
||||||
@@ -28,7 +30,7 @@ export default function ProfileSummary({user, items}: Props) {
|
|||||||
<div className="flex -md:flex-col justify-between w-full gap-8">
|
<div className="flex -md:flex-col justify-between w-full gap-8">
|
||||||
<div className="flex flex-col gap-2 py-2">
|
<div className="flex flex-col gap-2 py-2">
|
||||||
<h1 className="font-bold text-2xl md:text-4xl">{user.name}</h1>
|
<h1 className="font-bold text-2xl md:text-4xl">{user.name}</h1>
|
||||||
<h6 className="font-normal text-base text-mti-gray-taupe">{capitalize(user.type)}</h6>
|
<h6 className="font-normal text-base text-mti-gray-taupe">{USER_TYPE_LABELS[user.type]}</h6>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
|
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
|
||||||
@@ -47,7 +49,10 @@ export default function ProfileSummary({user, items}: Props) {
|
|||||||
<div className="flex justify-between w-full mt-8 -md:hidden">
|
<div className="flex justify-between w-full mt-8 -md:hidden">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<div className="flex gap-4 items-center" key={item.label}>
|
<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}
|
{item.icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { IconType } from "react-icons";
|
import { IconType } from "react-icons";
|
||||||
import { MdSpaceDashboard } from "react-icons/md";
|
import { MdSpaceDashboard } from "react-icons/md";
|
||||||
import {BsFileEarmarkText, BsClockHistory, BsPencil, BsGraphUp, BsChevronBarRight, BsChevronBarLeft, BsShieldFill} from "react-icons/bs";
|
import {
|
||||||
|
BsFileEarmarkText,
|
||||||
|
BsClockHistory,
|
||||||
|
BsPencil,
|
||||||
|
BsGraphUp,
|
||||||
|
BsChevronBarRight,
|
||||||
|
BsChevronBarLeft,
|
||||||
|
BsShieldFill,
|
||||||
|
BsCloudFill,
|
||||||
|
BsCurrencyDollar,
|
||||||
|
BsClipboardData,
|
||||||
|
} from "react-icons/bs";
|
||||||
import { RiLogoutBoxFill } from "react-icons/ri";
|
import { RiLogoutBoxFill } from "react-icons/ri";
|
||||||
import { SlPencil } from "react-icons/sl";
|
import { SlPencil } from "react-icons/sl";
|
||||||
import { FaAward } from "react-icons/fa";
|
import { FaAward } from "react-icons/fa";
|
||||||
@@ -31,24 +42,45 @@ interface NavProps {
|
|||||||
isMinimized?: boolean;
|
isMinimized?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false}: NavProps) => (
|
const Nav = ({
|
||||||
|
Icon,
|
||||||
|
label,
|
||||||
|
path,
|
||||||
|
keyPath,
|
||||||
|
disabled = false,
|
||||||
|
isMinimized = false,
|
||||||
|
}: NavProps) => (
|
||||||
<Link
|
<Link
|
||||||
href={!disabled ? keyPath : ""}
|
href={!disabled ? keyPath : ""}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"p-4 rounded-full flex gap-4 items-center cursor-pointer text-gray-500 hover:bg-mti-purple-light hover:text-white",
|
"flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white",
|
||||||
"transition-all duration-300 ease-in-out",
|
"transition-all duration-300 ease-in-out",
|
||||||
|
disabled
|
||||||
|
? "hover:bg-mti-gray-dim cursor-not-allowed"
|
||||||
|
: "hover:bg-mti-purple-light cursor-pointer",
|
||||||
path === keyPath && "bg-mti-purple-light text-white",
|
path === keyPath && "bg-mti-purple-light text-white",
|
||||||
isMinimized ? "w-fit" : "w-full min-w-[200px] 2xl:min-w-[220px] px-8",
|
isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]",
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
<Icon size={24} />
|
<Icon size={24} />
|
||||||
{!isMinimized && <span className="text-lg font-semibold">{label}</span>}
|
{!isMinimized && <span className="text-lg font-semibold">{label}</span>}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function Sidebar({path, navDisabled = false, focusMode = false, userType, onFocusLayerMouseEnter, className}: Props) {
|
export default function Sidebar({
|
||||||
|
path,
|
||||||
|
navDisabled = false,
|
||||||
|
focusMode = false,
|
||||||
|
userType,
|
||||||
|
onFocusLayerMouseEnter,
|
||||||
|
className,
|
||||||
|
}: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
|
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [
|
||||||
|
state.isSidebarMinimized,
|
||||||
|
state.toggleSidebarMinimized,
|
||||||
|
]);
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
axios.post("/api/logout").finally(() => {
|
axios.post("/api/logout").finally(() => {
|
||||||
@@ -61,13 +93,23 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"h-full flex bg-transparent flex-col justify-between px-4 py-4 pb-8 relative",
|
"relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8",
|
||||||
isMinimized ? "w-fit" : "w-1/6 -xl:w-fit",
|
isMinimized ? "w-fit" : "-xl:w-fit w-1/6",
|
||||||
className,
|
className,
|
||||||
)}>
|
)}
|
||||||
<div className="xl:flex -xl:hidden flex-col gap-3">
|
>
|
||||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
|
<div className="-xl:hidden flex-col gap-3 xl:flex">
|
||||||
{(userType === "student" || userType === "teacher" || userType === "developer") && (
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={MdSpaceDashboard}
|
||||||
|
label="Dashboard"
|
||||||
|
path={path}
|
||||||
|
keyPath="/"
|
||||||
|
isMinimized={isMinimized}
|
||||||
|
/>
|
||||||
|
{(userType === "student" ||
|
||||||
|
userType === "teacher" ||
|
||||||
|
userType === "developer") && (
|
||||||
<>
|
<>
|
||||||
<Nav
|
<Nav
|
||||||
disabled={disableNavigation}
|
disabled={disableNavigation}
|
||||||
@@ -87,48 +129,167 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
|
<Nav
|
||||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
|
disabled={disableNavigation}
|
||||||
{userType !== "student" && (
|
Icon={BsGraphUp}
|
||||||
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Admin" path={path} keyPath="/admin" isMinimized={isMinimized} />
|
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}
|
||||||
|
Icon={BsCurrencyDollar}
|
||||||
|
label="Payment Record"
|
||||||
|
path={path}
|
||||||
|
keyPath="/payment-record"
|
||||||
|
isMinimized={isMinimized}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{["admin", "developer", "corporate", "teacher"].includes(
|
||||||
|
userType || "",
|
||||||
|
) && (
|
||||||
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={BsShieldFill}
|
||||||
|
label="Settings"
|
||||||
|
path={path}
|
||||||
|
keyPath="/settings"
|
||||||
|
isMinimized={isMinimized}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{["admin", "developer", "agent"].includes(userType || "") && (
|
||||||
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={BsClipboardData}
|
||||||
|
label="Tickets"
|
||||||
|
path={path}
|
||||||
|
keyPath="/tickets"
|
||||||
|
isMinimized={isMinimized}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{userType === "developer" && (
|
||||||
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={BsCloudFill}
|
||||||
|
label="Generation"
|
||||||
|
path={path}
|
||||||
|
keyPath="/generation"
|
||||||
|
isMinimized={isMinimized}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
||||||
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} />
|
disabled={disableNavigation}
|
||||||
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
|
Icon={MdSpaceDashboard}
|
||||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
|
label="Dashboard"
|
||||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
|
path={path}
|
||||||
|
keyPath="/"
|
||||||
|
isMinimized={true}
|
||||||
|
/>
|
||||||
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={BsFileEarmarkText}
|
||||||
|
label="Exams"
|
||||||
|
path={path}
|
||||||
|
keyPath="/exam"
|
||||||
|
isMinimized={true}
|
||||||
|
/>
|
||||||
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={BsPencil}
|
||||||
|
label="Exercises"
|
||||||
|
path={path}
|
||||||
|
keyPath="/exercises"
|
||||||
|
isMinimized={true}
|
||||||
|
/>
|
||||||
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={BsGraphUp}
|
||||||
|
label="Stats"
|
||||||
|
path={path}
|
||||||
|
keyPath="/stats"
|
||||||
|
isMinimized={true}
|
||||||
|
/>
|
||||||
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={BsClockHistory}
|
||||||
|
label="Record"
|
||||||
|
path={path}
|
||||||
|
keyPath="/record"
|
||||||
|
isMinimized={true}
|
||||||
|
/>
|
||||||
{userType !== "student" && (
|
{userType !== "student" && (
|
||||||
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Admin" path={path} keyPath="/admin" isMinimized={true} />
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={BsShieldFill}
|
||||||
|
label="Settings"
|
||||||
|
path={path}
|
||||||
|
keyPath="/settings"
|
||||||
|
isMinimized={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{userType === "developer" && (
|
||||||
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={BsCloudFill}
|
||||||
|
label="Generation"
|
||||||
|
path={path}
|
||||||
|
keyPath="/generation"
|
||||||
|
isMinimized={true}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-0 absolute bottom-12">
|
<div className="fixed bottom-12 flex flex-col gap-0">
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={1}
|
tabIndex={1}
|
||||||
onClick={toggleMinimize}
|
onClick={toggleMinimize}
|
||||||
className={clsx(
|
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 ? "w-fit" : "w-full min-w-[250px] px-8",
|
||||||
)}>
|
)}
|
||||||
{isMinimized ? <BsChevronBarRight size={24} /> : <BsChevronBarLeft size={24} />}
|
>
|
||||||
{!isMinimized && <span className="text-lg font-medium">Minimize</span>}
|
{isMinimized ? (
|
||||||
|
<BsChevronBarRight size={24} />
|
||||||
|
) : (
|
||||||
|
<BsChevronBarLeft size={24} />
|
||||||
|
)}
|
||||||
|
{!isMinimized && (
|
||||||
|
<span className="text-lg font-medium">Minimize</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={1}
|
tabIndex={1}
|
||||||
onClick={focusMode ? () => {} : logout}
|
onClick={focusMode ? () => {} : logout}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"p-4 rounded-full flex gap-4 items-center cursor-pointer text-black hover:text-mti-rose transition duration-300 ease-in-out",
|
"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",
|
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
<RiLogoutBoxFill size={24} />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
{focusMode && (
|
||||||
|
<FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"rounded-full hover:text-white hover:bg-mti-red transition duration-300 ease-in-out my-1 px-5 py-2 text-center text-white bg-mti-red-light",
|
"rounded-full hover:text-white hover:bg-mti-gray-davy transition duration-300 ease-in-out my-1 px-5 py-2 text-center text-white bg-mti-gray-davy",
|
||||||
)}>
|
)}>
|
||||||
{solution.solution}
|
{solution?.solution}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -99,7 +99,7 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
|
|||||||
Correct
|
Correct
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="w-4 h-4 rounded-full bg-mti-red" />
|
<div className="w-4 h-4 rounded-full bg-mti-gray-davy" />
|
||||||
Unanswered
|
Unanswered
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import Button from "../Low/Button";
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {speakingReverseMarking} from "@/utils/score";
|
import {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});
|
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||||
|
|
||||||
@@ -20,9 +24,10 @@ export default function InteractiveSpeaking({
|
|||||||
onBack,
|
onBack,
|
||||||
}: InteractiveSpeakingExercise & CommonProps) {
|
}: InteractiveSpeakingExercise & CommonProps) {
|
||||||
const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
|
const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
|
||||||
|
const [diffNumber, setDiffNumber] = useState<0 | 1 | 2 | 3>(0);
|
||||||
|
|
||||||
useEffect(() => {
|
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(
|
Promise.all(userSolutions[0].solution.map((x) => axios.post(`/api/speaking`, {path: x.answer}, {responseType: "arraybuffer"}))).then(
|
||||||
(values) => {
|
(values) => {
|
||||||
setSolutionsURL(
|
setSolutionsURL(
|
||||||
@@ -40,6 +45,44 @@ export default function InteractiveSpeaking({
|
|||||||
|
|
||||||
return (
|
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 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 w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
@@ -47,7 +90,7 @@ export default function InteractiveSpeaking({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<span className="font-bold">You should talk about the following things:</span>
|
<span className="font-bold">You should talk about the following things:</span>
|
||||||
<div className="grid grid-cols-3 gap-6 text-center">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 text-center">
|
||||||
{prompts.map((x, index) => (
|
{prompts.map((x, index) => (
|
||||||
<div className="italic flex flex-col gap-2 text-sm" key={index}>
|
<div className="italic flex flex-col gap-2 text-sm" key={index}>
|
||||||
<video key={index} controls className="">
|
<video key={index} controls className="">
|
||||||
@@ -61,19 +104,32 @@ export default function InteractiveSpeaking({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full h-full flex flex-col gap-8">
|
<div className="w-full h-full flex flex-col gap-8">
|
||||||
<div className="flex items-center gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
{solutionsURL.map((x, index) => (
|
{solutionsURL.map((x, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="w-full min-w-[460px] p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
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">
|
<div className="flex gap-8 items-center justify-center py-8">
|
||||||
<Waveform audio={x} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
<Waveform audio={x} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||||
</div>
|
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{userSolutions && userSolutions.length > 0 && (
|
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<div className="flex gap-4 px-1">
|
<div className="flex gap-4 px-1">
|
||||||
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
|
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
|
||||||
@@ -82,9 +138,81 @@ export default function InteractiveSpeaking({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-mti-gray-smoke rounded-3xl">
|
{userSolutions[0].evaluation &&
|
||||||
|
Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length === 3 ? (
|
||||||
|
<Tab.Group>
|
||||||
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
||||||
|
<Tab
|
||||||
|
className={({selected}) =>
|
||||||
|
clsx(
|
||||||
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||||
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
Evaluation
|
||||||
|
</Tab>
|
||||||
|
<Tab
|
||||||
|
className={({selected}) =>
|
||||||
|
clsx(
|
||||||
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||||
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
Recommended Answer (Prompt 1)
|
||||||
|
</Tab>
|
||||||
|
<Tab
|
||||||
|
className={({selected}) =>
|
||||||
|
clsx(
|
||||||
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||||
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
Recommended Answer (Prompt 2)
|
||||||
|
</Tab>
|
||||||
|
<Tab
|
||||||
|
className={({selected}) =>
|
||||||
|
clsx(
|
||||||
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||||
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
Recommended Answer (Prompt 3)
|
||||||
|
</Tab>
|
||||||
|
</Tab.List>
|
||||||
|
<Tab.Panels>
|
||||||
|
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
|
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
||||||
|
</Tab.Panel>
|
||||||
|
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
|
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
||||||
|
{userSolutions[0].evaluation!.perfect_answer_1!.replaceAll(/\s{2,}/g, "\n\n")}
|
||||||
|
</span>
|
||||||
|
</Tab.Panel>
|
||||||
|
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
|
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
||||||
|
{userSolutions[0].evaluation!.perfect_answer_2!.replaceAll(/\s{2,}/g, "\n\n")}
|
||||||
|
</span>
|
||||||
|
</Tab.Panel>
|
||||||
|
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
|
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
||||||
|
{userSolutions[0].evaluation!.perfect_answer_3!.replaceAll(/\s{2,}/g, "\n\n")}
|
||||||
|
</span>
|
||||||
|
</Tab.Panel>
|
||||||
|
</Tab.Panels>
|
||||||
|
</Tab.Group>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-ielts-speaking/10 rounded-3xl">
|
||||||
{userSolutions[0].evaluation!.comment}
|
{userSolutions[0].evaluation!.comment}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -111,7 +239,11 @@ export default function InteractiveSpeaking({
|
|||||||
onNext({
|
onNext({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: userSolutions,
|
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,
|
type,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default function MatchSentencesSolutions({
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"w-8 h-8 rounded-full z-10 text-white",
|
"w-8 h-8 rounded-full z-10 text-white",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
!userSolutions.find((x) => x.question.toString() === id.toString()) && "!bg-mti-red",
|
!userSolutions.find((x) => x.question.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-purple",
|
||||||
userSolutions.find((x) => x.question.toString() === id.toString())?.option !== solution && "bg-mti-rose",
|
userSolutions.find((x) => x.question.toString() === id.toString())?.option !== solution && "bg-mti-rose",
|
||||||
)}>
|
)}>
|
||||||
@@ -96,7 +96,7 @@ export default function MatchSentencesSolutions({
|
|||||||
<div className="w-4 h-4 rounded-full bg-mti-purple" /> Correct
|
<div className="w-4 h-4 rounded-full bg-mti-purple" /> Correct
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="w-4 h-4 rounded-full bg-mti-red" /> Unanswered
|
<div className="w-4 h-4 rounded-full bg-mti-gray-davy" /> Unanswered
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="w-4 h-4 rounded-full bg-mti-rose" /> Wrong
|
<div className="w-4 h-4 rounded-full bg-mti-rose" /> Wrong
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
|
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ function Question({
|
|||||||
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
|
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
|
||||||
const optionColor = (option: string) => {
|
const optionColor = (option: string) => {
|
||||||
if (option === solution && !userSolution) {
|
if (option === solution && !userSolution) {
|
||||||
return "!border-mti-red-light !text-mti-red-light";
|
return "!border-mti-gray-davy !text-mti-gray-davy";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (option === solution) {
|
if (option === solution) {
|
||||||
@@ -54,7 +54,16 @@ function Question({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MultipleChoice({id, type, prompt, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
|
export default function MultipleChoice({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
prompt,
|
||||||
|
questions,
|
||||||
|
userSolutions,
|
||||||
|
updateIndex,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
}: MultipleChoiceExercise & CommonProps) {
|
||||||
const [questionIndex, setQuestionIndex] = useState(0);
|
const [questionIndex, setQuestionIndex] = useState(0);
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
@@ -67,6 +76,10 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
|
|||||||
return {total, correct, missing};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (updateIndex) updateIndex(questionIndex);
|
||||||
|
}, [questionIndex, updateIndex]);
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
if (questionIndex === questions.length - 1) {
|
if (questionIndex === questions.length - 1) {
|
||||||
onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
|
onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
|
||||||
@@ -101,7 +114,7 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
|
|||||||
Correct
|
Correct
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="w-4 h-4 rounded-full bg-mti-red" />
|
<div className="w-4 h-4 rounded-full bg-mti-gray-davy" />
|
||||||
Unanswered
|
Unanswered
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
|
|||||||
@@ -6,14 +6,20 @@ import Button from "../Low/Button";
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {speakingReverseMarking} from "@/utils/score";
|
import {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});
|
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||||
|
|
||||||
export default function Speaking({id, type, title, video_url, text, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
|
export default function Speaking({id, type, title, video_url, text, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
|
||||||
const [solutionURL, setSolutionURL] = useState<string>();
|
const [solutionURL, setSolutionURL] = useState<string>();
|
||||||
|
const [showDiff, setShowDiff] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userSolutions && userSolutions.length > 0) {
|
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
|
||||||
axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => {
|
axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => {
|
||||||
const blob = new Blob([data], {type: "audio/wav"});
|
const blob = new Blob([data], {type: "audio/wav"});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -25,6 +31,42 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
|
|
||||||
return (
|
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 h-full w-full gap-8 mb-20">
|
||||||
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
|
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
@@ -63,13 +105,22 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full h-full flex flex-col gap-8">
|
<div className="w-full h-full flex flex-col gap-8 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">
|
<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">
|
<div className="flex gap-8 items-center justify-center py-8">
|
||||||
{solutionURL && <Waveform audio={solutionURL} waveColor="#FCDDEC" progressColor="#EF5DA8" />}
|
{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>
|
||||||
</div>
|
</div>
|
||||||
{userSolutions && userSolutions.length > 0 && (
|
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<div className="flex gap-4 px-1">
|
<div className="flex gap-4 px-1">
|
||||||
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
|
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
|
||||||
@@ -78,9 +129,52 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-mti-gray-smoke rounded-3xl">
|
{userSolutions[0].evaluation &&
|
||||||
|
(userSolutions[0].evaluation.perfect_answer || userSolutions[0].evaluation.perfect_answer_1) ? (
|
||||||
|
<Tab.Group>
|
||||||
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
||||||
|
<Tab
|
||||||
|
className={({selected}) =>
|
||||||
|
clsx(
|
||||||
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||||
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
Evaluation
|
||||||
|
</Tab>
|
||||||
|
<Tab
|
||||||
|
className={({selected}) =>
|
||||||
|
clsx(
|
||||||
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||||
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
Recommended Answer
|
||||||
|
</Tab>
|
||||||
|
</Tab.List>
|
||||||
|
<Tab.Panels>
|
||||||
|
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
|
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
||||||
|
</Tab.Panel>
|
||||||
|
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
|
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
||||||
|
{userSolutions[0].evaluation!.perfect_answer &&
|
||||||
|
userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n")}
|
||||||
|
{userSolutions[0].evaluation!.perfect_answer_1 &&
|
||||||
|
userSolutions[0].evaluation!.perfect_answer_1.replaceAll(/\s{2,}/g, "\n\n")}
|
||||||
|
</span>
|
||||||
|
</Tab.Panel>
|
||||||
|
</Tab.Panels>
|
||||||
|
</Tab.Group>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-ielts-speaking/10 rounded-3xl">
|
||||||
{userSolutions[0].evaluation!.comment}
|
{userSolutions[0].evaluation!.comment}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -107,7 +201,11 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
onNext({
|
onNext({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: userSolutions,
|
solutions: userSolutions,
|
||||||
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
score: {
|
||||||
|
total: 100,
|
||||||
|
missing: 0,
|
||||||
|
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
|
||||||
|
},
|
||||||
type,
|
type,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
|||||||
return "rose";
|
return "rose";
|
||||||
}
|
}
|
||||||
|
|
||||||
return "red";
|
return "gray";
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -67,6 +67,7 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
|||||||
{userSolutions &&
|
{userSolutions &&
|
||||||
questions.map((question, index) => {
|
questions.map((question, index) => {
|
||||||
const userSolution = userSolutions.find((x) => x.id === question.id.toString());
|
const userSolution = userSolutions.find((x) => x.id === question.id.toString());
|
||||||
|
const solution = question.solution.toString().toLowerCase() as Solution;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={question.id.toString()} className="flex flex-col gap-4">
|
<div key={question.id.toString()} className="flex flex-col gap-4">
|
||||||
@@ -75,23 +76,23 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
|||||||
</span>
|
</span>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<Button
|
<Button
|
||||||
variant={question.solution === "true" || userSolution?.solution === "true" ? "solid" : "outline"}
|
variant={solution === "true" || userSolution?.solution.toLowerCase() === "true" ? "solid" : "outline"}
|
||||||
className="!py-2"
|
className="!py-2"
|
||||||
color={getButtonColor("true", question.solution, userSolution?.solution)}>
|
color={getButtonColor("true", solution, userSolution?.solution.toLowerCase() as Solution)}>
|
||||||
True
|
True
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={question.solution === "false" || userSolution?.solution === "false" ? "solid" : "outline"}
|
variant={solution === "false" || userSolution?.solution.toLowerCase() === "false" ? "solid" : "outline"}
|
||||||
className="!py-2"
|
className="!py-2"
|
||||||
color={getButtonColor("false", question.solution, userSolution?.solution)}>
|
color={getButtonColor("false", solution, userSolution?.solution.toLowerCase() as Solution)}>
|
||||||
False
|
False
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={
|
variant={
|
||||||
question.solution === "not_given" || userSolution?.solution === "not_given" ? "solid" : "outline"
|
solution === "not_given" || userSolution?.solution.toLowerCase() === "not_given" ? "solid" : "outline"
|
||||||
}
|
}
|
||||||
className="!py-2"
|
className="!py-2"
|
||||||
color={getButtonColor("not_given", question.solution, userSolution?.solution)}>
|
color={getButtonColor("not_given", solution, userSolution?.solution.toLowerCase() as Solution)}>
|
||||||
Not Given
|
Not Given
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,7 +106,7 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
|||||||
Correct
|
Correct
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="w-4 h-4 rounded-full bg-mti-red" />
|
<div className="w-4 h-4 rounded-full bg-mti-gray-davy" />
|
||||||
Unanswered
|
Unanswered
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function Blank({
|
|||||||
|
|
||||||
const getSolutionStyling = () => {
|
const getSolutionStyling = () => {
|
||||||
if (!userSolution) {
|
if (!userSolution) {
|
||||||
return "bg-mti-red-ultralight text-mti-red-light";
|
return "bg-mti-gray-davy text-white";
|
||||||
}
|
}
|
||||||
|
|
||||||
return "bg-mti-purple-ultralight text-mti-purple-light";
|
return "bg-mti-purple-ultralight text-mti-purple-light";
|
||||||
@@ -131,7 +131,7 @@ export default function WriteBlanksSolutions({
|
|||||||
Correct
|
Correct
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="w-4 h-4 rounded-full bg-mti-red" />
|
<div className="w-4 h-4 rounded-full bg-mti-gray-davy" />
|
||||||
Unanswered
|
Unanswered
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
|
||||||
import {WritingExercise} from "@/interfaces/exam";
|
import {WritingExercise} from "@/interfaces/exam";
|
||||||
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
|
||||||
import Icon from "@mdi/react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
import {Fragment, useEffect, useState} from "react";
|
import {Fragment, useEffect, useState} from "react";
|
||||||
import {toast} from "react-toastify";
|
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import {Dialog, Transition} from "@headlessui/react";
|
import {Dialog, Tab, Transition} from "@headlessui/react";
|
||||||
import {writingReverseMarking} from "@/utils/score";
|
import {writingReverseMarking} from "@/utils/score";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
|
||||||
|
|
||||||
export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
|
export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [showDiff, setShowDiff] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -68,18 +66,55 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full h-full flex flex-col gap-8">
|
<div className="w-full h-full flex flex-col gap-8">
|
||||||
{userSolutions && (
|
{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>
|
<span>Your answer:</span>
|
||||||
<textarea
|
<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">
|
||||||
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"
|
{userSolutions[0]!.solution.replaceAll("\\n", "\n")}
|
||||||
contentEditable={false}
|
</div>
|
||||||
readOnly
|
</>
|
||||||
value={userSolutions[0]!.solution}
|
)}
|
||||||
|
{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>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{userSolutions && userSolutions.length > 0 && (
|
{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" && (
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<div className="flex gap-4 px-1">
|
<div className="flex gap-4 px-1">
|
||||||
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
|
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
|
||||||
@@ -88,9 +123,48 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-mti-gray-smoke rounded-3xl">
|
{userSolutions[0].evaluation && userSolutions[0].evaluation.perfect_answer ? (
|
||||||
|
<Tab.Group>
|
||||||
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1">
|
||||||
|
<Tab
|
||||||
|
className={({selected}) =>
|
||||||
|
clsx(
|
||||||
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
|
||||||
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
Evaluation
|
||||||
|
</Tab>
|
||||||
|
<Tab
|
||||||
|
className={({selected}) =>
|
||||||
|
clsx(
|
||||||
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
|
||||||
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
Recommended Answer
|
||||||
|
</Tab>
|
||||||
|
</Tab.List>
|
||||||
|
<Tab.Panels>
|
||||||
|
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
|
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
||||||
|
</Tab.Panel>
|
||||||
|
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
|
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
||||||
|
{userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n").replaceAll("\\n", "\n")}
|
||||||
|
</span>
|
||||||
|
</Tab.Panel>
|
||||||
|
</Tab.Panels>
|
||||||
|
</Tab.Group>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-ielts-writing/10 rounded-3xl">
|
||||||
{userSolutions[0].evaluation!.comment}
|
{userSolutions[0].evaluation!.comment}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -117,7 +191,11 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
onNext({
|
onNext({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: userSolutions,
|
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,
|
type,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,27 +22,36 @@ import Writing from "./Writing";
|
|||||||
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), {ssr: false});
|
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), {ssr: false});
|
||||||
|
|
||||||
export interface CommonProps {
|
export interface CommonProps {
|
||||||
|
updateIndex?: (internalIndex: number) => void;
|
||||||
onNext: (userSolutions: UserSolution) => void;
|
onNext: (userSolutions: UserSolution) => void;
|
||||||
onBack: (userSolutions: UserSolution) => void;
|
onBack: (userSolutions: UserSolution) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: () => void) => {
|
export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: () => void, updateIndex?: (internalIndex: number) => void) => {
|
||||||
switch (exercise.type) {
|
switch (exercise.type) {
|
||||||
case "fillBlanks":
|
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":
|
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":
|
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":
|
case "multipleChoice":
|
||||||
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
|
return (
|
||||||
|
<MultipleChoice
|
||||||
|
key={exercise.id}
|
||||||
|
{...(exercise as MultipleChoiceExercise)}
|
||||||
|
updateIndex={updateIndex}
|
||||||
|
onNext={onNext}
|
||||||
|
onBack={onBack}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "writeBlanks":
|
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":
|
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":
|
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":
|
case "interactiveSpeaking":
|
||||||
return <InteractiveSpeaking {...(exercise as InteractiveSpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
return <InteractiveSpeaking key={exercise.id} {...(exercise as InteractiveSpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import axios from "axios";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {Divider} from "primereact/divider";
|
import {Divider} from "primereact/divider";
|
||||||
import {useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import ReactDatePicker from "react-datepicker";
|
import ReactDatePicker from "react-datepicker";
|
||||||
import {BsFileEarmarkText, BsPencil, BsStar} from "react-icons/bs";
|
import {BsFileEarmarkText, BsPencil, BsStar} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
@@ -17,6 +17,8 @@ import Input from "./Low/Input";
|
|||||||
import ProfileSummary from "./ProfileSummary";
|
import ProfileSummary from "./ProfileSummary";
|
||||||
import Select from "react-select";
|
import Select from "react-select";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
|
import {CURRENCIES} from "@/resources/paypal";
|
||||||
|
|
||||||
const expirationDateColor = (date: Date) => {
|
const expirationDateColor = (date: Date) => {
|
||||||
const momentDate = moment(date);
|
const momentDate = moment(date);
|
||||||
@@ -33,22 +35,106 @@ interface Props {
|
|||||||
onClose: (reload?: boolean) => void;
|
onClose: (reload?: boolean) => void;
|
||||||
onViewStudents?: () => void;
|
onViewStudents?: () => void;
|
||||||
onViewTeachers?: () => void;
|
onViewTeachers?: () => void;
|
||||||
|
onViewCorporate?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers}: Props) => {
|
const USER_STATUS_OPTIONS = [
|
||||||
|
{
|
||||||
|
value: "active",
|
||||||
|
label: "Active",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "disabled",
|
||||||
|
label: "Disabled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "paymentDue",
|
||||||
|
label: "Payment Due",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const USER_TYPE_OPTIONS = Object.keys(USER_TYPE_LABELS).map((type) => ({
|
||||||
|
value: type,
|
||||||
|
label: USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const CURRENCIES_OPTIONS = CURRENCIES.map(({label, currency}) => ({value: currency, label}));
|
||||||
|
|
||||||
|
const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers, onViewCorporate, disabled = false}: Props) => {
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null | undefined>(user.subscriptionExpirationDate);
|
const [expiryDate, setExpiryDate] = useState<Date | null | undefined>(user.subscriptionExpirationDate);
|
||||||
const [referralAgent, setReferralAgent] = useState(user.corporateInformation?.referralAgent);
|
|
||||||
const [type, setType] = useState(user.type);
|
const [type, setType] = useState(user.type);
|
||||||
const [status, setStatus] = useState(user.status);
|
const [status, setStatus] = useState(user.status);
|
||||||
|
const [referralAgentLabel, setReferralAgentLabel] = useState<string>();
|
||||||
|
const [position, setPosition] = useState<string | undefined>(user.type === "corporate" ? user.demographicInformation?.position : undefined);
|
||||||
|
const [passport_id, setPassportID] = useState<string | undefined>(user.type === "student" ? user.demographicInformation?.passport_id : undefined);
|
||||||
|
|
||||||
|
const [referralAgent, setReferralAgent] = useState(user.type === "corporate" ? user.corporateInformation?.referralAgent : undefined);
|
||||||
|
const [companyName, setCompanyName] = useState(
|
||||||
|
user.type === "corporate"
|
||||||
|
? user.corporateInformation?.companyInformation.name
|
||||||
|
: user.type === "agent"
|
||||||
|
? user.agentInformation?.companyName
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
const [commercialRegistration, setCommercialRegistration] = useState(
|
||||||
|
user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined,
|
||||||
|
);
|
||||||
|
const [userAmount, setUserAmount] = useState(user.type === "corporate" ? user.corporateInformation?.companyInformation.userAmount : undefined);
|
||||||
|
const [paymentValue, setPaymentValue] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.value : undefined);
|
||||||
|
const [paymentCurrency, setPaymentCurrency] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.currency : "EUR");
|
||||||
|
const [monthlyDuration, setMonthlyDuration] = useState(user.type === "corporate" ? user.corporateInformation?.monthlyDuration : undefined);
|
||||||
|
const [commissionValue, setCommission] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.commission : undefined);
|
||||||
const {stats} = useStats(user.id);
|
const {stats} = useStats(user.id);
|
||||||
const {users} = useUsers();
|
const {users} = useUsers();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (users && users.length > 0) {
|
||||||
|
if (!referralAgent) {
|
||||||
|
setReferralAgentLabel("No manager");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = users.find((x) => x.id === referralAgent);
|
||||||
|
setReferralAgentLabel(`${agent?.name} - ${agent?.email}`);
|
||||||
|
}
|
||||||
|
}, [users, referralAgent]);
|
||||||
|
|
||||||
const updateUser = () => {
|
const updateUser = () => {
|
||||||
|
if (user.type === "corporate" && (!paymentValue || paymentValue < 0))
|
||||||
|
return toast.error("Please set a price for the user's package before updating!");
|
||||||
if (!confirm(`Are you sure you want to update ${user.name}'s account?`)) return;
|
if (!confirm(`Are you sure you want to update ${user.name}'s account?`)) return;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {...user, subscriptionExpirationDate: expiryDate, type, status})
|
.post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {
|
||||||
|
...user,
|
||||||
|
subscriptionExpirationDate: expiryDate,
|
||||||
|
type,
|
||||||
|
status,
|
||||||
|
agentInformation:
|
||||||
|
type === "agent"
|
||||||
|
? {
|
||||||
|
name: companyName,
|
||||||
|
commercialRegistration,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
corporateInformation:
|
||||||
|
type === "corporate"
|
||||||
|
? {
|
||||||
|
referralAgent,
|
||||||
|
monthlyDuration,
|
||||||
|
companyInformation: {
|
||||||
|
name: companyName,
|
||||||
|
userAmount,
|
||||||
|
},
|
||||||
|
payment: {
|
||||||
|
value: paymentValue,
|
||||||
|
currency: paymentCurrency,
|
||||||
|
...(referralAgent === "" ? {} : {commission: commissionValue}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("User updated successfully!");
|
toast.success("User updated successfully!");
|
||||||
onClose(true);
|
onClose(true);
|
||||||
@@ -81,6 +167,168 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers}:
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{user.type === "agent" && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full">
|
||||||
|
<Input
|
||||||
|
label="Corporate Name"
|
||||||
|
type="text"
|
||||||
|
name="companyName"
|
||||||
|
onChange={setCompanyName}
|
||||||
|
placeholder="Enter corporate name"
|
||||||
|
defaultValue={companyName}
|
||||||
|
required
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Commercial Registration"
|
||||||
|
type="text"
|
||||||
|
name="commercialRegistration"
|
||||||
|
onChange={setCommercialRegistration}
|
||||||
|
placeholder="Enter commercial registration"
|
||||||
|
defaultValue={commercialRegistration}
|
||||||
|
required
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Divider className="w-full !m-0" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{user.type === "corporate" && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 w-full">
|
||||||
|
<Input
|
||||||
|
label="Corporate Name"
|
||||||
|
type="text"
|
||||||
|
name="companyName"
|
||||||
|
onChange={setCompanyName}
|
||||||
|
placeholder="Enter corporate name"
|
||||||
|
defaultValue={companyName}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Number of Users"
|
||||||
|
type="number"
|
||||||
|
name="userAmount"
|
||||||
|
onChange={(e) => setUserAmount(e ? parseInt(e) : undefined)}
|
||||||
|
placeholder="Enter number of users"
|
||||||
|
defaultValue={userAmount}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Monthly Duration"
|
||||||
|
type="number"
|
||||||
|
name="monthlyDuration"
|
||||||
|
onChange={(e) => setMonthlyDuration(e ? parseInt(e) : undefined)}
|
||||||
|
placeholder="Enter monthly duration"
|
||||||
|
defaultValue={monthlyDuration}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-3 w-full lg:col-span-2">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Pricing</label>
|
||||||
|
<div className="w-full grid grid-cols-5 gap-2">
|
||||||
|
<Input
|
||||||
|
name="paymentValue"
|
||||||
|
onChange={(e) => setPaymentValue(e ? parseInt(e) : undefined)}
|
||||||
|
type="number"
|
||||||
|
defaultValue={paymentValue || 0}
|
||||||
|
className="col-span-3"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<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",
|
||||||
|
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",
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 w-full">
|
||||||
|
<div className="flex flex-col gap-3 w-8/12">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Country Manager</label>
|
||||||
|
{referralAgentLabel && (
|
||||||
|
<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) &&
|
||||||
|
"!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}`})),
|
||||||
|
]}
|
||||||
|
defaultValue={{
|
||||||
|
value: referralAgent,
|
||||||
|
label: referralAgentLabel,
|
||||||
|
}}
|
||||||
|
menuPortalTarget={document?.body}
|
||||||
|
onChange={(value) => setReferralAgent(value?.value)}
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
// editing country manager should only be available for dev/admin
|
||||||
|
isDisabled={!["developer", "admin"].includes(loggedInUser.type)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 w-4/12">
|
||||||
|
{referralAgent !== "" && loggedInUser.type !== "corporate" ? (
|
||||||
|
<>
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Commission</label>
|
||||||
|
<Input
|
||||||
|
name="commissionValue"
|
||||||
|
onChange={(e) => setCommission(e ? parseInt(e) : undefined)}
|
||||||
|
type="number"
|
||||||
|
defaultValue={commissionValue || 0}
|
||||||
|
className="col-span-3"
|
||||||
|
disabled={disabled || loggedInUser.type === "agent"}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Divider className="w-full !m-0" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<section className="flex flex-col gap-4 justify-between">
|
<section className="flex flex-col gap-4 justify-between">
|
||||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||||
<Input
|
<Input
|
||||||
@@ -119,12 +367,27 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers}:
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{user.type === "student" && (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="passport_id"
|
||||||
|
label="Passport/National ID"
|
||||||
|
onChange={() => null}
|
||||||
|
placeholder="Enter National ID or Passport number"
|
||||||
|
value={user.type === "student" ? user.demographicInformation?.passport_id : undefined}
|
||||||
|
disabled
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||||
|
{user.type !== "corporate" && (
|
||||||
<div className="relative flex flex-col gap-3 w-full">
|
<div className="relative flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Employment Status</label>
|
<label className="font-normal text-base text-mti-gray-dim">Employment Status</label>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={user.demographicInformation?.employment}
|
value={user.demographicInformation?.employment}
|
||||||
className="grid grid-cols-2 items-center gap-4 place-items-center">
|
className="grid grid-cols-2 items-center gap-4 place-items-center"
|
||||||
|
disabled={disabled}>
|
||||||
{EMPLOYMENT_STATUS.map(({status, label}) => (
|
{EMPLOYMENT_STATUS.map(({status, label}) => (
|
||||||
<RadioGroup.Option value={status} key={status}>
|
<RadioGroup.Option value={status} key={status}>
|
||||||
{({checked}) => (
|
{({checked}) => (
|
||||||
@@ -143,10 +406,26 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers}:
|
|||||||
))}
|
))}
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{user.type === "corporate" && (
|
||||||
|
<Input
|
||||||
|
name="position"
|
||||||
|
onChange={setPosition}
|
||||||
|
type="text"
|
||||||
|
label="Position"
|
||||||
|
defaultValue={position}
|
||||||
|
placeholder="CEO, Head of Marketing..."
|
||||||
|
disabled
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="flex flex-col gap-8 w-full">
|
<div className="flex flex-col gap-8 w-full">
|
||||||
<div className="relative flex flex-col gap-3 w-full">
|
<div className="relative flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Gender</label>
|
<label className="font-normal text-base text-mti-gray-dim">Gender</label>
|
||||||
<RadioGroup value={user.demographicInformation?.gender} className="flex flex-row gap-4 justify-between">
|
<RadioGroup
|
||||||
|
value={user.demographicInformation?.gender}
|
||||||
|
className="flex flex-row gap-4 justify-between"
|
||||||
|
disabled={disabled}>
|
||||||
<RadioGroup.Option value="male">
|
<RadioGroup.Option value="male">
|
||||||
{({checked}) => (
|
{({checked}) => (
|
||||||
<span
|
<span
|
||||||
@@ -196,7 +475,8 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers}:
|
|||||||
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
|
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
isChecked={!!expiryDate}
|
isChecked={!!expiryDate}
|
||||||
onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : undefined)}>
|
onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : null)}
|
||||||
|
disabled={disabled}>
|
||||||
Enabled
|
Enabled
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
@@ -229,77 +509,24 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers}:
|
|||||||
dateFormat="dd/MM/yyyy"
|
dateFormat="dd/MM/yyyy"
|
||||||
selected={moment(expiryDate).toDate()}
|
selected={moment(expiryDate).toDate()}
|
||||||
onChange={(date) => setExpiryDate(date)}
|
onChange={(date) => setExpiryDate(date)}
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(loggedInUser.type === "developer" || loggedInUser.type === "owner") && (
|
{(loggedInUser.type === "developer" || loggedInUser.type === "admin") && (
|
||||||
<>
|
<>
|
||||||
<Divider className="w-full" />
|
<Divider className="w-full !m-0" />
|
||||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Status</label>
|
<label className="font-normal text-base text-mti-gray-dim">Status</label>
|
||||||
<select
|
|
||||||
defaultValue={user.status}
|
|
||||||
onChange={(e) => setStatus(e.target.value as typeof user.status)}
|
|
||||||
className="p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
|
||||||
<option value="active">Active</option>
|
|
||||||
<option value="disabled">Disabled</option>
|
|
||||||
<option value="paymentDue">Payment Due</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3 w-full">
|
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Type</label>
|
|
||||||
<select
|
|
||||||
defaultValue={user.type}
|
|
||||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
|
||||||
className="p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
|
||||||
<option value="student">Student</option>
|
|
||||||
<option value="teacher">Teacher</option>
|
|
||||||
<option value="corporate">Corporate</option>
|
|
||||||
<option value="agent">Country Agent</option>
|
|
||||||
<option value="owner">Owner</option>
|
|
||||||
<option value="developer">Developer</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{user.type === "corporate" && (
|
|
||||||
<>
|
|
||||||
<Divider className="w-full" />
|
|
||||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
|
||||||
<Input
|
|
||||||
label="Company Name"
|
|
||||||
type="text"
|
|
||||||
name="companyName"
|
|
||||||
onChange={() => null}
|
|
||||||
placeholder="Enter company name"
|
|
||||||
defaultValue={user.corporateInformation?.companyInformation.name}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Amount of Users"
|
|
||||||
type="number"
|
|
||||||
name="userAmount"
|
|
||||||
onChange={() => null}
|
|
||||||
placeholder="Enter amount of users"
|
|
||||||
defaultValue={user.corporateInformation?.companyInformation.userAmount}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 w-full">
|
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Country Agent</label>
|
|
||||||
<Select
|
<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"
|
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={[
|
options={USER_STATUS_OPTIONS}
|
||||||
{value: "", label: "No referral"},
|
menuPortalTarget={document?.body}
|
||||||
...users.filter((u) => u.type === "agent").map((x) => ({value: x.id, label: `${x.name} - ${x.email}`})),
|
value={USER_STATUS_OPTIONS.find((o) => o.value === status)}
|
||||||
]}
|
onChange={(value) => setStatus(value?.value as typeof user.status)}
|
||||||
defaultValue={{
|
|
||||||
value: referralAgent,
|
|
||||||
label: referralAgent ? users.find((u) => u.id === referralAgent)?.name || "" : "No agent",
|
|
||||||
}}
|
|
||||||
onChange={(value) => setReferralAgent(value?.value)}
|
|
||||||
styles={{
|
styles={{
|
||||||
control: (styles) => ({
|
control: (styles) => ({
|
||||||
...styles,
|
...styles,
|
||||||
@@ -310,12 +537,42 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers}:
|
|||||||
outline: "none",
|
outline: "none",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||||
option: (styles, state) => ({
|
option: (styles, state) => ({
|
||||||
...styles,
|
...styles,
|
||||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||||
color: state.isFocused ? "black" : styles.color,
|
color: state.isFocused ? "black" : styles.color,
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
|
isDisabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Type</label>
|
||||||
|
<Select
|
||||||
|
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
|
options={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={{
|
||||||
|
control: (styles) => ({
|
||||||
|
...styles,
|
||||||
|
paddingLeft: "4px",
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
":focus": {
|
||||||
|
outline: "none",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||||
|
option: (styles, state) => ({
|
||||||
|
...styles,
|
||||||
|
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||||
|
color: state.isFocused ? "black" : styles.color,
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
isDisabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -325,12 +582,17 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers}:
|
|||||||
|
|
||||||
<div className="flex gap-4 justify-between mt-4 w-full">
|
<div className="flex gap-4 justify-between mt-4 w-full">
|
||||||
<div className="self-start flex gap-4 justify-start items-center w-full">
|
<div className="self-start flex gap-4 justify-start items-center w-full">
|
||||||
{onViewStudents && (
|
{onViewCorporate && ["student", "teacher"].includes(user.type) && (
|
||||||
|
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewCorporate}>
|
||||||
|
View Corporate
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onViewStudents && ["corporate", "teacher"].includes(user.type) && (
|
||||||
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewStudents}>
|
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewStudents}>
|
||||||
View Students
|
View Students
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onViewTeachers && (
|
{onViewTeachers && ["student", "corporate"].includes(user.type) && (
|
||||||
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewTeachers}>
|
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewTeachers}>
|
||||||
View Teachers
|
View Teachers
|
||||||
</Button>
|
</Button>
|
||||||
@@ -340,7 +602,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers}:
|
|||||||
<Button className="w-full max-w-[200px]" variant="outline" onClick={onClose}>
|
<Button className="w-full max-w-[200px]" variant="outline" onClick={onClose}>
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={updateUser} className="w-full max-w-[200px]">
|
<Button disabled={disabled} onClick={updateUser} className="w-full max-w-[200px]">
|
||||||
Update
|
Update
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,20 +2,20 @@ import {Module} from "@/interfaces";
|
|||||||
|
|
||||||
export const MODULES: Module[] = ["reading", "listening", "writing", "speaking"];
|
export const MODULES: Module[] = ["reading", "listening", "writing", "speaking"];
|
||||||
|
|
||||||
export const BAND_SCORES: {[key in Module]: number[]} = {
|
// BAND SCORES is not in use anymore and level scoring is made based on thresholds
|
||||||
reading: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
|
// export const BAND_SCORES: {[key in Module]: number[]} = {
|
||||||
listening: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
|
// reading: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
|
||||||
writing: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
// listening: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
|
||||||
speaking: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
// writing: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||||
level: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
// speaking: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||||
};
|
// level: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||||
|
// };
|
||||||
|
|
||||||
export const moduleResultText = (level: number) => {
|
export type LevelScore = "Advanced" | "Upper-Intermediate" | "Intermediate" | "Pre-Intermediate" | "Elementary" | "Beginner";
|
||||||
if (level === 9) {
|
|
||||||
return (
|
|
||||||
|
const generateHighestScoreText = () : React.ReactNode => (
|
||||||
<>
|
<>
|
||||||
Congratulations on your exam performance! You achieved an impressive <span className="font-bold">level {level}</span>, demonstrating
|
|
||||||
excellent mastery of the assessed knowledge.
|
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
If you disagree with the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of
|
If you disagree with the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of
|
||||||
@@ -26,13 +26,9 @@ export const moduleResultText = (level: number) => {
|
|||||||
academic journey.
|
academic journey.
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
if (level >= 6) {
|
const generateAverageScoreText = () : React.ReactNode => (
|
||||||
return (
|
|
||||||
<>
|
<>
|
||||||
Congratulations on your exam performance! You achieved a commendable <span className="font-bold">level {level}</span>, demonstrating a
|
|
||||||
good understanding of the assessed knowledge.
|
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
|
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
|
||||||
@@ -43,29 +39,9 @@ export const moduleResultText = (level: number) => {
|
|||||||
journey.
|
journey.
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
if (level >= 3) {
|
const generateLowestScoreText = () : React.ReactNode => (
|
||||||
return (
|
|
||||||
<>
|
<>
|
||||||
Congratulations on your exam performance! You achieved a <span className="font-bold">level of {level}</span>, demonstrating a
|
|
||||||
satisfactory understanding of the assessed knowledge.
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
|
|
||||||
transparency of the results.
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
|
|
||||||
journey.
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
Thank you for taking the exam. You achieved a <span className="font-bold">level {level}</span>, but unfortunately, it did not meet the
|
|
||||||
required standards.
|
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
|
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
|
||||||
@@ -75,23 +51,70 @@ export const moduleResultText = (level: number) => {
|
|||||||
Please contact us for further information. We encourage you to continue your studies and wish you the best of luck in your future
|
Please contact us for further information. We encourage you to continue your studies and wish you the best of luck in your future
|
||||||
endeavors.
|
endeavors.
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
};
|
|
||||||
|
|
||||||
export const levelResultText = (level: number) => {
|
export const levelResultText = (level: number) => {
|
||||||
|
if(level === 9) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{"Outstanding! Your command of English is excellent. Focus on fine-tuning subtle language nuances and exploring sophisticated vocabulary. Keep up the excellent work!"}
|
||||||
|
{generateHighestScoreText()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if(level >= 8) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{"Impressive! You're approaching fluency. Continue refining nuances in grammar and expanding your vocabulary to express ideas more precisely."}
|
||||||
|
{generateAverageScoreText()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if(level >= 6) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{"Great job! You're navigating the complexities of English. Keep honing your grammar skills and exploring more advanced vocabulary."}
|
||||||
|
{generateAverageScoreText()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if(level >= 4) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{"Well done! You're moving beyond the basics. Work on expanding your vocabulary and refining your understanding of grammar structures."}
|
||||||
|
{generateAverageScoreText()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if(level >= 2) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{"Good effort! You're making progress. Continue studying and pay attention to common vocabulary and fundamental grammar rules."}
|
||||||
|
{generateAverageScoreText()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if(level >= 0) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{"Keep practicing! You're just starting, and improvement takes time. Focus on building your vocabulary and basic grammar skills."}
|
||||||
|
{generateLowestScoreText()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const moduleResultText = (module: Module, level: number) => {
|
||||||
|
if(module === 'level') return levelResultText(level);
|
||||||
if (level === 9) {
|
if (level === 9) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
Congratulations on your exam performance! You achieved an impressive <span className="font-bold">level {level}</span>, demonstrating
|
Congratulations on your exam performance! You achieved an impressive <span className="font-bold">level {level}</span>, demonstrating
|
||||||
excellent mastery of the assessed knowledge.
|
excellent mastery of the assessed knowledge.
|
||||||
<br />
|
{generateHighestScoreText()}
|
||||||
<br />
|
|
||||||
If you disagree with the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of
|
|
||||||
the results.
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
Please contact us for further information. Congratulations again on your outstanding achievement! We are here to support you on your
|
|
||||||
academic journey.
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -101,14 +124,7 @@ export const levelResultText = (level: number) => {
|
|||||||
<>
|
<>
|
||||||
Congratulations on your exam performance! You achieved a commendable <span className="font-bold">level {level}</span>, demonstrating a
|
Congratulations on your exam performance! You achieved a commendable <span className="font-bold">level {level}</span>, demonstrating a
|
||||||
good understanding of the assessed knowledge.
|
good understanding of the assessed knowledge.
|
||||||
<br />
|
{generateAverageScoreText()}
|
||||||
<br />
|
|
||||||
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
|
|
||||||
transparency of the results.
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
|
|
||||||
journey.
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -118,14 +134,7 @@ export const levelResultText = (level: number) => {
|
|||||||
<>
|
<>
|
||||||
Congratulations on your exam performance! You achieved a <span className="font-bold">level of {level}</span>, demonstrating a
|
Congratulations on your exam performance! You achieved a <span className="font-bold">level of {level}</span>, demonstrating a
|
||||||
satisfactory understanding of the assessed knowledge.
|
satisfactory understanding of the assessed knowledge.
|
||||||
<br />
|
{generateAverageScoreText()}
|
||||||
<br />
|
|
||||||
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
|
|
||||||
transparency of the results.
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
|
|
||||||
journey.
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -134,14 +143,7 @@ export const levelResultText = (level: number) => {
|
|||||||
<>
|
<>
|
||||||
Thank you for taking the exam. You achieved a <span className="font-bold">level {level}</span>, but unfortunately, it did not meet the
|
Thank you for taking the exam. You achieved a <span className="font-bold">level {level}</span>, but unfortunately, it did not meet the
|
||||||
required standards.
|
required standards.
|
||||||
<br />
|
{generateLowestScoreText()}
|
||||||
<br />
|
|
||||||
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
|
|
||||||
transparency of the results.
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
Please contact us for further information. We encourage you to continue your studies and wish you the best of luck in your future
|
|
||||||
endeavors.
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"type": "service_account",
|
"type": "service_account",
|
||||||
"project_id": "mti-ielts",
|
"project_id": "storied-phalanx-349916",
|
||||||
"private_key_id": "22b783a14c760d1215a8d1f5de0fa40a33a840e7",
|
"private_key_id": "c9e05f6fe413b1031a71f981160075ff4b044444",
|
||||||
"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",
|
"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-dyg6p@mti-ielts.iam.gserviceaccount.com",
|
"client_email": "firebase-adminsdk-3ml0u@storied-phalanx-349916.iam.gserviceaccount.com",
|
||||||
"client_id": "104980563453519094431",
|
"client_id": "114163760341944984396",
|
||||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||||
"token_uri": "https://oauth2.googleapis.com/token",
|
"token_uri": "https://oauth2.googleapis.com/token",
|
||||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
"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"
|
"universe_domain": "googleapis.com"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,38 +2,38 @@ import {Type} from "@/interfaces/user";
|
|||||||
|
|
||||||
export const PERMISSIONS = {
|
export const PERMISSIONS = {
|
||||||
generateCode: {
|
generateCode: {
|
||||||
student: ["corporate", "developer", "owner"],
|
student: ["corporate", "developer", "admin"],
|
||||||
teacher: ["corporate", "developer", "owner"],
|
teacher: ["corporate", "developer", "admin"],
|
||||||
corporate: ["owner", "developer"],
|
corporate: ["admin", "developer"],
|
||||||
owner: ["developer", "owner"],
|
admin: ["developer", "admin"],
|
||||||
agent: ["developer", "owner"],
|
agent: ["developer", "admin"],
|
||||||
developer: ["developer"],
|
developer: ["developer"],
|
||||||
},
|
},
|
||||||
deleteUser: {
|
deleteUser: {
|
||||||
student: ["teacher", "corporate", "developer", "owner"],
|
student: ["corporate", "developer", "admin"],
|
||||||
teacher: ["corporate", "developer", "owner"],
|
teacher: ["corporate", "developer", "admin"],
|
||||||
corporate: ["owner", "developer"],
|
corporate: ["admin", "developer"],
|
||||||
owner: ["developer", "owner"],
|
admin: ["developer", "admin"],
|
||||||
agent: ["developer", "owner"],
|
agent: ["developer", "admin"],
|
||||||
developer: ["developer"],
|
developer: ["developer"],
|
||||||
},
|
},
|
||||||
updateUser: {
|
updateUser: {
|
||||||
student: ["teacher", "corporate", "developer", "owner"],
|
student: ["developer", "admin"],
|
||||||
teacher: ["corporate", "developer", "owner"],
|
teacher: ["developer", "admin"],
|
||||||
corporate: ["owner", "developer"],
|
corporate: ["admin", "developer"],
|
||||||
owner: ["developer", "owner"],
|
admin: ["developer", "admin"],
|
||||||
agent: ["developer", "owner"],
|
agent: ["developer", "admin"],
|
||||||
developer: ["developer"],
|
developer: ["developer"],
|
||||||
},
|
},
|
||||||
updateExpiryDate: {
|
updateExpiryDate: {
|
||||||
student: ["developer", "owner"],
|
student: ["developer", "admin"],
|
||||||
teacher: ["developer", "owner"],
|
teacher: ["developer", "admin"],
|
||||||
corporate: ["owner", "developer"],
|
corporate: ["admin", "developer"],
|
||||||
owner: ["developer", "owner"],
|
admin: ["developer", "admin"],
|
||||||
agent: ["developer", "owner"],
|
agent: ["developer", "admin"],
|
||||||
developer: ["developer"],
|
developer: ["developer"],
|
||||||
},
|
},
|
||||||
examManagement: {
|
examManagement: {
|
||||||
delete: ["developer", "owner"],
|
delete: ["developer", "admin"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,16 +7,28 @@ import UserList from "@/pages/(admin)/Lists/UserList";
|
|||||||
import {dateSorter} from "@/utils";
|
import {dateSorter} from "@/utils";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {BsArrowLeft, BsGlobeCentralSouthAsia, BsPerson, BsPersonFill, BsPersonFillGear, BsPersonGear, BsPersonLinesFill} from "react-icons/bs";
|
import {
|
||||||
|
BsArrowLeft,
|
||||||
|
BsBriefcaseFill,
|
||||||
|
BsGlobeCentralSouthAsia,
|
||||||
|
BsPerson,
|
||||||
|
BsPersonFill,
|
||||||
|
BsPencilSquare,
|
||||||
|
BsBank,
|
||||||
|
BsCurrencyDollar,
|
||||||
|
} from "react-icons/bs";
|
||||||
import UserCard from "@/components/UserCard";
|
import UserCard from "@/components/UserCard";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
import IconCard from "./IconCard";
|
import IconCard from "./IconCard";
|
||||||
|
import useFilterStore from "@/stores/listFilterStore";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import usePaymentStatusUsers from "@/hooks/usePaymentStatusUsers";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OwnerDashboard({user}: Props) {
|
export default function AdminDashboard({user}: Props) {
|
||||||
const [page, setPage] = useState("");
|
const [page, setPage] = useState("");
|
||||||
const [selectedUser, setSelectedUser] = useState<User>();
|
const [selectedUser, setSelectedUser] = useState<User>();
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
@@ -24,18 +36,32 @@ export default function OwnerDashboard({user}: Props) {
|
|||||||
const {stats} = useStats(user.id);
|
const {stats} = useStats(user.id);
|
||||||
const {users, reload} = useUsers();
|
const {users, reload} = useUsers();
|
||||||
const {groups} = useGroups();
|
const {groups} = useGroups();
|
||||||
|
const {pending, done} = usePaymentStatusUsers();
|
||||||
|
|
||||||
|
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowModal(!!selectedUser && page === "");
|
setShowModal(!!selectedUser && page === "");
|
||||||
}, [selectedUser, page]);
|
}, [selectedUser, page]);
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
useEffect(reload, [page]);
|
||||||
|
|
||||||
|
const inactiveCountryManagerFilter = (x: User) =>
|
||||||
|
x.type === "agent" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
||||||
|
|
||||||
const UserDisplay = (displayUser: User) => (
|
const UserDisplay = (displayUser: User) => (
|
||||||
<div
|
<div
|
||||||
onClick={() => setSelectedUser(displayUser)}
|
onClick={() => setSelectedUser(displayUser)}
|
||||||
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
||||||
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
|
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
|
||||||
<div className="flex flex-col gap-1 items-start">
|
<div className="flex flex-col gap-1 items-start">
|
||||||
<span>{displayUser.name}</span>
|
<span>
|
||||||
|
{displayUser.type === "corporate"
|
||||||
|
? displayUser.corporateInformation?.companyInformation?.name || displayUser.name
|
||||||
|
: displayUser.name}
|
||||||
|
</span>
|
||||||
<span className="text-sm opacity-75">{displayUser.email}</span>
|
<span className="text-sm opacity-75">{displayUser.email}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -48,7 +74,7 @@ export default function OwnerDashboard({user}: Props) {
|
|||||||
? groups
|
? groups
|
||||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||||
.flatMap((g) => g.participants)
|
.flatMap((g) => g.participants)
|
||||||
.includes(x.id) || false
|
.includes(x.id)
|
||||||
: true);
|
: true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -63,7 +89,7 @@ export default function OwnerDashboard({user}: Props) {
|
|||||||
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
|
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UserList user={user} filter={filter} />
|
<UserList user={user} filters={[filter]} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -90,7 +116,27 @@ export default function OwnerDashboard({user}: Props) {
|
|||||||
<h2 className="text-2xl font-semibold">Teachers ({users.filter(filter).length})</h2>
|
<h2 className="text-2xl font-semibold">Teachers ({users.filter(filter).length})</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UserList user={user} filter={filter} />
|
<UserList user={user} filters={[filter]} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AgentsList = () => {
|
||||||
|
const filter = (x: User) => x.type === "agent";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => setPage("")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Country Managers ({users.filter(filter).length})</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UserList user={user} filters={[filter]} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -107,10 +153,50 @@ export default function OwnerDashboard({user}: Props) {
|
|||||||
<h2 className="text-2xl font-semibold">Corporate ({users.filter((x) => x.type === "corporate").length})</h2>
|
<h2 className="text-2xl font-semibold">Corporate ({users.filter((x) => x.type === "corporate").length})</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UserList user={user} filter={(x) => x.type === "corporate"} />
|
<UserList user={user} filters={[(x) => x.type === "corporate"]} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const CorporatePaidStatusList = ({paid}: {paid: Boolean}) => {
|
||||||
|
const list = paid ? done : pending;
|
||||||
|
const filter = (x: User) => x.type === "corporate" && list.includes(x.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => setPage("")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">
|
||||||
|
{paid ? "Payment Done" : "Pending Payment"} ({list.length})
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<UserList user={user} filters={[filter]} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const InactiveCountryManagerList = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => setPage("")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Inactive Country Managers ({users.filter(inactiveCountryManagerFilter).length})</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UserList user={user} filters={[inactiveCountryManagerFilter]} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const InactiveStudentsList = () => {
|
const InactiveStudentsList = () => {
|
||||||
const filter = (x: User) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
const filter = (x: User) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
||||||
|
|
||||||
@@ -126,7 +212,7 @@ export default function OwnerDashboard({user}: Props) {
|
|||||||
<h2 className="text-2xl font-semibold">Inactive Students ({users.filter(filter).length})</h2>
|
<h2 className="text-2xl font-semibold">Inactive Students ({users.filter(filter).length})</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UserList user={user} filter={filter} />
|
<UserList user={user} filters={[filter]} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -146,14 +232,14 @@ export default function OwnerDashboard({user}: Props) {
|
|||||||
<h2 className="text-2xl font-semibold">Inactive Corporate ({users.filter(filter).length})</h2>
|
<h2 className="text-2xl font-semibold">Inactive Corporate ({users.filter(filter).length})</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UserList user={user} filter={filter} />
|
<UserList user={user} filters={[filter]} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const DefaultDashboard = () => (
|
const DefaultDashboard = () => (
|
||||||
<>
|
<>
|
||||||
<section className="w-full flex flex-wrap gap-4 items-center justify-between">
|
<section className="w-full grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 place-items-center items-center justify-between">
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsPersonFill}
|
Icon={BsPersonFill}
|
||||||
label="Students"
|
label="Students"
|
||||||
@@ -162,19 +248,26 @@ export default function OwnerDashboard({user}: Props) {
|
|||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsPersonLinesFill}
|
Icon={BsPencilSquare}
|
||||||
label="Teachers"
|
label="Teachers"
|
||||||
value={users.filter((x) => x.type === "teacher").length}
|
value={users.filter((x) => x.type === "teacher").length}
|
||||||
onClick={() => setPage("teachers")}
|
onClick={() => setPage("teachers")}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsPersonLinesFill}
|
Icon={BsBank}
|
||||||
label="Corporate"
|
label="Corporate"
|
||||||
value={users.filter((x) => x.type === "corporate").length}
|
value={users.filter((x) => x.type === "corporate").length}
|
||||||
onClick={() => setPage("corporate")}
|
onClick={() => setPage("corporate")}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsBriefcaseFill}
|
||||||
|
label="Country Managers"
|
||||||
|
value={users.filter((x) => x.type === "agent").length}
|
||||||
|
onClick={() => setPage("agents")}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsGlobeCentralSouthAsia}
|
Icon={BsGlobeCentralSouthAsia}
|
||||||
label="Countries"
|
label="Countries"
|
||||||
@@ -183,7 +276,7 @@ export default function OwnerDashboard({user}: Props) {
|
|||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("inactiveStudents")}
|
onClick={() => setPage("inactiveStudents")}
|
||||||
Icon={BsPerson}
|
Icon={BsPersonFill}
|
||||||
label="Inactive Students"
|
label="Inactive Students"
|
||||||
value={
|
value={
|
||||||
users.filter((x) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
|
users.filter((x) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
|
||||||
@@ -191,9 +284,16 @@ export default function OwnerDashboard({user}: Props) {
|
|||||||
}
|
}
|
||||||
color="rose"
|
color="rose"
|
||||||
/>
|
/>
|
||||||
|
<IconCard
|
||||||
|
onClick={() => setPage("inactiveCountryManagers")}
|
||||||
|
Icon={BsBriefcaseFill}
|
||||||
|
label="Inactive Country Managers"
|
||||||
|
value={users.filter(inactiveCountryManagerFilter).length}
|
||||||
|
color="rose"
|
||||||
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("inactiveCorporate")}
|
onClick={() => setPage("inactiveCorporate")}
|
||||||
Icon={BsPerson}
|
Icon={BsBank}
|
||||||
label="Inactive Corporate"
|
label="Inactive Corporate"
|
||||||
value={
|
value={
|
||||||
users.filter((x) => x.type === "corporate" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
|
users.filter((x) => x.type === "corporate" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
|
||||||
@@ -201,6 +301,14 @@ export default function OwnerDashboard({user}: Props) {
|
|||||||
}
|
}
|
||||||
color="rose"
|
color="rose"
|
||||||
/>
|
/>
|
||||||
|
<IconCard onClick={() => setPage("paymentdone")} Icon={BsCurrencyDollar} label="Payment Done" value={done.length} color="purple" />
|
||||||
|
<IconCard
|
||||||
|
onClick={() => setPage("paymentpending")}
|
||||||
|
Icon={BsCurrencyDollar}
|
||||||
|
label="Pending Payment"
|
||||||
|
value={pending.length}
|
||||||
|
color="rose"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between">
|
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between">
|
||||||
@@ -220,7 +328,9 @@ export default function OwnerDashboard({user}: Props) {
|
|||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{users
|
||||||
.filter((x) => x.type === "corporate")
|
.filter((x) => x.type === "corporate")
|
||||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
.sort((a, b) => {
|
||||||
|
return dateSorter(a, b, "desc", "registrationDate");
|
||||||
|
})
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} {...x} />
|
||||||
))}
|
))}
|
||||||
@@ -253,7 +363,7 @@ export default function OwnerDashboard({user}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Teachers expiring in 1 month</span>
|
<span className="p-4">Country Manager expiring in 1 month</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{users
|
||||||
.filter(
|
.filter(
|
||||||
@@ -297,7 +407,7 @@ export default function OwnerDashboard({user}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Expired Teachers</span>
|
<span className="p-4">Expired Country Manager</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{users
|
||||||
.filter(
|
.filter(
|
||||||
@@ -338,9 +448,65 @@ export default function OwnerDashboard({user}: Props) {
|
|||||||
if (shouldReload) reload();
|
if (shouldReload) reload();
|
||||||
}}
|
}}
|
||||||
onViewStudents={
|
onViewStudents={
|
||||||
selectedUser.type === "corporate" || selectedUser.type === "teacher" ? () => setPage("students") : undefined
|
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
||||||
|
? () => {
|
||||||
|
appendUserFilters({
|
||||||
|
id: "view-students",
|
||||||
|
filter: (x: User) => x.type === "student",
|
||||||
|
});
|
||||||
|
appendUserFilters({
|
||||||
|
id: "belongs-to-admin",
|
||||||
|
filter: (x: User) =>
|
||||||
|
groups
|
||||||
|
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||||
|
.flatMap((g) => g.participants)
|
||||||
|
.includes(x.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push("/list/users");
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onViewTeachers={
|
||||||
|
selectedUser.type === "corporate" || selectedUser.type === "student"
|
||||||
|
? () => {
|
||||||
|
appendUserFilters({
|
||||||
|
id: "view-teachers",
|
||||||
|
filter: (x: User) => x.type === "teacher",
|
||||||
|
});
|
||||||
|
appendUserFilters({
|
||||||
|
id: "belongs-to-admin",
|
||||||
|
filter: (x: User) =>
|
||||||
|
groups
|
||||||
|
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||||
|
.flatMap((g) => g.participants)
|
||||||
|
.includes(x.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push("/list/users");
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onViewCorporate={
|
||||||
|
selectedUser.type === "teacher" || selectedUser.type === "student"
|
||||||
|
? () => {
|
||||||
|
appendUserFilters({
|
||||||
|
id: "view-corporate",
|
||||||
|
filter: (x: User) => x.type === "corporate",
|
||||||
|
});
|
||||||
|
appendUserFilters({
|
||||||
|
id: "belongs-to-admin",
|
||||||
|
filter: (x: User) =>
|
||||||
|
groups
|
||||||
|
.filter((g) => g.participants.includes(selectedUser.id))
|
||||||
|
.flatMap((g) => [g.admin, ...g.participants])
|
||||||
|
.includes(x.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push("/list/users");
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined}
|
|
||||||
user={selectedUser}
|
user={selectedUser}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -350,8 +516,12 @@ export default function OwnerDashboard({user}: Props) {
|
|||||||
{page === "students" && <StudentsList />}
|
{page === "students" && <StudentsList />}
|
||||||
{page === "teachers" && <TeachersList />}
|
{page === "teachers" && <TeachersList />}
|
||||||
{page === "corporate" && <CorporateList />}
|
{page === "corporate" && <CorporateList />}
|
||||||
|
{page === "agents" && <AgentsList />}
|
||||||
{page === "inactiveStudents" && <InactiveStudentsList />}
|
{page === "inactiveStudents" && <InactiveStudentsList />}
|
||||||
{page === "inactiveCorporate" && <InactiveCorporateList />}
|
{page === "inactiveCorporate" && <InactiveCorporateList />}
|
||||||
|
{page === "inactiveCountryManagers" && <InactiveCountryManagerList />}
|
||||||
|
{page === "paymentdone" && <CorporatePaidStatusList paid={true} />}
|
||||||
|
{page === "paymentpending" && <CorporatePaidStatusList paid={false} />}
|
||||||
{page === "" && <DefaultDashboard />}
|
{page === "" && <DefaultDashboard />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
244
src/dashboards/Agent.tsx
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import useStats from "@/hooks/useStats";
|
||||||
|
import useUsers from "@/hooks/useUsers";
|
||||||
|
import { User} from "@/interfaces/user";
|
||||||
|
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||||
|
import {dateSorter} from "@/utils";
|
||||||
|
import moment from "moment";
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
import {BsArrowLeft, BsPersonFill, BsBank, BsCurrencyDollar} from "react-icons/bs";
|
||||||
|
import UserCard from "@/components/UserCard";
|
||||||
|
import useGroups from "@/hooks/useGroups";
|
||||||
|
|
||||||
|
import IconCard from "./IconCard";
|
||||||
|
import usePaymentStatusUsers from '@/hooks/usePaymentStatusUsers';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AgentDashboard({user}: Props) {
|
||||||
|
const [page, setPage] = useState("");
|
||||||
|
const [selectedUser, setSelectedUser] = useState<User>();
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
|
||||||
|
const {stats} = useStats();
|
||||||
|
const {users, reload} = useUsers();
|
||||||
|
const {groups} = useGroups(user.id);
|
||||||
|
const { pending, done } = usePaymentStatusUsers();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setShowModal(!!selectedUser && page === "");
|
||||||
|
}, [selectedUser, page]);
|
||||||
|
|
||||||
|
const corporateFilter = (user: User) => user.type === "corporate";
|
||||||
|
const referredCorporateFilter = (x: User) =>
|
||||||
|
x.type === "corporate" && !!x.corporateInformation && x.corporateInformation.referralAgent === user.id;
|
||||||
|
const inactiveReferredCorporateFilter = (x: User) =>
|
||||||
|
referredCorporateFilter(x) && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
||||||
|
|
||||||
|
const UserDisplay = ({ displayUser, allowClick = true }: {displayUser: User, allowClick?: boolean}) => (
|
||||||
|
<div
|
||||||
|
onClick={() => allowClick && setSelectedUser(displayUser)}
|
||||||
|
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
||||||
|
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
|
||||||
|
<div className="flex flex-col gap-1 items-start">
|
||||||
|
<span>
|
||||||
|
{displayUser.type === "corporate"
|
||||||
|
? displayUser.corporateInformation?.companyInformation?.name || displayUser.name
|
||||||
|
: displayUser.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm opacity-75">{displayUser.email}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ReferredCorporateList = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => setPage("")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Referred Corporate ({users.filter(referredCorporateFilter).length})</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UserList user={user} filters={[referredCorporateFilter]} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const InactiveReferredCorporateList = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => setPage("")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Inactive Referred Corporate ({users.filter(inactiveReferredCorporateFilter).length})</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UserList user={user} filters={[inactiveReferredCorporateFilter]} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CorporateList = () => {
|
||||||
|
const filter = (x: User) => x.type === "corporate";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => setPage("")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Corporate ({users.filter(filter).length})</h2>
|
||||||
|
</div>
|
||||||
|
<UserList user={user} filters={[filter]} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CorporatePaidStatusList = ({ paid }: {paid: Boolean}) => {
|
||||||
|
const list = paid ? done : pending;
|
||||||
|
const filter = (x: User) => x.type === "corporate" && list.includes(x.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => setPage("")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">{paid ? 'Payment Done' : 'Pending Payment'} ({list.length})</h2>
|
||||||
|
</div>
|
||||||
|
<UserList user={user} filters={[filter]} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DefaultDashboard = () => (
|
||||||
|
<>
|
||||||
|
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:gap-4 text-center">
|
||||||
|
<IconCard
|
||||||
|
onClick={() => setPage("referredCorporate")}
|
||||||
|
Icon={BsBank}
|
||||||
|
label="Referred Corporate"
|
||||||
|
value={users.filter(referredCorporateFilter).length}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
onClick={() => setPage("inactiveReferredCorporate")}
|
||||||
|
Icon={BsBank}
|
||||||
|
label="Inactive Referred Corporate"
|
||||||
|
value={users.filter(inactiveReferredCorporateFilter).length}
|
||||||
|
color="rose"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
onClick={() => setPage("corporate")}
|
||||||
|
Icon={BsBank}
|
||||||
|
label="Corporate"
|
||||||
|
value={users.filter(corporateFilter).length}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
onClick={() => setPage("paymentdone")}
|
||||||
|
Icon={BsCurrencyDollar}
|
||||||
|
label="Payment Done"
|
||||||
|
value={done.length}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
onClick={() => setPage("paymentpending")}
|
||||||
|
Icon={BsCurrencyDollar}
|
||||||
|
label="Pending Payment"
|
||||||
|
value={pending.length}
|
||||||
|
color="rose"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Latest Referred Corporate</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{users
|
||||||
|
.filter(referredCorporateFilter)
|
||||||
|
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} displayUser={x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Latest corporate</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{users
|
||||||
|
.filter(corporateFilter)
|
||||||
|
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} displayUser={x} allowClick={false} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Referenced corporate expiring in 1 month</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{users
|
||||||
|
.filter(
|
||||||
|
(x) =>
|
||||||
|
referredCorporateFilter(x) &&
|
||||||
|
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
|
||||||
|
moment().isBefore(moment(x.subscriptionExpirationDate)),
|
||||||
|
)
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} displayUser={x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
|
||||||
|
<>
|
||||||
|
{selectedUser && (
|
||||||
|
<div className="w-full flex flex-col gap-8">
|
||||||
|
<UserCard
|
||||||
|
loggedInUser={user}
|
||||||
|
onClose={(shouldReload) => {
|
||||||
|
setSelectedUser(undefined);
|
||||||
|
if (shouldReload) reload();
|
||||||
|
}}
|
||||||
|
onViewStudents={
|
||||||
|
selectedUser.type === "corporate" || selectedUser.type === "teacher" ? () => setPage("students") : undefined
|
||||||
|
}
|
||||||
|
onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined}
|
||||||
|
user={selectedUser}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
</Modal>
|
||||||
|
{page === "referredCorporate" && <ReferredCorporateList />}
|
||||||
|
{page === "corporate" && <CorporateList />}
|
||||||
|
{page === "inactiveReferredCorporate" && <InactiveReferredCorporateList />}
|
||||||
|
{page === "paymentdone" && <CorporatePaidStatusList paid={true} />}
|
||||||
|
{page === "paymentpending" && <CorporatePaidStatusList paid={false} />}
|
||||||
|
{page === "" && <DefaultDashboard />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,70 +2,110 @@ import ProgressBar from "@/components/Low/ProgressBar";
|
|||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import { Module } from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import { Assignment } from "@/interfaces/results";
|
import { Assignment } from "@/interfaces/results";
|
||||||
import {Stat} from "@/interfaces/user";
|
|
||||||
import { calculateBandScore } from "@/utils/score";
|
import { calculateBandScore } from "@/utils/score";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {useState} from "react";
|
import {
|
||||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
BsBook,
|
||||||
|
BsClipboard,
|
||||||
|
BsHeadphones,
|
||||||
|
BsMegaphone,
|
||||||
|
BsPen,
|
||||||
|
} from "react-icons/bs";
|
||||||
|
import { usePDFDownload } from "@/hooks/usePDFDownload";
|
||||||
|
import { uniqBy } from "lodash";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
allowDownload?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AssignmentCard({id, name, assigner, startDate, endDate, assignees, results, exams, onClick}: Assignment & Props) {
|
export default function AssignmentCard({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
assigner,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
assignees,
|
||||||
|
results,
|
||||||
|
exams,
|
||||||
|
onClick,
|
||||||
|
allowDownload,
|
||||||
|
}: Assignment & Props) {
|
||||||
const { users } = useUsers();
|
const { users } = useUsers();
|
||||||
|
const renderPdfIcon = usePDFDownload("assignments");
|
||||||
|
|
||||||
const calculateAverageModuleScore = (module: Module) => {
|
const calculateAverageModuleScore = (module: Module) => {
|
||||||
const resultModuleBandScores = results.map((r) => {
|
const resultModuleBandScores = results.map((r) => {
|
||||||
const moduleStats = r.stats.filter((s) => s.module === module);
|
const moduleStats = r.stats.filter((s) => s.module === module);
|
||||||
|
|
||||||
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
|
const correct = moduleStats.reduce(
|
||||||
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
|
(acc, curr) => acc + curr.score.correct,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const total = moduleStats.reduce(
|
||||||
|
(acc, curr) => acc + curr.score.total,
|
||||||
|
0,
|
||||||
|
);
|
||||||
return calculateBandScore(correct, total, module, r.type);
|
return calculateBandScore(correct, total, module, r.type);
|
||||||
});
|
});
|
||||||
|
|
||||||
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / results.length;
|
return resultModuleBandScores.length === 0
|
||||||
|
? -1
|
||||||
|
: resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
|
||||||
|
results.length;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
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-col gap-3">
|
||||||
<h3 className="font-semibold text-xl">{name}</h3>
|
<div className="flex flex-row justify-between">
|
||||||
|
<h3 className="text-xl font-semibold">{name}</h3>
|
||||||
|
{allowDownload &&
|
||||||
|
renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
|
||||||
|
</div>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
color={results.length / assignees.length < 0.5 ? "red" : "purple"}
|
color={results.length / assignees.length < 0.5 ? "red" : "purple"}
|
||||||
percentage={(results.length / assignees.length) * 100}
|
percentage={(results.length / assignees.length) * 100}
|
||||||
label={`${results.length}/${assignees.length}`}
|
label={`${results.length}/${assignees.length}`}
|
||||||
className="h-5"
|
className="h-5"
|
||||||
textClassName={results.length / assignees.length < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"}
|
textClassName={
|
||||||
|
results.length / assignees.length < 0.5
|
||||||
|
? "!text-mti-gray-dim font-light"
|
||||||
|
: "text-white"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>{moment(startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
|
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
</span>
|
</span>
|
||||||
<div className="grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2">
|
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
||||||
{exams.map(({module}) => (
|
{uniqBy(exams, (x) => x.module).map(({ module }) => (
|
||||||
<div
|
<div
|
||||||
key={module}
|
key={module}
|
||||||
className={clsx(
|
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 === "reading" && "bg-ielts-reading",
|
||||||
module === "listening" && "bg-ielts-listening",
|
module === "listening" && "bg-ielts-listening",
|
||||||
module === "writing" && "bg-ielts-writing",
|
module === "writing" && "bg-ielts-writing",
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
module === "level" && "bg-ielts-level",
|
module === "level" && "bg-ielts-level",
|
||||||
)}>
|
)}
|
||||||
{module === "reading" && <BsBook className="w-4 h-4" />}
|
>
|
||||||
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||||
{module === "writing" && <BsPen className="w-4 h-4" />}
|
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||||
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||||
{module === "level" && <BsClipboard className="w-4 h-4" />}
|
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||||
|
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||||
{calculateAverageModuleScore(module) > -1 && (
|
{calculateAverageModuleScore(module) > -1 && (
|
||||||
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
|
<span className="text-sm">
|
||||||
|
{calculateAverageModuleScore(module).toFixed(1)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {getExam} from "@/utils/exams";
|
|||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import {uuidv4} from "@firebase/util";
|
import {uuidv4} from "@firebase/util";
|
||||||
import {Assignment} from "@/interfaces/results";
|
import {Assignment} from "@/interfaces/results";
|
||||||
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
|
import {Variant} from "@/interfaces/exam";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isCreating: boolean;
|
isCreating: boolean;
|
||||||
@@ -33,8 +35,13 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
const [assignees, setAssignees] = useState<string[]>(assignment?.assignees || []);
|
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 [name, setName] = useState(assignment?.name || generate({minLength: 6, maxLength: 8, min: 2, max: 3, join: " ", formatter: capitalize}));
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [startDate, setStartDate] = useState<Date | null>(assignment ? moment(assignment.startDate).toDate() : moment().add(1, "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().add(8, "day").toDate());
|
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");
|
||||||
|
// creates a new exam for each assignee or just one exam for all assignees
|
||||||
|
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
|
||||||
|
|
||||||
const toggleModule = (module: Module) => {
|
const toggleModule = (module: Module) => {
|
||||||
const modules = selectedModules.filter((x) => x !== module);
|
const modules = selectedModules.filter((x) => x !== module);
|
||||||
@@ -48,17 +55,14 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
const createAssignment = () => {
|
const createAssignment = () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const examPromises = selectedModules.map(async (module) => getExam(module, false));
|
|
||||||
Promise.all(examPromises)
|
|
||||||
.then((exams) => {
|
|
||||||
(assignment ? axios.patch : axios.post)(`/api/assignments${assignment ? `/${assignment.id}` : ""}`, {
|
(assignment ? axios.patch : axios.post)(`/api/assignments${assignment ? `/${assignment.id}` : ""}`, {
|
||||||
assigner,
|
|
||||||
assignees,
|
assignees,
|
||||||
name,
|
name,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
results: [],
|
selectedModules,
|
||||||
exams: exams.map((e) => ({module: e?.module, id: e?.id})),
|
generateMultiple,
|
||||||
|
variant,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`);
|
toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`);
|
||||||
@@ -69,12 +73,6 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
toast.error("Something went wrong, please try again later!");
|
toast.error("Something went wrong, please try again later!");
|
||||||
})
|
})
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.log(e);
|
|
||||||
toast.error("Something went wrong, please try again later!");
|
|
||||||
setIsLoading(false);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteAssignment = () => {
|
const deleteAssignment = () => {
|
||||||
@@ -203,7 +201,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
)}
|
)}
|
||||||
popperClassName="!z-20"
|
popperClassName="!z-20"
|
||||||
filterDate={(date) => moment(date).isAfter(new Date())}
|
filterTime={(date) => moment(date).isSameOrAfter(new Date())}
|
||||||
dateFormat="dd/MM/yyyy HH:mm"
|
dateFormat="dd/MM/yyyy HH:mm"
|
||||||
selected={startDate}
|
selected={startDate}
|
||||||
showTimeSelect
|
showTimeSelect
|
||||||
@@ -219,7 +217,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
)}
|
)}
|
||||||
popperClassName="!z-20"
|
popperClassName="!z-20"
|
||||||
filterDate={(date) => moment(date).isAfter(startDate)}
|
filterTime={(date) => moment(date).isAfter(startDate)}
|
||||||
dateFormat="dd/MM/yyyy HH:mm"
|
dateFormat="dd/MM/yyyy HH:mm"
|
||||||
selected={endDate}
|
selected={endDate}
|
||||||
showTimeSelect
|
showTimeSelect
|
||||||
@@ -284,6 +282,14 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<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>
|
||||||
<div className="flex gap-4 w-full justify-end">
|
<div className="flex gap-4 w-full justify-end">
|
||||||
<Button className="w-full max-w-[200px]" variant="outline" onClick={cancelCreation} disabled={isLoading} isLoading={isLoading}>
|
<Button className="w-full max-w-[200px]" variant="outline" onClick={cancelCreation} disabled={isLoading} isLoading={isLoading}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -13,7 +13,13 @@ import clsx from "clsx";
|
|||||||
import { capitalize, uniqBy } from "lodash";
|
import { capitalize, uniqBy } from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
import {
|
||||||
|
BsBook,
|
||||||
|
BsClipboard,
|
||||||
|
BsHeadphones,
|
||||||
|
BsMegaphone,
|
||||||
|
BsPen,
|
||||||
|
} from "react-icons/bs";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -43,16 +49,29 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
const resultModuleBandScores = assignment.results.map((r) => {
|
const resultModuleBandScores = assignment.results.map((r) => {
|
||||||
const moduleStats = r.stats.filter((s) => s.module === module);
|
const moduleStats = r.stats.filter((s) => s.module === module);
|
||||||
|
|
||||||
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
|
const correct = moduleStats.reduce(
|
||||||
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
|
(acc, curr) => acc + curr.score.correct,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const total = moduleStats.reduce(
|
||||||
|
(acc, curr) => acc + curr.score.total,
|
||||||
|
0,
|
||||||
|
);
|
||||||
return calculateBandScore(correct, total, module, r.type);
|
return calculateBandScore(correct, total, module, r.type);
|
||||||
});
|
});
|
||||||
|
|
||||||
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length;
|
return resultModuleBandScores.length === 0
|
||||||
|
? -1
|
||||||
|
: resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
|
||||||
|
assignment.results.length;
|
||||||
};
|
};
|
||||||
|
|
||||||
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => {
|
const aggregateScoresByModule = (
|
||||||
const scores: {[key in Module]: {total: number; missing: number; correct: number}} = {
|
stats: Stat[],
|
||||||
|
): { module: Module; total: number; missing: number; correct: number }[] => {
|
||||||
|
const scores: {
|
||||||
|
[key in Module]: { total: number; missing: number; correct: number };
|
||||||
|
} = {
|
||||||
reading: {
|
reading: {
|
||||||
total: 0,
|
total: 0,
|
||||||
correct: 0,
|
correct: 0,
|
||||||
@@ -93,10 +112,22 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
.map((x) => ({ module: x as Module, ...scores[x as Module] }));
|
.map((x) => ({ module: x as Module, ...scores[x as Module] }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => {
|
const customContent = (
|
||||||
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
|
stats: Stat[],
|
||||||
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
|
user: string,
|
||||||
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
|
focus: "academic" | "general",
|
||||||
|
) => {
|
||||||
|
const correct = stats.reduce(
|
||||||
|
(accumulator, current) => accumulator + current.score.correct,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const total = stats.reduce(
|
||||||
|
(accumulator, current) => accumulator + current.score.total,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const aggregatedScores = aggregateScoresByModule(stats).filter(
|
||||||
|
(x) => x.total > 0,
|
||||||
|
);
|
||||||
|
|
||||||
const aggregatedLevels = aggregatedScores.map((x) => ({
|
const aggregatedLevels = aggregatedScores.map((x) => ({
|
||||||
module: x.module,
|
module: x.module,
|
||||||
@@ -106,7 +137,9 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
const timeSpent = stats[0].timeSpent;
|
const timeSpent = stats[0].timeSpent;
|
||||||
|
|
||||||
const selectExam = () => {
|
const selectExam = () => {
|
||||||
const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam));
|
const examPromises = uniqBy(stats, "exam").map((stat) =>
|
||||||
|
getExamById(stat.module, stat.exam),
|
||||||
|
);
|
||||||
|
|
||||||
Promise.all(examPromises).then((exams) => {
|
Promise.all(examPromises).then((exams) => {
|
||||||
if (exams.every((x) => !!x)) {
|
if (exams.every((x) => !!x)) {
|
||||||
@@ -126,13 +159,17 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
<div className="w-full flex justify-between -md:items-center 2xl:items-center">
|
<div className="-md:items-center flex w-full justify-between 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: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>
|
<span className="font-medium">
|
||||||
|
{formatTimestamp(stats[0].date.toString())}
|
||||||
|
</span>
|
||||||
{timeSpent && (
|
{timeSpent && (
|
||||||
<>
|
<>
|
||||||
<span className="md:hidden 2xl:flex">• </span>
|
<span className="md:hidden 2xl:flex">• </span>
|
||||||
<span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span>
|
<span className="text-sm">
|
||||||
|
{Math.floor(timeSpent / 60)} minutes
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -141,30 +178,37 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
correct / total >= 0.7 && "text-mti-purple",
|
correct / total >= 0.7 && "text-mti-purple",
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
||||||
correct / total < 0.3 && "text-mti-rose",
|
correct / total < 0.3 && "text-mti-rose",
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
Level{" "}
|
Level{" "}
|
||||||
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
|
{(
|
||||||
|
aggregatedLevels.reduce(
|
||||||
|
(accumulator, current) => accumulator + current.level,
|
||||||
|
0,
|
||||||
|
) / aggregatedLevels.length
|
||||||
|
).toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full flex flex-col gap-1">
|
<div className="flex w-full flex-col gap-1">
|
||||||
<div className="grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2">
|
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
||||||
{aggregatedLevels.map(({ module, level }) => (
|
{aggregatedLevels.map(({ module, level }) => (
|
||||||
<div
|
<div
|
||||||
key={module}
|
key={module}
|
||||||
className={clsx(
|
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 === "reading" && "bg-ielts-reading",
|
||||||
module === "listening" && "bg-ielts-listening",
|
module === "listening" && "bg-ielts-listening",
|
||||||
module === "writing" && "bg-ielts-writing",
|
module === "writing" && "bg-ielts-writing",
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
module === "level" && "bg-ielts-level",
|
module === "level" && "bg-ielts-level",
|
||||||
)}>
|
)}
|
||||||
{module === "reading" && <BsBook className="w-4 h-4" />}
|
>
|
||||||
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||||
{module === "writing" && <BsPen className="w-4 h-4" />}
|
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||||
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||||
{module === "level" && <BsClipboard className="w-4 h-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>
|
<span className="text-sm">{level.toFixed(1)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -184,25 +228,31 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
<div
|
<div
|
||||||
key={user}
|
key={user}
|
||||||
className={clsx(
|
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.7 && "hover:border-mti-purple",
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
correct / total >= 0.3 &&
|
||||||
|
correct / total < 0.7 &&
|
||||||
|
"hover:border-mti-red",
|
||||||
correct / total < 0.3 && "hover:border-mti-rose",
|
correct / total < 0.3 && "hover:border-mti-rose",
|
||||||
)}
|
)}
|
||||||
onClick={selectExam}
|
onClick={selectExam}
|
||||||
role="button">
|
role="button"
|
||||||
|
>
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
key={user}
|
key={user}
|
||||||
className={clsx(
|
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.7 && "hover:border-mti-purple",
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
correct / total >= 0.3 &&
|
||||||
|
correct / total < 0.7 &&
|
||||||
|
"hover:border-mti-red",
|
||||||
correct / total < 0.3 && "hover:border-mti-rose",
|
correct / total < 0.3 && "hover:border-mti-rose",
|
||||||
)}
|
)}
|
||||||
data-tip="Your screen size is too small to view previous exams."
|
data-tip="Your screen size is too small to view previous exams."
|
||||||
role="button">
|
role="button"
|
||||||
|
>
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -211,20 +261,33 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}>
|
<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
|
<ProgressBar
|
||||||
color="purple"
|
color="purple"
|
||||||
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
|
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
|
||||||
className="h-6"
|
className="h-6"
|
||||||
textClassName={
|
textClassName={
|
||||||
(assignment?.results.length || 0) / (assignment?.assignees.length || 1) < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"
|
(assignment?.results.length || 0) /
|
||||||
|
(assignment?.assignees.length || 1) <
|
||||||
|
0.5
|
||||||
|
? "!text-mti-gray-dim font-light"
|
||||||
|
: "text-white"
|
||||||
|
}
|
||||||
|
percentage={
|
||||||
|
((assignment?.results.length || 0) /
|
||||||
|
(assignment?.assignees.length || 1)) *
|
||||||
|
100
|
||||||
}
|
}
|
||||||
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">
|
<div className="flex flex-col gap-2">
|
||||||
<span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span>
|
<span>
|
||||||
<span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span>
|
Start Date:{" "}
|
||||||
|
{moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span>
|
<span>
|
||||||
Assignees:{" "}
|
Assignees:{" "}
|
||||||
@@ -236,26 +299,32 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xl font-bold">Average Scores</span>
|
<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">
|
<div className="-md:mt-2 flex w-full items-center gap-4">
|
||||||
{assignment?.exams.map(({module}) => (
|
{assignment &&
|
||||||
|
uniqBy(assignment.exams, (x) => x.module).map(({ module }) => (
|
||||||
<div
|
<div
|
||||||
data-tip={capitalize(module)}
|
data-tip={capitalize(module)}
|
||||||
key={module}
|
key={module}
|
||||||
className={clsx(
|
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 === "reading" && "bg-ielts-reading",
|
||||||
module === "listening" && "bg-ielts-listening",
|
module === "listening" && "bg-ielts-listening",
|
||||||
module === "writing" && "bg-ielts-writing",
|
module === "writing" && "bg-ielts-writing",
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
module === "level" && "bg-ielts-level",
|
module === "level" && "bg-ielts-level",
|
||||||
)}>
|
)}
|
||||||
{module === "reading" && <BsBook className="w-4 h-4" />}
|
>
|
||||||
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||||
{module === "writing" && <BsPen className="w-4 h-4" />}
|
{module === "listening" && (
|
||||||
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
<BsHeadphones className="h-4 w-4" />
|
||||||
{module === "level" && <BsClipboard className="w-4 h-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 && (
|
{calculateAverageModuleScore(module) > -1 && (
|
||||||
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
|
<span className="text-sm">
|
||||||
|
{calculateAverageModuleScore(module).toFixed(1)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -263,15 +332,20 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xl font-bold">
|
<span className="text-xl font-bold">
|
||||||
Results ({assignment?.results.length}/{assignment?.assignees.length})
|
Results ({assignment?.results.length}/{assignment?.assignees.length}
|
||||||
|
)
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
{assignment && assignment?.results.length > 0 && (
|
{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))}
|
{assignment.results.map((r) =>
|
||||||
|
customContent(r.stats, r.user, r.type),
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useStats from "@/hooks/useStats";
|
import useStats from "@/hooks/useStats";
|
||||||
import useUsers from "@/hooks/useUsers";
|
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 UserList from "@/pages/(admin)/Lists/UserList";
|
||||||
import {dateSorter} from "@/utils";
|
import {dateSorter} from "@/utils";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
@@ -19,7 +19,10 @@ import {
|
|||||||
BsPersonFill,
|
BsPersonFill,
|
||||||
BsPersonFillGear,
|
BsPersonFillGear,
|
||||||
BsPersonGear,
|
BsPersonGear,
|
||||||
BsPersonLinesFill,
|
BsPencilSquare,
|
||||||
|
BsPersonBadge,
|
||||||
|
BsPersonCheck,
|
||||||
|
BsPeople,
|
||||||
} from "react-icons/bs";
|
} from "react-icons/bs";
|
||||||
import UserCard from "@/components/UserCard";
|
import UserCard from "@/components/UserCard";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
@@ -29,9 +32,12 @@ import {Module} from "@/interfaces";
|
|||||||
import {groupByExam} from "@/utils/stats";
|
import {groupByExam} from "@/utils/stats";
|
||||||
import IconCard from "./IconCard";
|
import IconCard from "./IconCard";
|
||||||
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
||||||
|
import useFilterStore from "@/stores/listFilterStore";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import useCodes from "@/hooks/useCodes";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: CorporateUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CorporateDashboard({user}: Props) {
|
export default function CorporateDashboard({user}: Props) {
|
||||||
@@ -41,8 +47,12 @@ export default function CorporateDashboard({user}: Props) {
|
|||||||
|
|
||||||
const {stats} = useStats();
|
const {stats} = useStats();
|
||||||
const {users, reload} = useUsers();
|
const {users, reload} = useUsers();
|
||||||
|
const {codes} = useCodes(user.id);
|
||||||
const {groups} = useGroups(user.id);
|
const {groups} = useGroups(user.id);
|
||||||
|
|
||||||
|
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowModal(!!selectedUser && page === "");
|
setShowModal(!!selectedUser && page === "");
|
||||||
}, [selectedUser, page]);
|
}, [selectedUser, page]);
|
||||||
@@ -86,7 +96,7 @@ export default function CorporateDashboard({user}: Props) {
|
|||||||
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
|
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UserList user={user} filter={filter} />
|
<UserList user={user} filters={[filter]} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -113,7 +123,7 @@ export default function CorporateDashboard({user}: Props) {
|
|||||||
<h2 className="text-2xl font-semibold">Teachers ({users.filter(filter).length})</h2>
|
<h2 className="text-2xl font-semibold">Teachers ({users.filter(filter).length})</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UserList user={user} filter={filter} />
|
<UserList user={user} filters={[filter]} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -165,7 +175,7 @@ export default function CorporateDashboard({user}: Props) {
|
|||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("teachers")}
|
onClick={() => setPage("teachers")}
|
||||||
Icon={BsPersonLinesFill}
|
Icon={BsPencilSquare}
|
||||||
label="Teachers"
|
label="Teachers"
|
||||||
value={users.filter(teacherFilter).length}
|
value={users.filter(teacherFilter).length}
|
||||||
color="purple"
|
color="purple"
|
||||||
@@ -182,7 +192,13 @@ export default function CorporateDashboard({user}: Props) {
|
|||||||
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
||||||
color="purple"
|
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
|
<IconCard
|
||||||
Icon={BsClock}
|
Icon={BsClock}
|
||||||
label="Expiration Date"
|
label="Expiration Date"
|
||||||
@@ -256,9 +272,45 @@ export default function CorporateDashboard({user}: Props) {
|
|||||||
if (shouldReload) reload();
|
if (shouldReload) reload();
|
||||||
}}
|
}}
|
||||||
onViewStudents={
|
onViewStudents={
|
||||||
selectedUser.type === "corporate" || selectedUser.type === "teacher" ? () => setPage("students") : undefined
|
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
||||||
|
? () => {
|
||||||
|
appendUserFilters({
|
||||||
|
id: "view-students",
|
||||||
|
filter: (x: User) => x.type === "student",
|
||||||
|
});
|
||||||
|
appendUserFilters({
|
||||||
|
id: "belongs-to-admin",
|
||||||
|
filter: (x: User) =>
|
||||||
|
groups
|
||||||
|
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||||
|
.flatMap((g) => g.participants)
|
||||||
|
.includes(x.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push("/list/users");
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onViewTeachers={
|
||||||
|
selectedUser.type === "corporate" || selectedUser.type === "student"
|
||||||
|
? () => {
|
||||||
|
appendUserFilters({
|
||||||
|
id: "view-teachers",
|
||||||
|
filter: (x: User) => x.type === "teacher",
|
||||||
|
});
|
||||||
|
appendUserFilters({
|
||||||
|
id: "belongs-to-admin",
|
||||||
|
filter: (x: User) =>
|
||||||
|
groups
|
||||||
|
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||||
|
.flatMap((g) => g.participants)
|
||||||
|
.includes(x.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push("/list/users");
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined}
|
|
||||||
user={selectedUser}
|
user={selectedUser}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ interface Props {
|
|||||||
label: string;
|
label: string;
|
||||||
value: string | number;
|
value: string | number;
|
||||||
color: "purple" | "rose" | "red";
|
color: "purple" | "rose" | "red";
|
||||||
|
tooltip?: string;
|
||||||
onClick?: () => void;
|
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} = {
|
const colorClasses: {[key in typeof color]: string} = {
|
||||||
purple: "text-mti-purple-light",
|
purple: "text-mti-purple-light",
|
||||||
red: "text-mti-red-light",
|
red: "text-mti-red-light",
|
||||||
@@ -19,7 +20,11 @@ export default function IconCard({Icon, label, value, color, onClick}: Props) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
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])} />
|
<Icon className={clsx("text-6xl", colorClasses[color])} />
|
||||||
<span className="flex flex-col gap-1 items-center text-xl">
|
<span className="flex flex-col gap-1 items-center text-xl">
|
||||||
<span className="text-lg">{label}</span>
|
<span className="text-lg">{label}</span>
|
||||||
|
|||||||
@@ -1,28 +1,43 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
|
import InviteCard from "@/components/Medium/InviteCard";
|
||||||
|
import PayPalPayment from "@/components/PayPalPayment";
|
||||||
import ProfileSummary from "@/components/ProfileSummary";
|
import ProfileSummary from "@/components/ProfileSummary";
|
||||||
import useAssignments from "@/hooks/useAssignments";
|
import useAssignments from "@/hooks/useAssignments";
|
||||||
|
import useInvites from "@/hooks/useInvites";
|
||||||
import useStats from "@/hooks/useStats";
|
import useStats from "@/hooks/useStats";
|
||||||
|
import useUsers from "@/hooks/useUsers";
|
||||||
|
import {Invite} from "@/interfaces/invite";
|
||||||
import {Assignment} from "@/interfaces/results";
|
import {Assignment} from "@/interfaces/results";
|
||||||
import {User} from "@/interfaces/user";
|
import {CorporateUser, User} from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {getExamById} from "@/utils/exams";
|
import {getExamById} from "@/utils/exams";
|
||||||
|
import {getUserCorporate} from "@/utils/groups";
|
||||||
import {MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
|
import {MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
|
||||||
import {averageScore, groupBySession} from "@/utils/stats";
|
import {averageScore, groupBySession} from "@/utils/stats";
|
||||||
|
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js";
|
||||||
|
import {PayPalButtons} from "@paypal/react-paypal-js";
|
||||||
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize} from "lodash";
|
import {capitalize} from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
import {BsArrowRepeat, BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs";
|
import {BsArrowRepeat, BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs";
|
||||||
|
import {toast} from "react-toastify";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StudentDashboard({user}: Props) {
|
export default function StudentDashboard({user}: Props) {
|
||||||
|
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
|
||||||
|
|
||||||
const {stats} = useStats(user.id);
|
const {stats} = useStats(user.id);
|
||||||
|
const {users} = useUsers();
|
||||||
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id});
|
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id});
|
||||||
|
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user.id});
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -32,8 +47,12 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||||
const setAssignment = useExamStore((state) => state.setAssignment);
|
const setAssignment = useExamStore((state) => state.setAssignment);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getUserCorporate(user.id).then(setCorporateUserToShow);
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
const startAssignment = (assignment: Assignment) => {
|
const startAssignment = (assignment: Assignment) => {
|
||||||
const examPromises = assignment.exams.map((e) => getExamById(e.module, e.id));
|
const examPromises = assignment.exams.filter((e) => e.assignee === user.id).map((e) => getExamById(e.module, e.id));
|
||||||
|
|
||||||
Promise.all(examPromises).then((exams) => {
|
Promise.all(examPromises).then((exams) => {
|
||||||
if (exams.every((x) => !!x)) {
|
if (exams.every((x) => !!x)) {
|
||||||
@@ -55,44 +74,51 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{corporateUserToShow && (
|
||||||
|
<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
|
<ProfileSummary
|
||||||
user={user}
|
user={user}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
icon: <BsFileEarmarkText className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
|
||||||
value: Object.keys(groupBySession(stats)).length,
|
value: Object.keys(groupBySession(stats)).length,
|
||||||
label: "Exams",
|
label: "Exams",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
icon: <BsPencil className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
|
||||||
value: stats.length,
|
value: stats.length,
|
||||||
label: "Exercises",
|
label: "Exercises",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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}%`,
|
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
|
||||||
label: "Average Score",
|
label: "Average Score",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Bio */}
|
||||||
<section className="flex flex-col gap-1 md:gap-3">
|
<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">
|
<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."}
|
{user.bio || "Your bio will appear here, you can change it by clicking on your name in the top right corner."}
|
||||||
</span>
|
</span>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Assignments */}
|
||||||
<section className="flex flex-col gap-1 md:gap-3">
|
<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
|
<div
|
||||||
onClick={reloadAssignments}
|
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">
|
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="font-bold text-lg text-mti-black">Assignments</span>
|
<span className="text-mti-black text-lg font-bold">Assignments</span>
|
||||||
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
|
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
|
||||||
</div>
|
</div>
|
||||||
</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.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 will appear here. It seems that for now there are no assignments for you."}
|
||||||
{assignments
|
{assignments
|
||||||
@@ -101,21 +127,22 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
.map((assignment) => (
|
.map((assignment) => (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
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",
|
assignment.results.map((r) => r.user).includes(user.id) && "border-mti-green-light",
|
||||||
)}
|
)}
|
||||||
key={assignment.id}>
|
key={assignment.id}>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h3 className="font-semibold text-xl text-mti-black/90">{assignment.name}</h3>
|
<h3 className="text-mti-black/90 text-xl font-semibold">{assignment.name}</h3>
|
||||||
<span className="flex gap-1 justify-between">
|
<span className="flex justify-between gap-1">
|
||||||
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
|
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span>
|
<span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between w-full items-center">
|
<div className="flex w-full items-center justify-between">
|
||||||
<div className="grid grid-cols-2 gap-2 place-items-center justify-center w-fit min-w-[104px] -md:mt-2">
|
<div className="-md:mt-2 grid w-fit min-w-[104px] grid-cols-2 place-items-center justify-center gap-2">
|
||||||
{assignment.exams
|
{assignment.exams
|
||||||
|
.filter((e) => e.assignee === user.id)
|
||||||
.map((e) => e.module)
|
.map((e) => e.module)
|
||||||
.sort(sortByModuleName)
|
.sort(sortByModuleName)
|
||||||
.map((module) => (
|
.map((module) => (
|
||||||
@@ -123,36 +150,36 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
key={module}
|
key={module}
|
||||||
data-tip={capitalize(module)}
|
data-tip={capitalize(module)}
|
||||||
className={clsx(
|
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 === "reading" && "bg-ielts-reading",
|
||||||
module === "listening" && "bg-ielts-listening",
|
module === "listening" && "bg-ielts-listening",
|
||||||
module === "writing" && "bg-ielts-writing",
|
module === "writing" && "bg-ielts-writing",
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
module === "level" && "bg-ielts-level",
|
module === "level" && "bg-ielts-level",
|
||||||
)}>
|
)}>
|
||||||
{module === "reading" && <BsBook className="w-4 h-4" />}
|
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||||
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||||
{module === "writing" && <BsPen className="w-4 h-4" />}
|
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||||
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||||
{module === "level" && <BsClipboard className="w-4 h-4" />}
|
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{!assignment.results.map((r) => r.user).includes(user.id) && (
|
{!assignment.results.map((r) => r.user).includes(user.id) && (
|
||||||
<>
|
<>
|
||||||
<div
|
<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">
|
data-tip="Your screen size is too small to perform an assignment">
|
||||||
<Button
|
<Button
|
||||||
disabled={moment(assignment.startDate).isAfter(moment())}
|
disabled={moment(assignment.startDate).isAfter(moment())}
|
||||||
className="w-full h-full !rounded-xl"
|
className="h-full w-full !rounded-xl"
|
||||||
variant="outline">
|
variant="outline">
|
||||||
Start
|
Start
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
disabled={moment(assignment.startDate).isAfter(moment())}
|
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)}
|
onClick={() => startAssignment(assignment)}
|
||||||
variant="outline">
|
variant="outline">
|
||||||
Start
|
Start
|
||||||
@@ -163,7 +190,7 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => router.push("/record")}
|
onClick={() => router.push("/record")}
|
||||||
color="green"
|
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">
|
variant="outline">
|
||||||
Submitted
|
Submitted
|
||||||
</Button>
|
</Button>
|
||||||
@@ -174,22 +201,43 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
</span>
|
</span>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="flex flex-col gap-3">
|
{/* Invites */}
|
||||||
<span className="font-bold text-lg">Score History</span>
|
{invites.length > 0 && (
|
||||||
<div className="grid -md:grid-rows-4 md:grid-cols-2 gap-6">
|
<section className="flex flex-col gap-1 md:gap-3">
|
||||||
{MODULE_ARRAY.map((module) => (
|
<div className="flex items-center gap-4">
|
||||||
<div className="border border-mti-gray-anti-flash rounded-xl flex flex-col gap-2 p-4" key={module}>
|
<div
|
||||||
<div className="flex gap-2 md:gap-3 items-center">
|
onClick={reloadInvites}
|
||||||
<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">
|
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out">
|
||||||
{module === "reading" && <BsBook className="text-ielts-reading w-4 h-4 md:w-5 md:h-5" />}
|
<span className="text-mti-black text-lg font-bold">Invites</span>
|
||||||
{module === "listening" && <BsHeadphones className="text-ielts-listening w-4 h-4 md:w-5 md:h-5" />}
|
<BsArrowRepeat className={clsx("text-xl", isInvitesLoading && "animate-spin")} />
|
||||||
{module === "writing" && <BsPen className="text-ielts-writing w-4 h-4 md:w-5 md:h-5" />}
|
|
||||||
{module === "speaking" && <BsMegaphone className="text-ielts-speaking w-4 h-4 md:w-5 md:h-5" />}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between w-full">
|
</div>
|
||||||
<span className="font-bold md:font-extrabold text-sm">{capitalize(module)}</span>
|
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
||||||
<span className="text-sm font-normal text-mti-gray-dim">
|
{invites.map((invite) => (
|
||||||
Level {user.levels[module]} / Level {user.desiredLevels[module]}
|
<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) => (
|
||||||
|
<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">
|
||||||
|
Level {user.levels[module] || 0} / Level 9 (Desired Level: {user.desiredLevels[module] || 9})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,8 +245,10 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
<ProgressBar
|
<ProgressBar
|
||||||
color={module}
|
color={module}
|
||||||
label=""
|
label=""
|
||||||
percentage={Math.round((user.levels[module] * 100) / user.desiredLevels[module])}
|
mark={Math.round((user.desiredLevels[module] * 100) / 9)}
|
||||||
className="w-full h-2"
|
markLabel={`Desired Level: ${user.desiredLevels[module]}`}
|
||||||
|
percentage={Math.round((user.levels[module] * 100) / 9)}
|
||||||
|
className="h-2 w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useStats from "@/hooks/useStats";
|
import useStats from "@/hooks/useStats";
|
||||||
import useUsers from "@/hooks/useUsers";
|
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 UserList from "@/pages/(admin)/Lists/UserList";
|
||||||
import {dateSorter} from "@/utils";
|
import {dateSorter} from "@/utils";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
@@ -19,12 +19,12 @@ import {
|
|||||||
BsEnvelopePaper,
|
BsEnvelopePaper,
|
||||||
BsGlobeCentralSouthAsia,
|
BsGlobeCentralSouthAsia,
|
||||||
BsPaperclip,
|
BsPaperclip,
|
||||||
|
BsPeople,
|
||||||
BsPerson,
|
BsPerson,
|
||||||
BsPersonAdd,
|
BsPersonAdd,
|
||||||
BsPersonFill,
|
BsPersonFill,
|
||||||
BsPersonFillGear,
|
BsPersonFillGear,
|
||||||
BsPersonGear,
|
BsPersonGear,
|
||||||
BsPersonLinesFill,
|
|
||||||
BsPlus,
|
BsPlus,
|
||||||
BsRepeat,
|
BsRepeat,
|
||||||
BsRepeat1,
|
BsRepeat1,
|
||||||
@@ -45,6 +45,7 @@ import clsx from "clsx";
|
|||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
import AssignmentCreator from "./AssignmentCreator";
|
import AssignmentCreator from "./AssignmentCreator";
|
||||||
import AssignmentView from "./AssignmentView";
|
import AssignmentView from "./AssignmentView";
|
||||||
|
import {getUserCorporate} from "@/utils/groups";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -56,6 +57,7 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
|
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
|
||||||
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
|
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
|
||||||
|
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
|
||||||
|
|
||||||
const {stats} = useStats();
|
const {stats} = useStats();
|
||||||
const {users, reload} = useUsers();
|
const {users, reload} = useUsers();
|
||||||
@@ -66,6 +68,10 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
setShowModal(!!selectedUser && page === "");
|
setShowModal(!!selectedUser && page === "");
|
||||||
}, [selectedUser, page]);
|
}, [selectedUser, page]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getUserCorporate(user.id).then(setCorporateUserToShow);
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
const studentFilter = (user: User) => user.type === "student" && groups.flatMap((g) => g.participants).includes(user.id);
|
const studentFilter = (user: User) => user.type === "student" && groups.flatMap((g) => g.participants).includes(user.id);
|
||||||
|
|
||||||
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
|
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
|
||||||
@@ -104,7 +110,7 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
|
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UserList user={user} filter={filter} />
|
<UserList user={user} filters={[filter]} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -145,8 +151,9 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AssignmentsPage = () => {
|
const AssignmentsPage = () => {
|
||||||
const activeFilter = (a: Assignment) => moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment());
|
const activeFilter = (a: Assignment) =>
|
||||||
const pastFilter = (a: Assignment) => moment(a.endDate).isBefore(moment());
|
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;
|
||||||
const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
|
const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -227,7 +234,7 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
|
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{assignments.filter(pastFilter).map((a) => (
|
{assignments.filter(pastFilter).map((a) => (
|
||||||
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} />
|
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} allowDownload />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -237,7 +244,16 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
|
|
||||||
const DefaultDashboard = () => (
|
const DefaultDashboard = () => (
|
||||||
<>
|
<>
|
||||||
<section className="flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center">
|
{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>
|
||||||
|
)}
|
||||||
|
<section
|
||||||
|
className={clsx(
|
||||||
|
"flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center",
|
||||||
|
!!corporateUserToShow && "mt-12 xl:mt-6",
|
||||||
|
)}>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("students")}
|
onClick={() => setPage("students")}
|
||||||
Icon={BsPersonFill}
|
Icon={BsPersonFill}
|
||||||
@@ -257,7 +273,7 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
||||||
color="purple"
|
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
|
<div
|
||||||
onClick={() => setPage("assignments")}
|
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">
|
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">
|
||||||
|
|||||||
@@ -40,3 +40,15 @@ export function prepareMailOptions(context: object, to: string[], subject: strin
|
|||||||
context,
|
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
@@ -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://platform.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
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
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
@@ -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
@@ -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>
|
||||||
@@ -10,6 +10,8 @@ import Link from "next/link";
|
|||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {Fragment, useEffect, useState} from "react";
|
import {Fragment, useEffect, useState} from "react";
|
||||||
import {BsArrowCounterclockwise, BsBook, BsClipboard, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs";
|
import {BsArrowCounterclockwise, BsBook, BsClipboard, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs";
|
||||||
|
import {LevelScore} from "@/constants/ielts";
|
||||||
|
import {getLevelScore} from "@/utils/score";
|
||||||
|
|
||||||
interface Score {
|
interface Score {
|
||||||
module: Module;
|
module: Module;
|
||||||
@@ -66,9 +68,24 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
return exam.exercises.length;
|
return exam.exercises.length;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const bandScore: number = calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus);
|
||||||
|
|
||||||
|
const showLevel = (level: number) => {
|
||||||
|
if (selectedModule === "level") {
|
||||||
|
const [levelStr, grade] = getLevelScore(level);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-1">
|
||||||
|
<span className="text-xl font-bold">{levelStr}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span className="text-3xl font-bold">{level}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
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
|
<ModuleTitle
|
||||||
module={selectedModule}
|
module={selectedModule}
|
||||||
totalExercises={getTotalExercises()}
|
totalExercises={getTotalExercises()}
|
||||||
@@ -81,10 +98,10 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
<div
|
<div
|
||||||
onClick={() => setSelectedModule("reading")}
|
onClick={() => setSelectedModule("reading")}
|
||||||
className={clsx(
|
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",
|
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>
|
<span className="font-semibold">Reading</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -92,10 +109,10 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
<div
|
<div
|
||||||
onClick={() => setSelectedModule("listening")}
|
onClick={() => setSelectedModule("listening")}
|
||||||
className={clsx(
|
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",
|
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>
|
<span className="font-semibold">Listening</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -103,10 +120,10 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
<div
|
<div
|
||||||
onClick={() => setSelectedModule("writing")}
|
onClick={() => setSelectedModule("writing")}
|
||||||
className={clsx(
|
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",
|
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>
|
<span className="font-semibold">Writing</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -114,10 +131,10 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
<div
|
<div
|
||||||
onClick={() => setSelectedModule("speaking")}
|
onClick={() => setSelectedModule("speaking")}
|
||||||
className={clsx(
|
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",
|
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>
|
<span className="font-semibold">Speaking</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -125,45 +142,49 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
<div
|
<div
|
||||||
onClick={() => setSelectedModule("level")}
|
onClick={() => setSelectedModule("level")}
|
||||||
className={clsx(
|
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",
|
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>
|
<span className="font-semibold">Level</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="w-fit h-fit absolute top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 animate-pulse flex flex-col gap-12 items-center">
|
<div className="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("loading loading-infinity w-32", moduleColors[selectedModule].progress)} />
|
||||||
<span className={clsx("font-bold text-2xl", moduleColors[selectedModule].progress)}>Evaluating your answers...</span>
|
<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!
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isLoading && (
|
{!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">
|
<span className="max-w-3xl">{moduleResultText(selectedModule, bandScore)}</span>
|
||||||
{moduleResultText(calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus))}
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-9 px-16">
|
<div className="flex gap-9 px-16">
|
||||||
<div
|
<div
|
||||||
className={clsx("radial-progress overflow-hidden", moduleColors[selectedModule].progress)}
|
className={clsx("radial-progress overflow-hidden", moduleColors[selectedModule].progress)}
|
||||||
style={
|
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
|
<div
|
||||||
className={clsx(
|
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,
|
moduleColors[selectedModule].inner,
|
||||||
)}>
|
)}>
|
||||||
<span className="text-xl">Level</span>
|
<span className="text-xl">Level</span>
|
||||||
<span className="text-3xl font-bold">
|
{showLevel(bandScore)}
|
||||||
{calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<div className="flex gap-2">
|
<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">
|
<div className="flex flex-col">
|
||||||
<span className="text-mti-red-light">
|
<span className="text-mti-red-light">
|
||||||
{(((selectedScore.total - selectedScore.missing) / selectedScore.total) * 100).toFixed(0)}%
|
{(((selectedScore.total - selectedScore.missing) / selectedScore.total) * 100).toFixed(0)}%
|
||||||
@@ -172,14 +193,14 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<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">
|
<div className="flex flex-col">
|
||||||
<span className="text-mti-purple-light">{selectedScore.correct.toString().padStart(2, "0")}</span>
|
<span className="text-mti-purple-light">{selectedScore.correct.toString().padStart(2, "0")}</span>
|
||||||
<span className="text-lg">Correct</span>
|
<span className="text-lg">Correct</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<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">
|
<div className="flex flex-col">
|
||||||
<span className="text-mti-rose-light">
|
<span className="text-mti-rose-light">
|
||||||
{(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")}
|
{(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")}
|
||||||
@@ -194,28 +215,28 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isLoading && (
|
{!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="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
|
<button
|
||||||
onClick={() => window.location.reload()}
|
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">
|
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="text-white w-7 h-7" />
|
<BsArrowCounterclockwise className="h-7 w-7 text-white" />
|
||||||
</button>
|
</button>
|
||||||
<span>Play Again</span>
|
<span>Play Again</span>
|
||||||
</div>
|
</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
|
<button
|
||||||
onClick={onViewResults}
|
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">
|
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="text-white w-7 h-7" />
|
<BsEyeFill className="h-7 w-7 text-white" />
|
||||||
</button>
|
</button>
|
||||||
<span>Review Answers</span>
|
<span>Review Answers</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link href="/" className="max-w-[200px] w-full self-end">
|
<Link href="/" className="w-full max-w-[200px] self-end">
|
||||||
<Button color="purple" className="max-w-[200px] self-end w-full">
|
<Button color="purple" className="w-full max-w-[200px] self-end">
|
||||||
Dashboard
|
Dashboard
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -19,15 +19,28 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Level({exam, showSolutions = false, onFinish}: Props) {
|
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 [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);
|
||||||
|
}, [questionIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasExamEnded && exerciseIndex === -1) {
|
||||||
|
setExerciseIndex((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
}, [hasExamEnded, exerciseIndex]);
|
||||||
|
|
||||||
const nextExercise = (solution?: UserSolution) => {
|
const nextExercise = (solution?: UserSolution) => {
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
}
|
}
|
||||||
|
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.exercises.length) {
|
if (exerciseIndex + 1 < exam.exercises.length) {
|
||||||
setExerciseIndex((prev) => prev + 1);
|
setExerciseIndex((prev) => prev + 1);
|
||||||
@@ -49,7 +62,7 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
|
|||||||
|
|
||||||
const previousExercise = (solution?: UserSolution) => {
|
const previousExercise = (solution?: UserSolution) => {
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exerciseIndex > 0) {
|
if (exerciseIndex > 0) {
|
||||||
@@ -70,7 +83,7 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
|
|||||||
<div className="flex flex-col h-full w-full gap-8 items-center">
|
<div className="flex flex-col h-full w-full gap-8 items-center">
|
||||||
<ModuleTitle
|
<ModuleTitle
|
||||||
minTimer={exam.minTimer}
|
minTimer={exam.minTimer}
|
||||||
exerciseIndex={exerciseIndex + 1}
|
exerciseIndex={exerciseIndex + 1 + questionIndex + currentQuestionIndex}
|
||||||
module="level"
|
module="level"
|
||||||
totalExercises={countExercises(exam.exercises)}
|
totalExercises={countExercises(exam.exercises)}
|
||||||
disableTimer={showSolutions}
|
disableTimer={showSolutions}
|
||||||
@@ -78,11 +91,11 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
|
|||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
exerciseIndex < exam.exercises.length &&
|
exerciseIndex < exam.exercises.length &&
|
||||||
!showSolutions &&
|
!showSolutions &&
|
||||||
renderExercise(getExercise(), nextExercise, previousExercise)}
|
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
exerciseIndex < exam.exercises.length &&
|
exerciseIndex < exam.exercises.length &&
|
||||||
showSolutions &&
|
showSolutions &&
|
||||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import AudioPlayer from "@/components/Low/AudioPlayer";
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
|
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {defaultUserSolutions} from "@/utils/exams";
|
|
||||||
import {countExercises} from "@/utils/moduleUtils";
|
import {countExercises} from "@/utils/moduleUtils";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -16,22 +15,39 @@ interface Props {
|
|||||||
onFinish: (userSolutions: UserSolution[]) => void;
|
onFinish: (userSolutions: UserSolution[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INSTRUCTIONS_AUDIO_SRC =
|
||||||
|
"https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_recordings%2Fgeneric_intro.mp3?alt=media&token=9b9cfdb8-e90d-40d1-854b-51c4378a5c4b";
|
||||||
|
|
||||||
export default function Listening({exam, showSolutions = false, onFinish}: Props) {
|
export default function Listening({exam, showSolutions = false, onFinish}: Props) {
|
||||||
const [exerciseIndex, setExerciseIndex] = useState(showSolutions ? 0 : -1);
|
const [questionIndex, setQuestionIndex] = useState(0);
|
||||||
const [partIndex, setPartIndex] = useState(0);
|
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||||
const [timesListened, setTimesListened] = 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 [showBlankModal, setShowBlankModal] = useState(false);
|
||||||
|
|
||||||
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 scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showSolutions) return setExerciseIndex(-1);
|
||||||
|
}, [setExerciseIndex, showSolutions]);
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// if (exam.variant !== "partial") setPartIndex(-1);
|
||||||
|
// }, [exam.variant, setPartIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded && exerciseIndex === -1) {
|
if (hasExamEnded && exerciseIndex === -1) {
|
||||||
setExerciseIndex((prev) => prev + 1);
|
setExerciseIndex(exerciseIndex + 1);
|
||||||
}
|
}
|
||||||
}, [hasExamEnded, exerciseIndex]);
|
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentQuestionIndex(0);
|
||||||
|
}, [questionIndex]);
|
||||||
|
|
||||||
const confirmFinishModule = (keepGoing?: boolean) => {
|
const confirmFinishModule = (keepGoing?: boolean) => {
|
||||||
if (!keepGoing) {
|
if (!keepGoing) {
|
||||||
@@ -43,17 +59,19 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
|||||||
};
|
};
|
||||||
|
|
||||||
const nextExercise = (solution?: UserSolution) => {
|
const nextExercise = (solution?: UserSolution) => {
|
||||||
|
scrollToTop();
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
}
|
}
|
||||||
|
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
||||||
setExerciseIndex((prev) => prev + 1);
|
setExerciseIndex(exerciseIndex + 1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
|
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
|
||||||
setPartIndex((prev) => prev + 1);
|
setPartIndex(partIndex + 1);
|
||||||
setExerciseIndex(showSolutions ? 0 : -1);
|
setExerciseIndex(showSolutions ? 0 : -1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -82,11 +100,12 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
|||||||
};
|
};
|
||||||
|
|
||||||
const previousExercise = (solution?: UserSolution) => {
|
const previousExercise = (solution?: UserSolution) => {
|
||||||
|
scrollToTop();
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
}
|
}
|
||||||
|
|
||||||
setExerciseIndex((prev) => prev - 1);
|
setExerciseIndex(exerciseIndex - 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getExercise = () => {
|
const getExercise = () => {
|
||||||
@@ -97,6 +116,17 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 = () => (
|
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 gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
|
||||||
<div className="flex flex-col w-full gap-2">
|
<div className="flex flex-col w-full gap-2">
|
||||||
@@ -126,35 +156,53 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
|||||||
<div className="flex flex-col h-full w-full gap-8 justify-between">
|
<div className="flex flex-col h-full w-full gap-8 justify-between">
|
||||||
<ModuleTitle
|
<ModuleTitle
|
||||||
exerciseIndex={
|
exerciseIndex={
|
||||||
(exam.parts
|
partIndex === -1
|
||||||
|
? 0
|
||||||
|
: (exam.parts
|
||||||
.flatMap((x) => x.exercises)
|
.flatMap((x) => x.exercises)
|
||||||
.findIndex(
|
.findIndex(
|
||||||
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
|
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
|
||||||
) || 0) + (exerciseIndex === -1 ? 0 : 1)
|
) || 0) +
|
||||||
|
(exerciseIndex === -1 ? 0 : 1) +
|
||||||
|
questionIndex +
|
||||||
|
currentQuestionIndex
|
||||||
}
|
}
|
||||||
minTimer={exam.minTimer}
|
minTimer={exam.minTimer}
|
||||||
module="listening"
|
module="listening"
|
||||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||||
disableTimer={showSolutions}
|
disableTimer={showSolutions}
|
||||||
/>
|
/>
|
||||||
{renderAudioPlayer()}
|
{/* Audio Player for the Instructions */}
|
||||||
|
{partIndex === -1 && renderAudioInstructionsPlayer()}
|
||||||
|
|
||||||
|
{/* Part's audio player */}
|
||||||
|
{partIndex > -1 && renderAudioPlayer()}
|
||||||
|
|
||||||
|
{/* Exercise renderer */}
|
||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
|
partIndex > -1 &&
|
||||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||||
!showSolutions &&
|
!showSolutions &&
|
||||||
renderExercise(getExercise(), nextExercise, previousExercise)}
|
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||||
|
|
||||||
|
{/* Solution renderer */}
|
||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
|
partIndex > -1 &&
|
||||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||||
showSolutions &&
|
showSolutions &&
|
||||||
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
|
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||||
</div>
|
</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">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<Button
|
<Button
|
||||||
color="purple"
|
color="purple"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (partIndex === 0) return setPartIndex(-1);
|
||||||
|
|
||||||
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
|
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
|
||||||
setPartIndex((prev) => prev - 1);
|
setPartIndex(partIndex - 1);
|
||||||
}}
|
}}
|
||||||
className="max-w-[200px] w-full">
|
className="max-w-[200px] w-full">
|
||||||
Back
|
Back
|
||||||
@@ -165,7 +213,13 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{exerciseIndex === -1 && (
|
|
||||||
|
{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">
|
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end">
|
||||||
Start now
|
Start now
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -81,15 +81,22 @@ function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: s
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Reading({exam, showSolutions = false, onFinish}: Props) {
|
export default function Reading({exam, showSolutions = false, onFinish}: Props) {
|
||||||
const [exerciseIndex, setExerciseIndex] = useState(showSolutions ? 0 : -1);
|
const [questionIndex, setQuestionIndex] = useState(0);
|
||||||
const [partIndex, setPartIndex] = useState(0);
|
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||||
const [showTextModal, setShowTextModal] = useState(false);
|
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 [showBlankModal, setShowBlankModal] = useState(false);
|
||||||
|
|
||||||
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 setStoreQuestionIndex = useExamStore((state) => state.setQuestionIndex);
|
||||||
|
|
||||||
|
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showSolutions) setExerciseIndex(-1);
|
||||||
|
}, [setExerciseIndex, showSolutions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const listener = (e: KeyboardEvent) => {
|
const listener = (e: KeyboardEvent) => {
|
||||||
@@ -105,11 +112,15 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentQuestionIndex(0);
|
||||||
|
}, [questionIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded && exerciseIndex === -1) {
|
if (hasExamEnded && exerciseIndex === -1) {
|
||||||
setExerciseIndex((prev) => prev + 1);
|
setExerciseIndex(exerciseIndex + 1);
|
||||||
}
|
}
|
||||||
}, [hasExamEnded, exerciseIndex]);
|
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
||||||
|
|
||||||
const confirmFinishModule = (keepGoing?: boolean) => {
|
const confirmFinishModule = (keepGoing?: boolean) => {
|
||||||
if (!keepGoing) {
|
if (!keepGoing) {
|
||||||
@@ -121,17 +132,20 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
};
|
};
|
||||||
|
|
||||||
const nextExercise = (solution?: UserSolution) => {
|
const nextExercise = (solution?: UserSolution) => {
|
||||||
|
scrollToTop();
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
}
|
}
|
||||||
|
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
||||||
|
setStoreQuestionIndex(0);
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
||||||
setExerciseIndex((prev) => prev + 1);
|
setExerciseIndex(exerciseIndex + 1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
|
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
|
||||||
setPartIndex((prev) => prev + 1);
|
setPartIndex(partIndex + 1);
|
||||||
setExerciseIndex(showSolutions ? 0 : -1);
|
setExerciseIndex(showSolutions ? 0 : -1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -160,11 +174,13 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
};
|
};
|
||||||
|
|
||||||
const previousExercise = (solution?: UserSolution) => {
|
const previousExercise = (solution?: UserSolution) => {
|
||||||
|
scrollToTop();
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
}
|
}
|
||||||
|
setStoreQuestionIndex(0);
|
||||||
|
|
||||||
setExerciseIndex((prev) => prev - 1);
|
setExerciseIndex(exerciseIndex - 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getExercise = () => {
|
const getExercise = () => {
|
||||||
@@ -199,15 +215,21 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
<>
|
<>
|
||||||
<div className="flex flex-col h-full w-full gap-8">
|
<div className="flex flex-col h-full w-full gap-8">
|
||||||
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
||||||
<TextModal {...exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />
|
{partIndex > -1 && <TextModal {...exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />}
|
||||||
<ModuleTitle
|
<ModuleTitle
|
||||||
minTimer={exam.minTimer}
|
minTimer={exam.minTimer}
|
||||||
exerciseIndex={
|
exerciseIndex={
|
||||||
(exam.parts
|
(exam.parts
|
||||||
.flatMap((x) => x.exercises)
|
.flatMap((x) => x.exercises)
|
||||||
.findIndex(
|
.findIndex(
|
||||||
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
|
(x) =>
|
||||||
) || 0) + (exerciseIndex === -1 ? 0 : 1)
|
x.id ===
|
||||||
|
exam.parts[partIndex > -1 ? partIndex : 0].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]
|
||||||
|
?.id,
|
||||||
|
) || 0) +
|
||||||
|
(exerciseIndex === -1 ? 0 : 1) +
|
||||||
|
questionIndex +
|
||||||
|
currentQuestionIndex
|
||||||
}
|
}
|
||||||
module="reading"
|
module="reading"
|
||||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||||
@@ -215,17 +237,21 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
label={exerciseIndex === -1 ? undefined : convertCamelCaseToReadable(exam.parts[partIndex].exercises[exerciseIndex].type)}
|
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")}>
|
<div className={clsx("mb-20 w-full", exerciseIndex > -1 && "grid grid-cols-2 gap-4")}>
|
||||||
{renderText()}
|
{partIndex > -1 && renderText()}
|
||||||
|
|
||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
|
partIndex > -1 &&
|
||||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||||
!showSolutions &&
|
!showSolutions &&
|
||||||
renderExercise(getExercise(), nextExercise, previousExercise)}
|
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||||
|
|
||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
|
partIndex > -1 &&
|
||||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||||
showSolutions &&
|
showSolutions &&
|
||||||
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
|
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||||
</div>
|
</div>
|
||||||
{exerciseIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
|
{exerciseIndex > -1 && partIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
|
||||||
<Button
|
<Button
|
||||||
color="purple"
|
color="purple"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -242,7 +268,7 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
|
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
|
||||||
setPartIndex((prev) => prev - 1);
|
setPartIndex(partIndex - 1);
|
||||||
}}
|
}}
|
||||||
className="max-w-[200px] w-full">
|
className="max-w-[200px] w-full">
|
||||||
Back
|
Back
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {Module} from "@/interfaces";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
import {BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
import {BsArrowRepeat, BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
||||||
import {totalExamsByModule} from "@/utils/stats";
|
import {totalExamsByModule} from "@/utils/stats";
|
||||||
import useStats from "@/hooks/useStats";
|
import useStats from "@/hooks/useStats";
|
||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
@@ -12,53 +12,78 @@ import {calculateAverageLevel} from "@/utils/score";
|
|||||||
import {sortByModuleName} from "@/utils/moduleUtils";
|
import {sortByModuleName} from "@/utils/moduleUtils";
|
||||||
import {capitalize} from "lodash";
|
import {capitalize} from "lodash";
|
||||||
import ProfileSummary from "@/components/ProfileSummary";
|
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 {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
page: "exercises" | "exams";
|
page: "exercises" | "exams";
|
||||||
onStart: (modules: Module[], avoidRepeated: boolean) => void;
|
onStart: (modules: Module[], avoidRepeated: boolean, variant: Variant) => void;
|
||||||
disableSelection?: boolean;
|
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 [selectedModules, setSelectedModules] = useState<Module[]>([]);
|
||||||
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
|
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
|
||||||
|
const [variant, setVariant] = useState<Variant>("full");
|
||||||
|
|
||||||
const {stats} = useStats(user?.id);
|
const {stats} = useStats(user?.id);
|
||||||
|
const {sessions, isLoading, reload} = useSessions(user.id);
|
||||||
|
|
||||||
|
const state = useExamStore((state) => state);
|
||||||
|
|
||||||
const toggleModule = (module: Module) => {
|
const toggleModule = (module: Module) => {
|
||||||
const modules = selectedModules.filter((x) => x !== 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 (
|
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 && (
|
{user && (
|
||||||
<ProfileSummary
|
<ProfileSummary
|
||||||
user={user}
|
user={user}
|
||||||
items={[
|
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",
|
label: "Reading",
|
||||||
value: totalExamsByModule(stats, "reading"),
|
value: totalExamsByModule(stats, "reading"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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",
|
label: "Listening",
|
||||||
value: totalExamsByModule(stats, "listening"),
|
value: totalExamsByModule(stats, "listening"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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",
|
label: "Writing",
|
||||||
value: totalExamsByModule(stats, "writing"),
|
value: totalExamsByModule(stats, "writing"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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",
|
label: "Speaking",
|
||||||
value: totalExamsByModule(stats, "speaking"),
|
value: totalExamsByModule(stats, "speaking"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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",
|
label: "Level",
|
||||||
value: totalExamsByModule(stats, "level"),
|
value: totalExamsByModule(stats, "level"),
|
||||||
},
|
},
|
||||||
@@ -67,7 +92,7 @@ export default function Selection({user, page, onStart, disableSelection = false
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="flex flex-col gap-3">
|
<section className="flex flex-col gap-3">
|
||||||
<span className="font-bold text-lg">About {capitalize(page)}</span>
|
<span className="text-lg font-bold">About {capitalize(page)}</span>
|
||||||
<span className="text-mti-gray-taupe">
|
<span className="text-mti-gray-taupe">
|
||||||
{page === "exercises" && (
|
{page === "exercises" && (
|
||||||
<>
|
<>
|
||||||
@@ -92,133 +117,171 @@ export default function Selection({user, page, onStart, disableSelection = false
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</section>
|
</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={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
|
<div
|
||||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
|
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
"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",
|
selectedModules.includes("reading") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||||
)}>
|
)}>
|
||||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-0 -translate-y-1/2">
|
<div className="bg-ielts-reading absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||||
<BsBook className="text-white w-7 h-7" />
|
<BsBook className="h-7 w-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold">Reading:</span>
|
<span className="font-semibold">Reading:</span>
|
||||||
<p className="text-center text-xs">
|
<p className="text-left text-xs">
|
||||||
Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
|
Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
|
||||||
</p>
|
</p>
|
||||||
{!selectedModules.includes("reading") && !selectedModules.includes("level") && !disableSelection && (
|
{!selectedModules.includes("reading") && !selectedModules.includes("level") && !disableSelection && (
|
||||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||||
)}
|
)}
|
||||||
{(selectedModules.includes("reading") || disableSelection) && (
|
{(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="mt-4 text-mti-red-light w-8 h-8" />}
|
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
|
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
"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",
|
selectedModules.includes("listening") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||||
)}>
|
)}>
|
||||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-0 -translate-y-1/2">
|
<div className="bg-ielts-listening absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||||
<BsHeadphones className="text-white w-7 h-7" />
|
<BsHeadphones className="h-7 w-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold">Listening:</span>
|
<span className="font-semibold">Listening:</span>
|
||||||
<p className="text-center text-xs">
|
<p className="text-left text-xs">
|
||||||
Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
|
Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
|
||||||
</p>
|
</p>
|
||||||
{!selectedModules.includes("listening") && !selectedModules.includes("level") && !disableSelection && (
|
{!selectedModules.includes("listening") && !selectedModules.includes("level") && !disableSelection && (
|
||||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||||
)}
|
)}
|
||||||
{(selectedModules.includes("listening") || disableSelection) && (
|
{(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="mt-4 text-mti-red-light w-8 h-8" />}
|
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
|
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
"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",
|
selectedModules.includes("writing") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||||
)}>
|
)}>
|
||||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-0 -translate-y-1/2">
|
<div className="bg-ielts-writing absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||||
<BsPen className="text-white w-7 h-7" />
|
<BsPen className="h-7 w-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold">Writing:</span>
|
<span className="font-semibold">Writing:</span>
|
||||||
<p className="text-center text-xs">
|
<p className="text-left text-xs">
|
||||||
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
|
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
|
||||||
</p>
|
</p>
|
||||||
{!selectedModules.includes("writing") && !selectedModules.includes("level") && !disableSelection && (
|
{!selectedModules.includes("writing") && !selectedModules.includes("level") && !disableSelection && (
|
||||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||||
)}
|
)}
|
||||||
{(selectedModules.includes("writing") || disableSelection) && (
|
{(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="mt-4 text-mti-red-light w-8 h-8" />}
|
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
|
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
"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",
|
selectedModules.includes("speaking") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||||
)}>
|
)}>
|
||||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-0 -translate-y-1/2">
|
<div className="bg-ielts-speaking absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||||
<BsMegaphone className="text-white w-7 h-7" />
|
<BsMegaphone className="h-7 w-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold">Speaking:</span>
|
<span className="font-semibold">Speaking:</span>
|
||||||
<p className="text-center text-xs">
|
<p className="text-left text-xs">
|
||||||
You'll have access to interactive dialogs, pronunciation exercises and speech recordings.
|
You'll have access to interactive dialogs, pronunciation exercises and speech recordings.
|
||||||
</p>
|
</p>
|
||||||
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && !disableSelection && (
|
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && !disableSelection && (
|
||||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||||
)}
|
)}
|
||||||
{(selectedModules.includes("speaking") || disableSelection) && (
|
{(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="mt-4 text-mti-red-light w-8 h-8" />}
|
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
||||||
</div>
|
</div>
|
||||||
{!disableSelection && (
|
{!disableSelection && (
|
||||||
<div
|
<div
|
||||||
onClick={selectedModules.length === 0 || selectedModules.includes("level") ? () => toggleModule("level") : undefined}
|
onClick={selectedModules.length === 0 || selectedModules.includes("level") ? () => toggleModule("level") : undefined}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
"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",
|
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">
|
<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="text-white w-7 h-7" />
|
<BsClipboard className="h-7 w-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold">Level:</span>
|
<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>
|
<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 && (
|
{!selectedModules.includes("level") && selectedModules.length === 0 && !disableSelection && (
|
||||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||||
)}
|
)}
|
||||||
{(selectedModules.includes("level") || disableSelection) && (
|
{(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 && (
|
{!selectedModules.includes("level") && selectedModules.length > 0 && (
|
||||||
<BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />
|
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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
|
<div
|
||||||
className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer tooltip w-full -md:justify-center"
|
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
|
||||||
data-tip="If possible, the platform will choose exams not yet done"
|
|
||||||
onClick={() => setAvoidRepeatedExams((prev) => !prev)}>
|
onClick={() => setAvoidRepeatedExams((prev) => !prev)}>
|
||||||
<input type="checkbox" className="hidden" />
|
<input type="checkbox" className="hidden" />
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
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",
|
"transition duration-300 ease-in-out",
|
||||||
avoidRepeatedExams && "!bg-mti-purple-light ",
|
avoidRepeatedExams && "!bg-mti-purple-light ",
|
||||||
)}>
|
)}>
|
||||||
<BsCheck color="white" className="w-full h-full" />
|
<BsCheck color="white" className="h-full w-full" />
|
||||||
|
</div>
|
||||||
|
<span className="tooltip" data-tip="If possible, the platform will choose exams not yet done.">
|
||||||
|
Avoid Repeated Questions
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<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" />
|
||||||
|
<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>
|
||||||
<span>Avoid Repeated Questions</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="tooltip w-full" data-tip={`Your screen size is too small to do ${page}`}>
|
<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>
|
<Button color="purple" className="w-full max-w-xs px-12 md:hidden" disabled>
|
||||||
Start Exam
|
Start Exam
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -227,10 +290,11 @@ export default function Selection({user, page, onStart, disableSelection = false
|
|||||||
onStart(
|
onStart(
|
||||||
!disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"],
|
!disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"],
|
||||||
avoidRepeatedExams,
|
avoidRepeatedExams,
|
||||||
|
variant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
color="purple"
|
color="purple"
|
||||||
className="px-12 w-full max-w-xs md:self-end -md:hidden"
|
className="-md:hidden w-full max-w-xs px-12 md:self-end"
|
||||||
disabled={selectedModules.length === 0 && !disableSelection}>
|
disabled={selectedModules.length === 0 && !disableSelection}>
|
||||||
Start Exam
|
Start Exam
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -20,17 +20,28 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Speaking({exam, showSolutions = false, onFinish}: Props) {
|
export default function Speaking({exam, showSolutions = false, onFinish}: Props) {
|
||||||
const [exerciseIndex, setExerciseIndex] = useState(0);
|
const [questionIndex, setQuestionIndex] = useState(0);
|
||||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
|
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||||
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) => {
|
const nextExercise = (solution?: UserSolution) => {
|
||||||
|
scrollToTop();
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
}
|
}
|
||||||
|
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.exercises.length) {
|
if (exerciseIndex + 1 < exam.exercises.length) {
|
||||||
setExerciseIndex((prev) => prev + 1);
|
setExerciseIndex(exerciseIndex + 1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,12 +59,13 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
|||||||
};
|
};
|
||||||
|
|
||||||
const previousExercise = (solution?: UserSolution) => {
|
const previousExercise = (solution?: UserSolution) => {
|
||||||
|
scrollToTop();
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exerciseIndex > 0) {
|
if (exerciseIndex > 0) {
|
||||||
setExerciseIndex((prev) => prev - 1);
|
setExerciseIndex(exerciseIndex - 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -71,7 +83,7 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
|||||||
<ModuleTitle
|
<ModuleTitle
|
||||||
label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
|
label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
|
||||||
minTimer={exam.minTimer}
|
minTimer={exam.minTimer}
|
||||||
exerciseIndex={exerciseIndex + 1}
|
exerciseIndex={exerciseIndex + 1 + questionIndex + currentQuestionIndex}
|
||||||
module="speaking"
|
module="speaking"
|
||||||
totalExercises={countExercises(exam.exercises)}
|
totalExercises={countExercises(exam.exercises)}
|
||||||
disableTimer={showSolutions}
|
disableTimer={showSolutions}
|
||||||
@@ -79,11 +91,11 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
|||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
exerciseIndex < exam.exercises.length &&
|
exerciseIndex < exam.exercises.length &&
|
||||||
!showSolutions &&
|
!showSolutions &&
|
||||||
renderExercise(getExercise(), nextExercise, previousExercise)}
|
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
exerciseIndex < exam.exercises.length &&
|
exerciseIndex < exam.exercises.length &&
|
||||||
showSolutions &&
|
showSolutions &&
|
||||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,18 +19,20 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Writing({exam, showSolutions = false, onFinish}: Props) {
|
export default function Writing({exam, showSolutions = false, onFinish}: Props) {
|
||||||
const [exerciseIndex, setExerciseIndex] = useState(0);
|
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
|
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) => {
|
const nextExercise = (solution?: UserSolution) => {
|
||||||
|
scrollToTop();
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.exercises.length) {
|
if (exerciseIndex + 1 < exam.exercises.length) {
|
||||||
setExerciseIndex((prev) => prev + 1);
|
setExerciseIndex(exerciseIndex + 1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,12 +50,13 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
|
|||||||
};
|
};
|
||||||
|
|
||||||
const previousExercise = (solution?: UserSolution) => {
|
const previousExercise = (solution?: UserSolution) => {
|
||||||
|
scrollToTop();
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exerciseIndex > 0) {
|
if (exerciseIndex > 0) {
|
||||||
setExerciseIndex((prev) => prev - 1);
|
setExerciseIndex(exerciseIndex - 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,7 +81,7 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
|
|||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
exerciseIndex < exam.exercises.length &&
|
exerciseIndex < exam.exercises.length &&
|
||||||
!showSolutions &&
|
!showSolutions &&
|
||||||
renderExercise(getExercise(), nextExercise, previousExercise)}
|
renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
|
||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
exerciseIndex < exam.exercises.length &&
|
exerciseIndex < exam.exercises.length &&
|
||||||
showSolutions &&
|
showSolutions &&
|
||||||
|
|||||||
169
src/exams/pdf/details/level.exam.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { View, Text, StyleSheet } from "@react-pdf/renderer";
|
||||||
|
import { ModuleScore } from "@/interfaces/module.scores";
|
||||||
|
import { styles } from "../styles";
|
||||||
|
import { RadialResult } from "./radial.result";
|
||||||
|
interface Props {
|
||||||
|
detail: ModuleScore;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const thresholds = [
|
||||||
|
{
|
||||||
|
level: "Low A1",
|
||||||
|
label: "Begginner",
|
||||||
|
minValue: 0,
|
||||||
|
maxValue: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
level: "High A1/Low A2",
|
||||||
|
label: "Elementary",
|
||||||
|
minValue: 4,
|
||||||
|
maxValue: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
level: "High A2/Low B1",
|
||||||
|
label: "Pre-Intermediate",
|
||||||
|
minValue: 8,
|
||||||
|
maxValue: 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
level: "High B2/Low C1",
|
||||||
|
label: "Upper-Intermediate",
|
||||||
|
minValue: 16,
|
||||||
|
maxValue: 21,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
level: "C1",
|
||||||
|
label: "Advanced",
|
||||||
|
minValue: 22,
|
||||||
|
maxValue: 25,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const customStyles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 30,
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
tableContainer: {
|
||||||
|
display: "flex",
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: "column",
|
||||||
|
},
|
||||||
|
tableLabel: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
tableBody: { display: "flex", flex: 1, flexDirection: "row" },
|
||||||
|
tableRow: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const LevelExamDetails = ({ detail, title }: Props) => {
|
||||||
|
const updatedThresholds = thresholds.map((t) => ({
|
||||||
|
...t,
|
||||||
|
match: detail.score >= t.minValue && detail.score <= t.maxValue,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const getBackgroundColor = (match: boolean, base: boolean) => {
|
||||||
|
if (match) return "#c2bfdd";
|
||||||
|
return base ? "#553b25" : "#ea7c7b";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTextColor = (match: boolean, base: boolean) => {
|
||||||
|
if (match) return "#9e7936";
|
||||||
|
return base ? "white" : "#553b25";
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<View style={[styles.textFont, customStyles.container]}>
|
||||||
|
<RadialResult {...detail} />
|
||||||
|
<View style={customStyles.tableContainer}>
|
||||||
|
<View style={customStyles.tableLabel}>
|
||||||
|
<Text
|
||||||
|
style={[styles.textBold, styles.textColor, { fontSize: "10px" }]}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={customStyles.tableBody}>
|
||||||
|
{updatedThresholds.map(
|
||||||
|
({ level, label, minValue, maxValue, match }, index, arr) => (
|
||||||
|
<View
|
||||||
|
key={label}
|
||||||
|
style={[
|
||||||
|
customStyles.tableRow,
|
||||||
|
{
|
||||||
|
width: `calc(100% / ${arr.length})`,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: getBackgroundColor(match, true),
|
||||||
|
paddingVertical: "8px",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.textBold,
|
||||||
|
{
|
||||||
|
color: getTextColor(match, true),
|
||||||
|
fontSize: "6px",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{level}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: getBackgroundColor(match, false),
|
||||||
|
paddingVertical: "8px",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.textBold,
|
||||||
|
{
|
||||||
|
color: getTextColor(match, false),
|
||||||
|
fontSize: "6px",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: getBackgroundColor(match, true),
|
||||||
|
paddingVertical: "24px",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.textBold,
|
||||||
|
{
|
||||||
|
color: getTextColor(match, true),
|
||||||
|
fontSize: "10px",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{minValue}-{maxValue}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||