Compare commits
941 Commits
feature/di
...
settings-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80939d16a5 | ||
|
|
11b5490af4 | ||
|
|
a31070d4a3 | ||
|
|
2a58e0d33f | ||
|
|
afe59f5a3a | ||
|
|
7fd56357e0 | ||
|
|
a4a40b9145 | ||
|
|
309dfba583 | ||
|
|
cf64a91651 | ||
|
|
0f47a8af70 | ||
|
|
d0310f7c2b | ||
|
|
f6a0a391b9 | ||
|
|
8dd4dad096 | ||
|
|
96baa2a6e0 | ||
|
|
8dd557a29b | ||
|
|
4e30eda06f | ||
|
|
12bb124d91 | ||
|
|
a71e6632d6 | ||
|
|
36f518afca | ||
|
|
a534126c61 | ||
|
|
752a46b247 | ||
|
|
663b1aae4f | ||
|
|
9b37b60be0 | ||
|
|
4347d0cabb | ||
|
|
0403773b8e | ||
|
|
8d99a6b03c | ||
|
|
02320b9484 | ||
|
|
fb077fd8cc | ||
|
|
b5a305485f | ||
|
|
8f5b27e9ce | ||
|
|
9ef04b822a | ||
|
|
a6160c3cf0 | ||
|
|
8f6639b7fc | ||
|
|
6a803fe137 | ||
|
|
d7f6a4dde7 | ||
|
|
6058e510de | ||
|
|
7208530879 | ||
|
|
9b6c545932 | ||
|
|
afb9071758 | ||
|
|
d50393930e | ||
|
|
03e1f2cfa3 | ||
|
|
877d2f359f | ||
|
|
45df9837e7 | ||
|
|
923319051c | ||
|
|
f6b4d6ad52 | ||
|
|
19d16c9cef | ||
|
|
daa27e41b3 | ||
|
|
916fa66446 | ||
|
|
10a3243756 | ||
|
|
a1c7f70329 | ||
|
|
bd2efb0ef5 | ||
|
|
34065f1f6e | ||
|
|
41873f80d7 | ||
|
|
a1b67c017d | ||
|
|
13fd7e1ee5 | ||
|
|
4996417218 | ||
|
|
60d436b5b9 | ||
|
|
8d39a20267 | ||
|
|
5d46d7e453 | ||
|
|
15f9fb320d | ||
|
|
494fc9bab6 | ||
|
|
0c5c024098 | ||
|
|
903a567805 | ||
|
|
df3929d5e6 | ||
|
|
6d62500596 | ||
|
|
e5e4e87752 | ||
|
|
0b3e686f3f | ||
|
|
3da87cce60 | ||
|
|
c9daba17e1 | ||
|
|
5cfd6d56a6 | ||
|
|
ec8c06ca94 | ||
|
|
77a22b3ab3 | ||
|
|
e79139174b | ||
|
|
61a86394ed | ||
|
|
f6741dd80e | ||
|
|
ce6708be6e | ||
|
|
b62cae2e3a | ||
|
|
d73b6d9d12 | ||
|
|
c11906a395 | ||
|
|
a29b0b56d9 | ||
|
|
53dbf99fba | ||
|
|
cb49e15cb0 | ||
|
|
0eddded560 | ||
|
|
11c6f70576 | ||
|
|
6712e89c47 | ||
|
|
9959cf4294 | ||
|
|
daec246835 | ||
|
|
8ea97ee944 | ||
|
|
975f4c8285 | ||
|
|
f0b85409c9 | ||
|
|
bdd862c633 | ||
|
|
4166781f7e | ||
|
|
1f8e9106de | ||
|
|
9e651358d5 | ||
|
|
5aed336c96 | ||
|
|
85b94512e9 | ||
|
|
906646ebce | ||
|
|
96108a4958 | ||
|
|
fb449f2054 | ||
|
|
d5ee3d9519 | ||
|
|
4e20ec6575 | ||
|
|
836b674076 | ||
|
|
5086c6fb09 | ||
|
|
489c9c3b7e | ||
|
|
e3ded29e77 | ||
|
|
16419a5584 | ||
|
|
3e3b24cc30 | ||
|
|
841698ba10 | ||
|
|
d50904611c | ||
|
|
e77fd16d26 | ||
|
|
649f24e4ae | ||
|
|
2f0cbfe74e | ||
|
|
d022bd078a | ||
|
|
c18afee9ad | ||
|
|
a65b72adad | ||
|
|
e13aea9f7d | ||
|
|
2920fa7f3a | ||
|
|
7af96ecccc | ||
|
|
70716b3483 | ||
|
|
d7bb64e7e0 | ||
|
|
dd19b5746c | ||
|
|
f967282f71 | ||
|
|
8b2459c304 | ||
|
|
72fb934d4f | ||
|
|
ed0b8bcb99 | ||
|
|
6f211d8435 | ||
|
|
b59589b855 | ||
|
|
db20feaa00 | ||
|
|
8fc2cf571e | ||
|
|
3128fea8c9 | ||
|
|
0e53b4a454 | ||
|
|
cbb61d18fe | ||
|
|
dff51cf6ea | ||
|
|
15dbadcc53 | ||
|
|
624a3fb88e | ||
|
|
00feee2179 | ||
|
|
0f8f9bc05b | ||
|
|
f76b7578a6 | ||
|
|
1a17689cd2 | ||
|
|
a958e2ff0d | ||
|
|
36b861266f | ||
|
|
771262fc18 | ||
|
|
0f03ce95e7 | ||
|
|
6a6e010daa | ||
|
|
13496387c4 | ||
|
|
4ecb21e0ae | ||
|
|
8663fe13bd | ||
|
|
de4638bc46 | ||
|
|
c9740fe8ee | ||
|
|
9b9b67c6cd | ||
|
|
fe2abaacae | ||
|
|
11e2ea3249 | ||
|
|
2de4b7c715 | ||
|
|
a8ffebe944 | ||
|
|
9ab7c3ed59 | ||
|
|
f374d91ef8 | ||
|
|
62ecc4e395 | ||
|
|
46764cacfa | ||
|
|
0b9e1bd734 | ||
|
|
bddb2ed18e | ||
|
|
e8fbeff77a | ||
|
|
b64593df90 | ||
|
|
2657cb409c | ||
|
|
329ed573b3 | ||
|
|
bb7558afb8 | ||
|
|
259ed03ee4 | ||
|
|
bf6c805487 | ||
|
|
1086e78936 | ||
|
|
7d0d930140 | ||
|
|
f02fff55e7 | ||
|
|
08e71c4dd8 | ||
|
|
6f5a74844c | ||
|
|
c4011cd456 | ||
|
|
5ef2568aa5 | ||
|
|
6d817e6d27 | ||
|
|
5decfb098d | ||
|
|
c2b6be4425 | ||
|
|
f320fee416 | ||
|
|
445e486cd2 | ||
|
|
ee26b50cf6 | ||
|
|
22f2b43692 | ||
|
|
29b2c8b3b8 | ||
|
|
51cc1e3f36 | ||
|
|
d9fce10538 | ||
|
|
bd74313bd5 | ||
|
|
18df890ef9 | ||
|
|
13ebb9bbd8 | ||
|
|
38c0c823e1 | ||
|
|
b50e15d1d9 | ||
|
|
969698d8b8 | ||
|
|
7d83ebc5c5 | ||
|
|
e99650ecd8 | ||
|
|
7287a9ce9a | ||
|
|
8cc7e6a57d | ||
|
|
0a24cb9978 | ||
|
|
a5c1286748 | ||
|
|
06684a4900 | ||
|
|
1823538058 | ||
|
|
60ccc822b5 | ||
|
|
9abd69c5e5 | ||
|
|
2667891bdd | ||
|
|
65485a0d1f | ||
|
|
74dd96d000 | ||
|
|
49ee3c45e5 | ||
|
|
49d2680a07 | ||
|
|
9dac7fd19e | ||
|
|
528299571c | ||
|
|
dcc630b8e5 | ||
|
|
be5125e5b0 | ||
|
|
0adf45c6ad | ||
|
|
d9b93a3470 | ||
|
|
83e4173750 | ||
|
|
e2d5f6ac9d | ||
|
|
37c3c6f7f4 | ||
|
|
3b4dfb9648 | ||
|
|
330c177ff9 | ||
|
|
0cff310354 | ||
|
|
87a1d7c288 | ||
|
|
8e1fe15a24 | ||
|
|
c95c0eff9b | ||
|
|
eaf94f458a | ||
|
|
ba85596e79 | ||
|
|
c6a478c406 | ||
|
|
2a27fbd02f | ||
|
|
a86ed9f76c | ||
|
|
20b52d049d | ||
|
|
165e33b188 | ||
|
|
04cbcbc4cb | ||
|
|
2feb9223c1 | ||
|
|
02d2d07f6c | ||
|
|
ecd66d61f2 | ||
|
|
424b72efaf | ||
|
|
79e51d6294 | ||
|
|
773480875f | ||
|
|
96d1b85f56 | ||
|
|
cf20920fd8 | ||
|
|
4114971244 | ||
|
|
bee20388d9 | ||
|
|
bd97529658 | ||
|
|
d3c24d738c | ||
|
|
eac43a160d | ||
|
|
24c3f506c6 | ||
|
|
3e13ed5830 | ||
|
|
9b5ff70037 | ||
|
|
d7f1a4f6b2 | ||
|
|
b663e5c706 | ||
|
|
efb99b31f2 | ||
|
|
03882d2a7e | ||
|
|
a3087567ea | ||
|
|
e37afd5bbc | ||
|
|
cf5e827ca7 | ||
|
|
bfa9d039e2 | ||
|
|
62b915fbc1 | ||
|
|
cdfafb3eea | ||
|
|
29cae5c3d2 | ||
|
|
04f97b62c3 | ||
|
|
52d309e7f4 | ||
|
|
dbf5b17f64 | ||
|
|
703fb0df5f | ||
|
|
be4d2de76f | ||
|
|
44c61c2e5d | ||
|
|
764064bc28 | ||
|
|
d87de9fea9 | ||
|
|
b63ba3f316 | ||
|
|
64b1d9266e | ||
|
|
b7cd1fb141 | ||
|
|
30cb2f460c | ||
|
|
6a38b7a32e | ||
|
|
2a1b5236ee | ||
|
|
a99f6fd20e | ||
|
|
c0c9d22864 | ||
|
|
718782cfd5 | ||
|
|
f643430068 | ||
|
|
2823af7ef8 | ||
|
|
57116f50e8 | ||
|
|
e382a09ae8 | ||
|
|
b4c7c9a911 | ||
|
|
86e920f102 | ||
|
|
6f12a4a1db | ||
|
|
a27a3c1fb0 | ||
|
|
63618405bc | ||
|
|
7ab67fdf15 | ||
|
|
17ec004a59 | ||
|
|
417bd7fecb | ||
|
|
e82895351d | ||
|
|
4802310474 | ||
|
|
dc3373be6a | ||
|
|
2e894622d0 | ||
|
|
1895b9e183 | ||
|
|
03f78ceb46 | ||
|
|
872cc62fe4 | ||
|
|
ce7032c8a7 | ||
|
|
71f07af2eb | ||
|
|
89250fb98e | ||
|
|
b09fe79cb7 | ||
|
|
870ed57166 | ||
|
|
2a9e204041 | ||
|
|
00f6aaf058 | ||
|
|
044a4f91aa | ||
|
|
65fe1ec8ed | ||
|
|
779fb76b8b | ||
|
|
4ec439492e | ||
|
|
c4b61c4787 | ||
|
|
934394b17f | ||
|
|
8baa25c445 | ||
|
|
f6166ca9e1 | ||
|
|
e6017854fd | ||
|
|
0bd8b0ab24 | ||
|
|
401d212d85 | ||
|
|
9383929ebb | ||
|
|
5dcab23fdb | ||
|
|
d111be2f70 | ||
|
|
00c171b161 | ||
|
|
53d3f843da | ||
|
|
8d7f312a83 | ||
|
|
6f11818876 | ||
|
|
81bc4e7a0c | ||
|
|
48265a8e54 | ||
|
|
0053105dd3 | ||
|
|
846d829d10 | ||
|
|
c0c3e37568 | ||
|
|
a872190e1b | ||
|
|
147a450be2 | ||
|
|
908ce5b5b9 | ||
|
|
0ec62c107c | ||
|
|
626655d0d0 | ||
|
|
16eeba76fd | ||
|
|
85729116e7 | ||
|
|
2de9636c8b | ||
|
|
bcad5b5646 | ||
|
|
4e40dc9c8c | ||
|
|
e3bcaf6b30 | ||
|
|
a35c85545e | ||
|
|
c4707d6426 | ||
|
|
3564d0af6b | ||
|
|
e7acdb5858 | ||
|
|
8bff64dd13 | ||
|
|
2c4168a014 | ||
|
|
800d04da37 | ||
|
|
b7b2718387 | ||
|
|
a862e59574 | ||
|
|
688d8ba0b2 | ||
|
|
8b7e550a70 | ||
|
|
cf1cb6f270 | ||
|
|
476a6b0188 | ||
|
|
01e55f970d | ||
|
|
bca73dff2e | ||
|
|
aef3800c08 | ||
|
|
a40c21ca53 | ||
|
|
34b1c7f25b | ||
|
|
7c641508ce | ||
|
|
4163076524 | ||
|
|
009c610033 | ||
|
|
c05df7d6b7 | ||
|
|
b881969bd4 | ||
|
|
5e6af11156 | ||
|
|
c1162c5e88 | ||
|
|
213bdd0c8f | ||
|
|
13401562fb | ||
|
|
4e199931aa | ||
|
|
3eafc799ab | ||
|
|
9b87764afb | ||
|
|
a969e90c98 | ||
|
|
c38c1d9ff6 | ||
|
|
bcacbbdd15 | ||
|
|
fa481dc50e | ||
|
|
710c7931aa | ||
|
|
d3f80603c4 | ||
|
|
fea2d311ae | ||
|
|
5f475fb7a7 | ||
|
|
bd0fab4c8f | ||
|
|
74d3f30c93 | ||
|
|
67c2e06575 | ||
|
|
506ee1e0e4 | ||
|
|
81943dbf42 | ||
|
|
c868ea8795 | ||
|
|
cfde8ac9f0 | ||
|
|
8c1da3a84a | ||
|
|
52143d2472 | ||
|
|
c7f303e410 | ||
|
|
da93b79c78 | ||
|
|
83b8ab7774 | ||
|
|
f6bb69f994 | ||
|
|
a97c40dc47 | ||
|
|
3de0357369 | ||
|
|
8eb8a7af46 | ||
|
|
9773f1da72 | ||
|
|
2ef86344cd | ||
|
|
5e8b6f96bb | ||
|
|
b757cbbed7 | ||
|
|
4e08afb259 | ||
|
|
68069d118f | ||
|
|
74dcccf089 | ||
|
|
b7ae9fb837 | ||
|
|
63d2baf35f | ||
|
|
c02a6a01f4 | ||
|
|
a646955493 | ||
|
|
7a577a7ca2 | ||
|
|
c26ff48b60 | ||
|
|
9ee09c8fda | ||
|
|
d4867fd9a2 | ||
|
|
13e52bfce6 | ||
|
|
5540e4a3e6 | ||
|
|
a18ee93909 | ||
|
|
0641d4250c | ||
|
|
f85a1f5601 | ||
|
|
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 | ||
|
|
13c8459d4b | ||
|
|
19b3bbe139 | ||
|
|
44a89c6645 | ||
|
|
4a51bd7dfa | ||
|
|
dc759a368e | ||
|
|
c28f7bb024 | ||
|
|
d412c1616f | ||
|
|
c2a807efc7 | ||
|
|
6056735c72 | ||
|
|
261ba74105 | ||
|
|
4328a1d72d | ||
|
|
82643b51d3 | ||
|
|
a38e5e2f0a | ||
|
|
19624e97bd | ||
|
|
536c1dfab3 | ||
|
|
c2acb39859 | ||
|
|
dd2ddc0e5b | ||
|
|
40f095191a | ||
|
|
37baa11987 | ||
|
|
3ecc2b7982 | ||
|
|
ba3588e97d | ||
|
|
bd6892dcf1 | ||
|
|
dc13a4a7b7 | ||
|
|
37c889a39a | ||
|
|
c5ece3a5f8 | ||
|
|
a20b980adb | ||
|
|
6e31a05f21 | ||
|
|
0aefbb85ec | ||
|
|
15f8d25bc9 | ||
|
|
c0269fca45 | ||
|
|
bdb0ffde95 | ||
|
|
8515eaf4ee | ||
|
|
3bb27a692f | ||
|
|
3528eb227e | ||
|
|
729204a095 | ||
|
|
cf5a9c9780 | ||
|
|
8b872020c6 | ||
|
|
e16a9873be | ||
|
|
faced0b20c | ||
|
|
7e576738ce | ||
|
|
e10aebf4c0 | ||
|
|
9f9e36f0cd | ||
|
|
913ed54cf9 | ||
|
|
960c5b8c6f | ||
|
|
57f2135848 | ||
|
|
171f328278 | ||
|
|
ffe534edd9 | ||
|
|
fb15668288 | ||
|
|
b00d155aa1 | ||
|
|
9b852bd6be | ||
|
|
550cdba5a7 | ||
|
|
aaa7d6deb3 | ||
|
|
e2567e128e | ||
|
|
5c11087cec | ||
|
|
635c92791c | ||
|
|
e8b44ee10e | ||
|
|
932a2e4081 | ||
|
|
11777b1bea | ||
|
|
69425d0b93 | ||
|
|
1895ffb5c3 | ||
|
|
f51dc450b9 | ||
|
|
18e12db7c5 | ||
|
|
ebb6bb2a1a | ||
|
|
348a020e7f | ||
|
|
ca96b37303 | ||
|
|
1a255b5a4d | ||
|
|
320aedefb1 | ||
|
|
da135d3e6f | ||
|
|
4c95d85cf9 | ||
|
|
1d27da71ec | ||
|
|
a84edcd237 | ||
|
|
25ce3bdf8f | ||
|
|
634a396434 | ||
|
|
1aa4f0ddfd | ||
|
|
0c9a49a9c3 | ||
|
|
cb3790dc1d | ||
|
|
d4c4546c88 | ||
|
|
ebe4c41f76 | ||
|
|
5e1b9ce2c7 | ||
|
|
2d095316a7 | ||
|
|
73d3922f18 | ||
|
|
925250d2c5 | ||
|
|
29914d3e89 | ||
|
|
1ccb9555b6 | ||
|
|
07e73b0d88 | ||
|
|
9239068cde | ||
|
|
0f0b7748d7 | ||
|
|
551faadd28 | ||
|
|
b58957e38e | ||
|
|
782976c14f | ||
|
|
2a68d37de8 | ||
|
|
a47ee28ca5 | ||
|
|
169ae2c959 | ||
|
|
a568950aa9 | ||
|
|
f9cd477114 | ||
|
|
75fb6ab197 | ||
|
|
7af607d476 | ||
|
|
41040f92c3 | ||
|
|
9dbf43cf22 | ||
|
|
4873832437 | ||
|
|
a362dc5a11 | ||
|
|
f3fea7fc66 | ||
|
|
64e7fcbcc7 | ||
|
|
733138f2be | ||
|
|
b0a11a5f8d | ||
|
|
8fb1d8e886 | ||
|
|
3491efb494 | ||
|
|
5564b4c181 | ||
|
|
7d4d228f0d | ||
|
|
8b7e7cf0ad | ||
|
|
cb5434d166 | ||
|
|
8b51e50f15 | ||
|
|
6dda49a917 | ||
|
|
7a957e4d78 | ||
|
|
a9ceecdc84 | ||
|
|
d46d0ab42f | ||
|
|
1bac6eb110 | ||
|
|
1e4316a57e | ||
|
|
9a45f53062 | ||
|
|
0ca2649040 | ||
|
|
395afbb4ee | ||
|
|
7cdff84d5e | ||
|
|
f5de8f5e10 | ||
|
|
4d364bd597 | ||
|
|
3e010572f6 | ||
|
|
68fb5e5bc7 | ||
|
|
efb341355d | ||
|
|
161d5236b4 | ||
|
|
91495d6a34 | ||
|
|
05ca96e476 | ||
|
|
dc8682e1c3 | ||
|
|
3a51185942 | ||
|
|
39a2813bde | ||
|
|
27c6eff590 | ||
|
|
8dcfb8a670 | ||
|
|
8cbf56b81a | ||
|
|
27ff2bd158 | ||
|
|
dadaa831ba | ||
|
|
27956d311c | ||
|
|
cca94db9bf | ||
|
|
42a0821677 | ||
|
|
9de62cc55f | ||
|
|
af70d36b7c | ||
|
|
b1461d1c04 | ||
|
|
f91cd0ca63 | ||
|
|
5211e92c65 | ||
|
|
af994cfadb | ||
|
|
caddc57ef8 | ||
|
|
578f42d9b1 | ||
|
|
1a0ec780b9 | ||
|
|
10b2f09c7f | ||
|
|
5263cc260d | ||
|
|
0013d86ef8 | ||
|
|
41d6303403 | ||
|
|
f2323b35b8 | ||
|
|
b3b804fc11 | ||
|
|
7dd96bf259 | ||
|
|
c2fb398707 | ||
|
|
f4b0d6822d | ||
|
|
511c30d635 | ||
|
|
084d19600a | ||
|
|
b75a0be52c | ||
|
|
c23c07ae38 | ||
|
|
17ff85b62b | ||
|
|
84a42d0e14 | ||
|
|
437c405c74 | ||
|
|
b4d856d32f | ||
|
|
3c711e0279 | ||
|
|
c879d5d8de | ||
|
|
82d2c548ef | ||
|
|
cdb4f329cf | ||
|
|
c91264e455 | ||
|
|
14a719b8b5 | ||
|
|
78c5b7027e | ||
|
|
cd71cf4833 | ||
|
|
93a5bcf40f | ||
|
|
dd0acbea61 | ||
|
|
ef736bc63e | ||
|
|
d9ca0e84a6 | ||
|
|
db54d58bab | ||
|
|
5099721b9b | ||
|
|
2c2fbffd8c | ||
|
|
3fee4292f1 | ||
|
|
7e9e28f134 | ||
|
|
d879f4afab | ||
|
|
d38ca76182 | ||
|
|
77692d270e | ||
|
|
f5c3abb310 | ||
|
|
02260d496c | ||
|
|
581adbb56e | ||
|
|
6ade34d243 | ||
|
|
16ea0b497e | ||
|
|
ea41875e36 | ||
|
|
eae0a4ae4e | ||
|
|
fea788bdc4 | ||
|
|
86c69e5993 | ||
|
|
f01794fed8 | ||
|
|
cc4b38fbbd | ||
|
|
121ac8ba4d | ||
|
|
2c10a203a5 | ||
|
|
6a2fab4f88 | ||
|
|
9637cb6477 | ||
|
|
ce90de1b74 | ||
|
|
49e24865a3 | ||
|
|
dceff807e9 | ||
|
|
3c4dba69db | ||
|
|
3fac92b54d | ||
|
|
139f527fdd | ||
|
|
93cef3d58f | ||
|
|
60b23ce1b5 | ||
|
|
d3a37eed3e | ||
|
|
447cecbf3f | ||
|
|
b2cc706a5e | ||
|
|
9cbb5b93c8 | ||
|
|
747c07f84e | ||
|
|
79ed521703 | ||
|
|
fe4a97ec85 | ||
|
|
b194a9183e | ||
|
|
f369234e8a | ||
|
|
808ec6315b | ||
|
|
d2cf50be68 | ||
|
|
294f00952e | ||
|
|
7beb1c84e7 | ||
|
|
3a7c29de56 | ||
|
|
dd357d991c | ||
|
|
47b1784615 | ||
|
|
d4156c83f4 | ||
|
|
572bc25eed | ||
|
|
e80b163b4a | ||
|
|
87e0610c79 | ||
|
|
52218ff8b8 | ||
|
|
84b0b8ac42 | ||
|
|
989a7449bf | ||
|
|
bc7eaea911 | ||
|
|
f5ec910010 | ||
|
|
2d46bad40f | ||
|
|
65ebdd7dde | ||
|
|
60217e9a66 | ||
|
|
ec3157870e | ||
|
|
9cf4bf7184 | ||
|
|
f5fc85e1a7 | ||
|
|
31f2eb510e | ||
|
|
31e2e56833 | ||
|
|
efaa32cd68 | ||
|
|
b41ee8e2ad | ||
|
|
e055b84688 | ||
|
|
1e286bb65b | ||
|
|
abe986313f | ||
|
|
088b77a66b | ||
|
|
72fc98fccd | ||
|
|
9ce45dfc30 | ||
|
|
e864e16064 | ||
|
|
6fe8a678ea | ||
|
|
b2232df0c7 | ||
|
|
9a7853bd05 | ||
|
|
1e8e95da34 | ||
|
|
4d37bf536a | ||
|
|
d0704e573b | ||
|
|
31dc29b812 | ||
|
|
9ed3672cb6 |
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
README.md
|
||||||
|
.next
|
||||||
|
.git
|
||||||
7
.gitignore
vendored
@@ -1,3 +1,5 @@
|
|||||||
|
src/constants/test_firebase.json
|
||||||
|
|
||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
@@ -35,4 +37,7 @@ yarn-error.log*
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
.env
|
.env
|
||||||
|
.yarn/*
|
||||||
|
.history*
|
||||||
|
__ENV.js
|
||||||
4
.husky/pre-commit
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
. "$(dirname -- "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
yarn build
|
||||||
5
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"dbaeumer.vscode-eslint"
|
||||||
|
]
|
||||||
|
}
|
||||||
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
57
Dockerfile
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
#syntax=docker/dockerfile:1.4
|
||||||
|
FROM node:18-alpine AS base
|
||||||
|
|
||||||
|
# Install dependencies only when needed
|
||||||
|
FROM base AS deps
|
||||||
|
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies based on the preferred package manager
|
||||||
|
COPY package.json yarn.lock* ./
|
||||||
|
RUN yarn --frozen-lockfile
|
||||||
|
|
||||||
|
|
||||||
|
# Rebuild the source code only when needed
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Next.js collects completely anonymous telemetry data about general usage.
|
||||||
|
# Learn more here: https://nextjs.org/telemetry
|
||||||
|
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||||
|
# ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
RUN yarn build
|
||||||
|
|
||||||
|
# If using npm comment out above and use below instead
|
||||||
|
# RUN npm run build
|
||||||
|
|
||||||
|
# Production image, copy all the files and run next
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV production
|
||||||
|
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
RUN \
|
||||||
|
addgroup --system --gid 1001 nodejs; \
|
||||||
|
adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
# Automatically leverage output traces to reduce image size
|
||||||
|
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||||
|
COPY --from=builder --chown=1001:1001 /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=1001:1001 /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT 3000
|
||||||
|
ENV HOSTNAME localhost
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -1,6 +1,57 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
|
const websiteUrl = process.env.NODE_ENV === 'production' ? "https://encoach.com" : "http://localhost:3000";
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
}
|
output: "standalone",
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/api/packages",
|
||||||
|
headers: [
|
||||||
|
{key: "Access-Control-Allow-Credentials", value: "false"},
|
||||||
|
{key: "Access-Control-Allow-Origin", value: websiteUrl},
|
||||||
|
{
|
||||||
|
key: "Access-Control-Allow-Methods",
|
||||||
|
value: "GET",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Access-Control-Allow-Headers",
|
||||||
|
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "/api/tickets",
|
||||||
|
headers: [
|
||||||
|
{key: "Access-Control-Allow-Credentials", value: "false"},
|
||||||
|
{key: "Access-Control-Allow-Origin", value: websiteUrl},
|
||||||
|
{
|
||||||
|
key: "Access-Control-Allow-Methods",
|
||||||
|
value: "POST,OPTIONS",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Access-Control-Allow-Headers",
|
||||||
|
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "/api/users/agents",
|
||||||
|
headers: [
|
||||||
|
{key: "Access-Control-Allow-Credentials", value: "false"},
|
||||||
|
{key: "Access-Control-Allow-Origin", value: websiteUrl},
|
||||||
|
{
|
||||||
|
key: "Access-Control-Allow-Methods",
|
||||||
|
value: "POST,OPTIONS",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Access-Control-Allow-Headers",
|
||||||
|
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = nextConfig
|
module.exports = nextConfig;
|
||||||
|
|||||||
8841
package-lock.json
generated
55
package.json
@@ -6,49 +6,98 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"prepare": "husky install"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@beam-australia/react-env": "^3.1.1",
|
||||||
|
"@dnd-kit/core": "^6.1.0",
|
||||||
|
"@firebase/util": "^1.9.7",
|
||||||
"@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",
|
||||||
|
"@react-spring/web": "^9.7.4",
|
||||||
|
"@tanstack/react-table": "^8.10.1",
|
||||||
"@types/node": "18.13.0",
|
"@types/node": "18.13.0",
|
||||||
"@types/react": "18.0.27",
|
"@types/react": "18.0.27",
|
||||||
"@types/react-dom": "18.0.10",
|
"@types/react-dom": "18.0.10",
|
||||||
|
"@use-gesture/react": "^10.3.1",
|
||||||
"axios": "^1.3.5",
|
"axios": "^1.3.5",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
"chart.js": "^4.2.1",
|
"chart.js": "^4.2.1",
|
||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
"daisyui": "^2.50.0",
|
"countries-list": "^3.0.1",
|
||||||
|
"country-codes-list": "^1.6.11",
|
||||||
|
"currency-symbol-map": "^5.1.0",
|
||||||
|
"daisyui": "^3.1.5",
|
||||||
"eslint": "8.33.0",
|
"eslint": "8.33.0",
|
||||||
"eslint-config-next": "13.1.6",
|
"eslint-config-next": "13.1.6",
|
||||||
|
"express-handlebars": "^7.1.2",
|
||||||
"firebase": "9.19.1",
|
"firebase": "9.19.1",
|
||||||
|
"firebase-admin": "^11.10.1",
|
||||||
|
"formidable": "^3.5.0",
|
||||||
|
"formidable-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-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",
|
||||||
"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-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-lineto": "^3.3.0",
|
"react-lineto": "^3.3.0",
|
||||||
"react-media-recorder": "^1.6.6",
|
"react-media-recorder": "1.6.5",
|
||||||
|
"react-phone-number-input": "^3.3.6",
|
||||||
"react-player": "^2.12.0",
|
"react-player": "^2.12.0",
|
||||||
|
"react-select": "^5.7.5",
|
||||||
"react-string-replace": "^1.1.0",
|
"react-string-replace": "^1.1.0",
|
||||||
"react-toastify": "^9.1.2",
|
"react-toastify": "^9.1.2",
|
||||||
|
"react-tooltip": "^5.27.1",
|
||||||
|
"react-xarrows": "^2.0.2",
|
||||||
|
"read-excel-file": "^5.7.1",
|
||||||
|
"short-unique-id": "5.0.2",
|
||||||
|
"stripe": "^13.10.0",
|
||||||
"swr": "^2.1.3",
|
"swr": "^2.1.3",
|
||||||
|
"tailwind-scrollbar-hide": "^1.1.7",
|
||||||
"typescript": "4.9.5",
|
"typescript": "4.9.5",
|
||||||
|
"use-file-picker": "^2.1.0",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
|
"wavesurfer.js": "^6.6.4",
|
||||||
"zustand": "^4.3.6"
|
"zustand": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/blob-stream": "^0.1.33",
|
||||||
|
"@types/formidable": "^3.4.0",
|
||||||
|
"@types/howler": "^2.2.11",
|
||||||
"@types/lodash": "^4.14.191",
|
"@types/lodash": "^4.14.191",
|
||||||
|
"@types/nodemailer": "^6.4.11",
|
||||||
|
"@types/nodemailer-express-handlebars": "^4.0.3",
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
|
"@types/react-csv": "^1.1.10",
|
||||||
|
"@types/react-datepicker": "^4.15.1",
|
||||||
"@types/uuid": "^9.0.1",
|
"@types/uuid": "^9.0.1",
|
||||||
|
"@types/wavesurfer.js": "^6.0.6",
|
||||||
"@wixc3/react-board": "^2.2.0",
|
"@wixc3/react-board": "^2.2.0",
|
||||||
"autoprefixer": "^10.4.13",
|
"autoprefixer": "^10.4.13",
|
||||||
|
"husky": "^8.0.3",
|
||||||
"postcss": "^8.4.21",
|
"postcss": "^8.4.21",
|
||||||
"tailwindcss": "^3.2.4"
|
"tailwindcss": "^3.2.4"
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/audio/check.mp3
Normal file
BIN
public/audio/error.mp3
Normal file
BIN
public/audio/sent.mp3
Normal file
BIN
public/defaultAvatar.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 5.5 KiB |
BIN
public/logo.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
public/logo_title.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
public/manuals/corporate.pdf
Normal file
BIN
public/manuals/student.pdf
Normal file
BIN
public/manuals/teacher.pdf
Normal file
1
public/mat-icon-info.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed"><path d="M440-280h80v-240h-80v240Zm40-320q17 0 28.5-11.5T520-640q0-17-11.5-28.5T480-680q-17 0-28.5 11.5T440-640q0 17 11.5 28.5T480-600Zm0 520q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 535 B |
BIN
public/people-talking-tablet.png
Normal file
|
After Width: | Height: | Size: 832 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 |
193
src/components/AIDetection.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import RadialProgressBar from "./RadialProgressBar";
|
||||||
|
import { AIDetectionAttributes } from "@/interfaces/exam";
|
||||||
|
import { Tooltip } from 'react-tooltip';
|
||||||
|
import SegmentedProgressBar from "./SegmentedProgressBar";
|
||||||
|
|
||||||
|
|
||||||
|
// Colors and texts scrapped from gpt's zero react bundle
|
||||||
|
const AIDetection: React.FC<AIDetectionAttributes> = ({ predicted_class, confidence_category, class_probabilities, sentences }) => {
|
||||||
|
const probabilityTooltipContent = `
|
||||||
|
Encoach's deep learning model predicts the <br/>
|
||||||
|
probability this text has been entirely <br/>
|
||||||
|
generated by AI. For instance, a 40% AI <br/>
|
||||||
|
probability does not indicate that the text<br/>
|
||||||
|
contains 40% AI-written content. Rather, it<br/>
|
||||||
|
indicates the text is more likely to be partially<br/>
|
||||||
|
human written than be entirely AI-written.
|
||||||
|
`;
|
||||||
|
const confidenceTooltipContent = `
|
||||||
|
Confidence scores are a safeguard to better<br/>
|
||||||
|
understand AI identification results. Encoach<br/>
|
||||||
|
trained it's deep learning model on a diverse<br/>
|
||||||
|
dataset of millions of human and AI-written<br/>
|
||||||
|
documents. Green scores indicate that you can scan<br/>
|
||||||
|
with confidence that the model has classified<br/>
|
||||||
|
many similar documents with high accuracy.<br/>
|
||||||
|
Red scores indicate that this text is dissimilar<br/>
|
||||||
|
to the ones in their training set, which can impact<br/>
|
||||||
|
the model's accuracy, and to proceed with caution.
|
||||||
|
`;
|
||||||
|
const confidenceKeywords = ["moderately", "highly", "confident", "uncertain"];
|
||||||
|
var confidence = {
|
||||||
|
low: {
|
||||||
|
ai: "Encoach is uncertain about this text. If Encoach had to classify it, it would be considered",
|
||||||
|
human: "Encoach is uncertain about this text. If Encoach had to classify it, it would likely be considered",
|
||||||
|
mixed: "Encoach is uncertain about this text. If Encoach had to classify it, it would likely be a"
|
||||||
|
},
|
||||||
|
medium: {
|
||||||
|
ai: "Encoach is moderately confident this text was",
|
||||||
|
human: "Encoach is moderately confident this text is entirely",
|
||||||
|
mixed: "Encoach is moderately confident this text is a"
|
||||||
|
},
|
||||||
|
high: {
|
||||||
|
ai: "Encoach is highly confident this text was",
|
||||||
|
human: "Encoach is highly confident this text is entirely",
|
||||||
|
mixed: "Encoach is highly confident this text is a"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var classPrediction = {
|
||||||
|
ai: {
|
||||||
|
background: "bg-ai-detection-result-ai-bg",
|
||||||
|
color: "text-ai-detection-result-ai",
|
||||||
|
text: "ai generated"
|
||||||
|
},
|
||||||
|
mixed: {
|
||||||
|
background: "bg-ai-detection-result-mixed-bg",
|
||||||
|
color: "text-ai-detection-result-mixed",
|
||||||
|
text: "mix of ai and human"
|
||||||
|
},
|
||||||
|
human: {
|
||||||
|
background: "bg-ai-detection-result-human-bg",
|
||||||
|
color: "text-ai-detection-result-human",
|
||||||
|
text: "human"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const segments = [
|
||||||
|
{ percentage: Math.round(class_probabilities["human"] * 100), subtitle: 'human', color: "ai-detection-result-human" },
|
||||||
|
{ percentage: Math.round(class_probabilities["mixed"] * 100), subtitle: 'mixed', color: "ai-detection-result-mixed" },
|
||||||
|
{ percentage: Math.round(class_probabilities["ai"] * 100), subtitle: 'ai', color: "ai-detection-result-ai" }
|
||||||
|
];
|
||||||
|
const styleConfidenceText = (text: string): [string, string[]] => {
|
||||||
|
const keywords: string[] = [];
|
||||||
|
const styledText = text.split(" ").map(word => {
|
||||||
|
if (confidenceKeywords.includes(word)) {
|
||||||
|
keywords.push(word);
|
||||||
|
return `<span style="font-weight: 500; text-decoration: underline;">${word}</span>`;
|
||||||
|
}
|
||||||
|
return word
|
||||||
|
}).join(" ");
|
||||||
|
return [styledText, keywords];
|
||||||
|
};
|
||||||
|
const confidenceText = confidence[confidence_category][predicted_class];
|
||||||
|
const [styledText, keywords] = styleConfidenceText(confidenceText);
|
||||||
|
const tooltipStyle = {
|
||||||
|
"backgroundColor": "rgb(255, 255, 255)",
|
||||||
|
"color": "#8992B1",
|
||||||
|
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||||
|
borderRadius: '0.125rem'
|
||||||
|
}
|
||||||
|
const highestProbability = Math.max(class_probabilities["ai"], class_probabilities["human"], class_probabilities["mixed"]);
|
||||||
|
const spanTextColor = highestProbability === class_probabilities["ai"]
|
||||||
|
? "#f4bf4f"
|
||||||
|
: highestProbability === class_probabilities["human"]
|
||||||
|
? "#50c08a"
|
||||||
|
: "#93aafb";
|
||||||
|
let spanClassName = highestProbability === class_probabilities["ai"]
|
||||||
|
? "text-ai-detection-result-ai"
|
||||||
|
: highestProbability === class_probabilities["human"]
|
||||||
|
? "text-ai-detection-result-human"
|
||||||
|
: "text-ai-detection-result-mixed";
|
||||||
|
spanClassName = `${spanClassName} font-bold text-lg`
|
||||||
|
const percentage = Math.round(highestProbability * 100)
|
||||||
|
const hasHighlightedForAI = sentences.some(item => item.highlight_sentence_for_ai);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip id="probability-tooltip" className="z-50 bg-white shadow-md rounded-sm" style={tooltipStyle} />
|
||||||
|
<Tooltip id="confidence-tooltip" className="z-50 bg-white shadow-md rounded-sm" style={tooltipStyle} />
|
||||||
|
<div className="flex flex-col bg-white p-6 rounded-lg shadow-lg gap-16">
|
||||||
|
<h1 className="text-lg font-semibold">Encoach Detection Results</h1>
|
||||||
|
<div className="flex flex-row -md:flex-col -lg:gap-0 -xl:gap-10 gap-20 items-stretch -md:items-center">
|
||||||
|
<div className="flex -md:w-5/6 w-1/2 justify-center">
|
||||||
|
<div className="flex flex-col border rounded-xl">
|
||||||
|
<h1 className="border-b p-6 font-medium">Text Classification</h1>
|
||||||
|
<div className="flex flex-row gap-8 items-center p-6">
|
||||||
|
<RadialProgressBar
|
||||||
|
percentage={percentage}
|
||||||
|
text={predicted_class}
|
||||||
|
color={spanTextColor}
|
||||||
|
spanClassName={spanClassName}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1 text-sm">
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<span className="mr-2 text-ai-detection-result-ai-text font-semibold text-xl">
|
||||||
|
{`${Math.round(class_probabilities["ai"] * 100)}%`}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm -md:text-xs text-ai-detection-text">Probability AI generated</span>
|
||||||
|
<a data-tooltip-id="probability-tooltip" data-tooltip-html={probabilityTooltipContent} className='ml-1 flex items-center justify-center'>
|
||||||
|
<Image src="/mat-icon-info.svg" width={24} height={24} alt="Probability Tooltip" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row items-center gap-1">
|
||||||
|
<div className={clsx(
|
||||||
|
"rounded-full w-3 h-3",
|
||||||
|
confidence_category == 'low' ?
|
||||||
|
"bg-ai-detection-confidence-low border border-ai-detection-confidence-border" : "bg-ai-detection-confidence-low-transparent"
|
||||||
|
)}></div>
|
||||||
|
<div className={clsx(
|
||||||
|
"rounded-full w-3 h-3",
|
||||||
|
confidence_category == 'medium' ?
|
||||||
|
"bg-ai-detection-confidence-medium border border-ai-detection-confidence-border" : "bg-ai-detection-confidence-medium-transparent"
|
||||||
|
)}></div>
|
||||||
|
<div className={clsx(
|
||||||
|
"rounded-full w-3 h-3 mr-2",
|
||||||
|
confidence_category == 'high' ?
|
||||||
|
"bg-ai-detection-confidence-high border border-ai-detection-confidence-border" : "bg-ai-detection-confidence-high-transparent"
|
||||||
|
)}></div>
|
||||||
|
<span className="text-sm -md:text-xs text-ai-detection-text">{keywords.join(' ')}</span>
|
||||||
|
<a data-tooltip-id="confidence-tooltip" data-tooltip-html={confidenceTooltipContent} className='ml-1 flex items-center justify-center'>
|
||||||
|
<Image src="/mat-icon-info.svg" width={24} height={24} alt="Probability Tooltip" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col border rounded-xl -md:w-5/6 w-2/6">
|
||||||
|
<h1 className="border-b p-6 font-medium">Probability Breakdown</h1>
|
||||||
|
<div className="flex items-center w-full h-full">
|
||||||
|
<SegmentedProgressBar segments={segments} className="w-full px-8 -md:py-8 text-ai-detection-text" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: styledText }} className="mr-2"></div>
|
||||||
|
<div className={clsx(
|
||||||
|
"flex items-center justify-center p-2 rounded",
|
||||||
|
classPrediction[predicted_class]['color'],
|
||||||
|
classPrediction[predicted_class]['background']
|
||||||
|
)}>
|
||||||
|
<span className="text-sm">{classPrediction[predicted_class]['text']}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(hasHighlightedForAI && <div>
|
||||||
|
Sentences that are likely written by AI are <span className="font-semibold bg-ai-detection-highlight">highlighted</span>.
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
</div >
|
||||||
|
<div>
|
||||||
|
{sentences.map((item, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={item.highlight_sentence_for_ai ? 'bg-ai-detection-highlight' : ''}
|
||||||
|
>
|
||||||
|
{item.sentence}{' '}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export default AIDetection;
|
||||||
55
src/components/AbandonPopup.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import {Dialog, Transition} from "@headlessui/react";
|
||||||
|
import {Fragment} from "react";
|
||||||
|
import Button from "./Low/Button";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
abandonPopupTitle: string;
|
||||||
|
abandonPopupDescription: string;
|
||||||
|
abandonConfirmButtonText: string;
|
||||||
|
onAbandon: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AbandonPopup({isOpen, abandonPopupTitle, abandonPopupDescription, abandonConfirmButtonText, onAbandon, onCancel}: Props) {
|
||||||
|
return (
|
||||||
|
<Transition show={isOpen} as={Fragment}>
|
||||||
|
<Dialog onClose={onCancel} className="relative z-50">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0">
|
||||||
|
<div className="fixed inset-0 bg-black/30" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95">
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||||
|
<Dialog.Panel className="w-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
|
||||||
|
<Dialog.Title className="font-bold text-xl">{abandonPopupTitle}</Dialog.Title>
|
||||||
|
<span>{abandonPopupDescription}</span>
|
||||||
|
<div className="w-full flex justify-between mt-8">
|
||||||
|
<Button color="purple" onClick={onCancel} variant="outline" className="max-w-[200px] self-end w-full">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button color="purple" onClick={onAbandon} className="max-w-[200px] self-end w-full">
|
||||||
|
{abandonConfirmButtonText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Panel>
|
||||||
|
</div>
|
||||||
|
</Transition.Child>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/components/BlankQuestionsModal.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import {Dialog, Transition} from "@headlessui/react";
|
||||||
|
import {Fragment} from "react";
|
||||||
|
import Button from "./Low/Button";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: (next?: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BlankQuestionsModal({isOpen, onClose}: Props) {
|
||||||
|
return (
|
||||||
|
<Transition show={isOpen} as={Fragment}>
|
||||||
|
<Dialog onClose={() => onClose(false)} className="relative z-50">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0">
|
||||||
|
<div className="fixed inset-0 bg-black/30" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95">
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||||
|
<Dialog.Panel className="w-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
|
||||||
|
<Dialog.Title className="font-bold text-xl">Questions Unanswered</Dialog.Title>
|
||||||
|
<span>
|
||||||
|
Please note that you are finishing the current module and once you proceed to the next module, you will no longer be
|
||||||
|
able to change the answers in the current one, including your unanswered questions. <br />
|
||||||
|
<br />
|
||||||
|
Are you sure you want to continue without completing those questions?
|
||||||
|
</span>
|
||||||
|
<div className="w-full flex justify-between mt-8">
|
||||||
|
<Button color="purple" onClick={() => onClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
<Button color="purple" onClick={() => onClose(true)} className="max-w-[200px] self-end w-full">
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Panel>
|
||||||
|
</div>
|
||||||
|
</Transition.Child>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
src/components/BottomBar.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import {IconType} from "react-icons";
|
||||||
|
import {MdSpaceDashboard} from "react-icons/md";
|
||||||
|
import {BsFileEarmarkText, BsClockHistory, BsPencil, BsGraphUp} from "react-icons/bs";
|
||||||
|
import {RiLogoutBoxFill} from "react-icons/ri";
|
||||||
|
import {SlPencil} from "react-icons/sl";
|
||||||
|
import {FaAward} from "react-icons/fa";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import axios from "axios";
|
||||||
|
import FocusLayer from "@/components/FocusLayer";
|
||||||
|
import {preventNavigation} from "@/utils/navigation.disabled";
|
||||||
|
interface Props {
|
||||||
|
path: string;
|
||||||
|
navDisabled?: boolean;
|
||||||
|
focusMode?: boolean;
|
||||||
|
onFocusLayerMouseEnter?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavProps {
|
||||||
|
Icon: IconType;
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
keyPath: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Nav = ({Icon, label, path, keyPath, disabled = false}: NavProps) => (
|
||||||
|
<Link
|
||||||
|
href={!disabled ? keyPath : ""}
|
||||||
|
className={clsx(
|
||||||
|
"p-4 rounded-full flex gap-4 items-center cursor-pointer text-gray-500 hover:bg-mti-purple-light hover:text-white transition duration-300 ease-in-out",
|
||||||
|
path === keyPath && "bg-mti-purple-light text-white",
|
||||||
|
)}>
|
||||||
|
<Icon size={20} />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function BottomBar({path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter, className}: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
axios.post("/api/logout").finally(() => {
|
||||||
|
setTimeout(() => router.reload(), 500);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={clsx("w-full bg-white py-2 drop-shadow-2xl shadow-2xl rounded-t-2xl", className)}>
|
||||||
|
<div className="flex justify-around gap-3">
|
||||||
|
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" />
|
||||||
|
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" />
|
||||||
|
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" />
|
||||||
|
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" />
|
||||||
|
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" />
|
||||||
|
</div>
|
||||||
|
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
src/components/DemographicInformationInput.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import {EmploymentStatus, EMPLOYMENT_STATUS, Gender, User} from "@/interfaces/user";
|
||||||
|
import {FormEvent, useEffect, useState} from "react";
|
||||||
|
import countryCodes from "country-codes-list";
|
||||||
|
import {RadioGroup} from "@headlessui/react";
|
||||||
|
import Input from "./Low/Input";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import Button from "./Low/Button";
|
||||||
|
import {BsArrowRepeat} from "react-icons/bs";
|
||||||
|
import axios from "axios";
|
||||||
|
import {toast} from "react-toastify";
|
||||||
|
import {KeyedMutator} from "swr";
|
||||||
|
import CountrySelect from "./Low/CountrySelect";
|
||||||
|
import GenderInput from "@/components/High/GenderInput";
|
||||||
|
import EmploymentStatusInput from "@/components/High/EmploymentStatusInput";
|
||||||
|
import TimezoneSelect from "./Low/TImezoneSelect";
|
||||||
|
import moment from "moment";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
mutateUser: KeyedMutator<User>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DemographicInformationInput({user, mutateUser}: Props) {
|
||||||
|
const [country, setCountry] = 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 [employment, setEmployment] = useState<EmploymentStatus>();
|
||||||
|
const [position, setPosition] = useState<string>();
|
||||||
|
const [timezone, setTimezone] = useState<string>(moment.tz.guess());
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const [companyName, setCompanyName] = useState<string>();
|
||||||
|
const [commercialRegistration, setCommercialRegistration] = useState<string>();
|
||||||
|
|
||||||
|
const save = (e?: FormEvent) => {
|
||||||
|
if (e) e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
axios
|
||||||
|
.patch("/api/users/update", {
|
||||||
|
demographicInformation: {
|
||||||
|
country,
|
||||||
|
phone: `+${countryCodes.findOne("countryCode" as any, country!).countryCallingCode}${phone}`,
|
||||||
|
gender,
|
||||||
|
employment: user.type === "corporate" ? undefined : employment,
|
||||||
|
position: user.type === "corporate" ? position : undefined,
|
||||||
|
passport_id,
|
||||||
|
timezone,
|
||||||
|
},
|
||||||
|
agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined,
|
||||||
|
})
|
||||||
|
.then((response) => mutateUser((response.data as {user: User}).user))
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"});
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-12 w-full">
|
||||||
|
<h2 className="font-semibold text-center text-xl max-w-[800px]">
|
||||||
|
Welcome to EnCoach, the ultimate platform dedicated to helping you master the IELTS ! We are thrilled that you have chosen us as your
|
||||||
|
learning companion on this journey towards achieving your desired IELTS score.
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
To make the most of your learning experience, we kindly request you to complete your profile. By providing some essential information
|
||||||
|
about yourself.
|
||||||
|
</h2>
|
||||||
|
<form className="flex flex-col items-center justify-items-center gap-6 w-full h-full -md:px-4 lg:w-1/2 mb-32" onSubmit={save}>
|
||||||
|
{user.type === "agent" && (
|
||||||
|
<div className="w-full flex gap-8">
|
||||||
|
<Input type="text" onChange={setCompanyName} name="companyName" label="Corporate Name" required />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
onChange={setCommercialRegistration}
|
||||||
|
name="commercialRegistration"
|
||||||
|
label="Commercial Registration"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="w-full grid grid-cols-2 gap-6">
|
||||||
|
<div className="relative flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
||||||
|
<CountrySelect value={country} onChange={setCountry} />
|
||||||
|
</div>
|
||||||
|
<Input type="tel" name="phone" label="Phone number" onChange={(e) => setPhone(e)} placeholder="Enter phone number" required />
|
||||||
|
</div>
|
||||||
|
{user.type === "student" && (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="passport_id"
|
||||||
|
label="Passport/National ID"
|
||||||
|
onChange={(e) => setPassportID(e)}
|
||||||
|
value={passport_id}
|
||||||
|
placeholder="Enter National ID or Passport number"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Timezone</label>
|
||||||
|
<TimezoneSelect value={timezone} onChange={setTimezone} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GenderInput value={gender} onChange={setGender} />
|
||||||
|
{user.type === "corporate" && (
|
||||||
|
<Input name="position" onChange={setPosition} type="text" label="Position" placeholder="CEO, Head of Marketing..." required />
|
||||||
|
)}
|
||||||
|
{user.type !== "corporate" && <EmploymentStatusInput value={employment} onChange={setEmployment} />}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="self-end flex justify-end w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
|
<Button
|
||||||
|
className="lg:mt-8 max-w-[400px] w-full self-end"
|
||||||
|
color="purple"
|
||||||
|
onClick={save}
|
||||||
|
disabled={
|
||||||
|
isLoading ||
|
||||||
|
!country ||
|
||||||
|
!phone ||
|
||||||
|
!gender ||
|
||||||
|
(user.type === "corporate" ? !position : !employment) ||
|
||||||
|
(user.type === "agent" ? !companyName || !commercialRegistration : false)
|
||||||
|
}>
|
||||||
|
{!isLoading && "Save information"}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,118 +1,134 @@
|
|||||||
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";
|
||||||
import {getExamById} from "@/utils/exams";
|
import {getExam, getExamById} from "@/utils/exams";
|
||||||
|
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||||
|
import {writingMarking} from "@/utils/score";
|
||||||
|
import {Menu} from "@headlessui/react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize} from "lodash";
|
import {capitalize} from "lodash";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
|
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 ModuleLevelSelector from "./Medium/ModuleLevelSelector";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
onFinish: () => void;
|
onFinish: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DIAGNOSTIC_EXAMS = [
|
|
||||||
["reading", "CurQtQoxWmHaJHeN0JW2"],
|
|
||||||
["listening", "Y6cMao8kUcVnPQOo6teV"],
|
|
||||||
["writing", "hbueuDaEZXV37EW7I12A"],
|
|
||||||
["speaking", "QVFm4pdcziJQZN2iUTDo"],
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Diagnostic({onFinish}: Props) {
|
export default function Diagnostic({onFinish}: Props) {
|
||||||
const [focus, setFocus] = useState<"academic" | "general">();
|
const [focus, setFocus] = useState<"academic" | "general">();
|
||||||
const [isInsert, setIsInsert] = useState(false);
|
const [levels, setLevels] = useState({reading: -1, listening: -1, writing: -1, speaking: -1, level: 0});
|
||||||
const [levels, setLevels] = useState({reading: 0, listening: 0, writing: 0, speaking: 0});
|
const [desiredLevels, setDesiredLevels] = useState({reading: 9, listening: 9, writing: 9, speaking: 9, level: 9});
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const setExams = useExamStore((state) => state.setExams);
|
const setExams = useExamStore((state) => state.setExams);
|
||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||||
|
|
||||||
|
const isNextDisabled = () => {
|
||||||
|
if (!focus) return true;
|
||||||
|
return Object.values(levels).includes(-1);
|
||||||
|
};
|
||||||
|
|
||||||
const selectExam = () => {
|
const selectExam = () => {
|
||||||
const examPromises = DIAGNOSTIC_EXAMS.map((exam) => getExamById(exam[0] as Module, exam[1]));
|
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)) {
|
||||||
setExams(exams.map((x) => x!));
|
setExams(exams.map((x) => x!));
|
||||||
setSelectedModules(exams.map((x) => x!.module));
|
setSelectedModules(exams.map((x) => x!.module));
|
||||||
router.push("/exam");
|
router.push("/exercises");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateUser = (callback: () => void) => {
|
const updateUser = (callback: () => void) => {
|
||||||
axios
|
axios
|
||||||
.patch("/api/users/update", {focus, levels, isFirstLogin: false})
|
.patch("/api/users/update", {
|
||||||
|
focus,
|
||||||
|
levels: Object.values(levels).includes(-1) ? {reading: 0, listening: 0, writing: 0, speaking: 0, level: 0} : levels,
|
||||||
|
desiredLevels,
|
||||||
|
isFirstLogin: false,
|
||||||
|
})
|
||||||
.then(callback)
|
.then(callback)
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"});
|
toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!focus) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white p-16 rounded-2xl flex flex-col items-center justify-center gap-8 h-96 relative shadow-md">
|
|
||||||
<h2 className="absolute top-8 font-semibold text-xl">What is your focus?</h2>
|
|
||||||
<div className="flex flex-col gap-4 justify-self-stretch">
|
|
||||||
<button onClick={() => setFocus("academic")} className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}>
|
|
||||||
Academic
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setFocus("general")} className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}>
|
|
||||||
General
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isInsert) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white p-16 rounded-2xl flex flex-col items-center justify-center gap-8 shadow-md">
|
|
||||||
<h2 className="font-semibold text-xl">What is your level?</h2>
|
|
||||||
<div className="flex w-full flex-col gap-4 justify-self-stretch">
|
|
||||||
{Object.keys(levels).map((module) => (
|
|
||||||
<div key={module} className="flex items-center gap-4 justify-between">
|
|
||||||
<span className="font-medium text-lg">{capitalize(module)}</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className={clsx(
|
|
||||||
"input input-bordered bg-white w-24",
|
|
||||||
!BAND_SCORES[module as Module].includes(levels[module as keyof typeof levels]) && "input-error",
|
|
||||||
)}
|
|
||||||
value={levels[module as keyof typeof levels]}
|
|
||||||
min={0}
|
|
||||||
max={9}
|
|
||||||
step={0.5}
|
|
||||||
onChange={(e) => setLevels((prev) => ({...prev, [module]: parseFloat(e.target.value)}))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => updateUser(onFinish)}
|
|
||||||
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
|
|
||||||
disabled={!Object.keys(levels).every((module) => BAND_SCORES[module as Module].includes(levels[module as keyof typeof levels]))}>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white p-16 rounded-2xl flex flex-col items-center justify-center gap-8 h-96 relative shadow-md">
|
<div className="flex flex-col items-center justify-center gap-12 w-full">
|
||||||
<h2 className="absolute top-8 font-semibold text-xl">What is your current IELTS level?</h2>
|
<div className="flex flex-col items-center justify-center gap-8 w-full">
|
||||||
<div className="flex flex-col gap-4">
|
<h2 className="font-semibold text-xl">What is your current focus?</h2>
|
||||||
<button onClick={() => setIsInsert(true)} className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}>
|
<div className="flex flex-col gap-16 w-full">
|
||||||
Insert my IELTS level
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-16">
|
||||||
</button>
|
<button
|
||||||
<button onClick={() => updateUser(selectExam)} className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}>
|
onClick={() => setFocus("academic")}
|
||||||
Perform a Diagnosis Test
|
className={clsx(
|
||||||
</button>
|
"w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-center items-center gap-12 bg-white",
|
||||||
|
"hover:bg-mti-purple-light hover:text-white",
|
||||||
|
focus === "academic" && "!bg-mti-purple-light !text-white",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
)}>
|
||||||
|
Academic
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFocus("general")}
|
||||||
|
className={clsx(
|
||||||
|
"w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-center items-center gap-12 bg-white",
|
||||||
|
"hover:bg-mti-purple-light hover:text-white",
|
||||||
|
focus === "general" && "!bg-mti-purple-light !text-white",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
)}>
|
||||||
|
General
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center justify-center gap-8 w-full">
|
||||||
|
<h2 className="font-semibold text-xl">What is your current IELTS level?</h2>
|
||||||
|
<ModuleLevelSelector levels={levels} setLevels={setLevels} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center justify-center gap-8 w-full mb-44">
|
||||||
|
<h2 className="font-semibold text-xl">What is your desired IELTS level?</h2>
|
||||||
|
<ModuleLevelSelector levels={desiredLevels} setLevels={setDesiredLevels} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:self-end flex -md:flex-col justify-between w-full gap-8 absolute bottom-8 left-0 px-4 md:px-8">
|
||||||
|
<div className="w-full tooltip" data-tip="Your screen size is too small to perform a diagnostic test">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
className="group flex items-center justify-center gap-6 relative md:max-w-[400px] w-full md:hidden"
|
||||||
|
disabled>
|
||||||
|
<BsQuestionSquare className="text-mti-purple-light transition duration-300 ease-in-out" size={20} />
|
||||||
|
<span>Perform diagnostic test instead</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => updateUser(selectExam)}
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
className="group flex items-center justify-center gap-6 relative md:max-w-[400px] w-full -md:hidden"
|
||||||
|
disabled={!focus}>
|
||||||
|
<BsQuestionSquare
|
||||||
|
className="text-mti-purple-light group-hover:text-white transition duration-300 ease-in-out"
|
||||||
|
size={20}
|
||||||
|
onClick={() => updateUser(selectExam)}
|
||||||
|
/>
|
||||||
|
<span onClick={() => updateUser(selectExam)}>Perform diagnostic test instead</span>
|
||||||
|
</Button>
|
||||||
|
<Button color="purple" className="md:max-w-[400px] w-full" onClick={() => updateUser(onFinish)} disabled={isNextDisabled()}>
|
||||||
|
Next Step
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
84
src/components/Dropdown.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import React, { useState, ReactNode, useRef, useEffect } from 'react';
|
||||||
|
import { animated, useSpring } from '@react-spring/web';
|
||||||
|
|
||||||
|
interface DropdownProps {
|
||||||
|
title: ReactNode;
|
||||||
|
open?: boolean;
|
||||||
|
className?: string;
|
||||||
|
contentWrapperClassName?: string;
|
||||||
|
bottomPadding?: number;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Dropdown: React.FC<DropdownProps> = ({
|
||||||
|
title,
|
||||||
|
open = false,
|
||||||
|
className = "w-full text-left font-semibold flex justify-between items-center p-4",
|
||||||
|
contentWrapperClassName = "px-6",
|
||||||
|
bottomPadding = 12,
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState<boolean>(open);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [contentHeight, setContentHeight] = useState<number>(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
if (contentRef.current) {
|
||||||
|
resizeObserver = new ResizeObserver(entries => {
|
||||||
|
for (let entry of entries) {
|
||||||
|
if (entry.borderBoxSize && entry.borderBoxSize.length > 0) {
|
||||||
|
const height = entry.borderBoxSize[0].blockSize;
|
||||||
|
setContentHeight(height + bottomPadding);
|
||||||
|
} else {
|
||||||
|
// Fallback for browsers that don't support borderBoxSize
|
||||||
|
const height = entry.contentRect.height;
|
||||||
|
setContentHeight(height + bottomPadding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(contentRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [bottomPadding]);
|
||||||
|
|
||||||
|
const springProps = useSpring({
|
||||||
|
height: isOpen ? contentHeight : 0,
|
||||||
|
opacity: isOpen ? 1 : 0,
|
||||||
|
config: { tension: 300, friction: 30 }
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 transform transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<animated.div style={springProps} className="overflow-hidden">
|
||||||
|
<div ref={contentRef} className={contentWrapperClassName} style={{paddingBottom: bottomPadding}}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</animated.div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dropdown;
|
||||||
@@ -1,116 +1,134 @@
|
|||||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
|
||||||
import {FillBlanksExercise} from "@/interfaces/exam";
|
import {FillBlanksExercise} from "@/interfaces/exam";
|
||||||
import {Dialog, Transition} from "@headlessui/react";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
|
||||||
import Icon from "@mdi/react";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {Fragment, useState} from "react";
|
import {Fragment, useEffect, useState} from "react";
|
||||||
import reactStringReplace from "react-string-replace";
|
import reactStringReplace from "react-string-replace";
|
||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
|
import Button from "../Low/Button";
|
||||||
|
|
||||||
interface WordsPopoutProps {
|
interface WordsDrawerProps {
|
||||||
words: {word: string; isDisabled: boolean}[];
|
words: {word: string; isDisabled: boolean}[];
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
blankId?: string;
|
||||||
|
previouslySelectedWord?: string;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onAnswer: (answer: string) => void;
|
onAnswer: (answer: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function WordsPopout({words, isOpen, onCancel, onAnswer}: WordsPopoutProps) {
|
function WordsDrawer({words, isOpen, blankId, previouslySelectedWord, onCancel, onAnswer}: WordsDrawerProps) {
|
||||||
|
const [selectedWord, setSelectedWord] = useState<string | undefined>(previouslySelectedWord);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<>
|
||||||
<Dialog as="div" className="relative z-10" onClose={onCancel}>
|
<div
|
||||||
<Transition.Child
|
className={clsx(
|
||||||
as={Fragment}
|
"w-full h-full absolute top-0 left-0 bg-gradient-to-t from-mti-black to-transparent z-10",
|
||||||
enter="ease-out duration-300"
|
isOpen ? "visible opacity-10" : "invisible opacity-0",
|
||||||
enterFrom="opacity-0"
|
)}
|
||||||
enterTo="opacity-100"
|
/>
|
||||||
leave="ease-in duration-200"
|
<div
|
||||||
leaveFrom="opacity-100"
|
className={clsx(
|
||||||
leaveTo="opacity-0">
|
"absolute w-full bg-white px-7 py-8 bottom-0 left-0 shadow-2xl rounded-2xl z-20 flex flex-col gap-8 transition-opacity duration-300 ease-in-out",
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
isOpen ? "visible opacity-100" : "invisible opacity-0",
|
||||||
</Transition.Child>
|
)}>
|
||||||
|
<div className="w-full flex gap-2">
|
||||||
<div className="fixed inset-0 overflow-y-auto">
|
<div className="rounded-full w-6 h-6 flex items-center justify-center text-white bg-mti-purple-light">{blankId}</div>
|
||||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
<span> Choose the correct word:</span>
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0 scale-95"
|
|
||||||
enterTo="opacity-100 scale-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100 scale-100"
|
|
||||||
leaveTo="opacity-0 scale-95">
|
|
||||||
<Dialog.Panel className="w-fit transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all flex flex-col">
|
|
||||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
|
|
||||||
List of words
|
|
||||||
</Dialog.Title>
|
|
||||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{words.map((word) => (
|
|
||||||
<button
|
|
||||||
key={word.word}
|
|
||||||
onClick={() => onAnswer(word.word)}
|
|
||||||
disabled={word.isDisabled}
|
|
||||||
className={clsx("btn sm:btn-wide gap-4 relative text-white", infoButtonStyle)}>
|
|
||||||
{word.word}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 self-end">
|
|
||||||
<button onClick={onCancel} className={clsx("btn md:btn-wide gap-4 relative text-white", errorButtonStyle)}>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
<div className="grid grid-cols-6 gap-6" key="word-array">
|
||||||
</Transition>
|
{words.map(({word, isDisabled}) => (
|
||||||
|
<button
|
||||||
|
key={`${word}_${blankId}`}
|
||||||
|
onClick={() => setSelectedWord((prev) => (prev === word ? undefined : word))}
|
||||||
|
className={clsx(
|
||||||
|
"rounded-full py-3 text-center transition duration-300 ease-in-out",
|
||||||
|
selectedWord === word ? "text-white bg-mti-purple-light" : "bg-mti-purple-ultralight",
|
||||||
|
!isDisabled && "hover:text-white hover:bg-mti-purple",
|
||||||
|
"disabled:cursor-not-allowed disabled:text-mti-gray-dim",
|
||||||
|
)}
|
||||||
|
disabled={isDisabled}>
|
||||||
|
{word}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between w-full">
|
||||||
|
<Button color="purple" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button color="purple" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FillBlanks({id, allowRepetition, type, prompt, solutions, text, words, onNext, onBack}: FillBlanksExercise & CommonProps) {
|
export default function FillBlanks({
|
||||||
const [userSolutions, setUserSolutions] = useState<{id: string; solution: string}[]>([]);
|
id,
|
||||||
const [currentBlankId, setCurrentBlankId] = useState<string>();
|
allowRepetition,
|
||||||
|
type,
|
||||||
|
prompt,
|
||||||
|
solutions,
|
||||||
|
text,
|
||||||
|
words,
|
||||||
|
userSolutions,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
}: FillBlanksExercise & CommonProps) {
|
||||||
|
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
|
||||||
|
|
||||||
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = text.match(/({{\d+}})/g)?.length || 0;
|
const total = text.match(/({{\d+}})/g)?.length || 0;
|
||||||
const correct = userSolutions.filter((x) => solutions.find((y) => x.id === y.id)?.solution === x.solution.toLowerCase() || false).length;
|
const correct = answers.filter((x) => {
|
||||||
|
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution.toLowerCase();
|
||||||
|
if (!solution) return false;
|
||||||
|
|
||||||
return {total, correct};
|
const option = words.find((w) =>
|
||||||
|
typeof w === "string" ? w.toLowerCase() === x.solution.toLowerCase() : w.letter.toLowerCase() === x.solution.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (!option) return false;
|
||||||
|
|
||||||
|
return solution === (typeof option === "string" ? option.toLowerCase() : option.word.toLowerCase());
|
||||||
|
}).length;
|
||||||
|
const missing = total - answers.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
|
||||||
|
|
||||||
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderLines = (line: string) => {
|
const renderLines = (line: string) => {
|
||||||
return (
|
return (
|
||||||
<span>
|
<div className="text-base leading-5">
|
||||||
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
||||||
const id = match.replaceAll(/[\{\}]/g, "");
|
const id = match.replaceAll(/[\{\}]/g, "");
|
||||||
const userSolution = userSolutions.find((x) => x.id === id);
|
const userSolution = answers.find((x) => x.id === id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button className="border-2 rounded-xl px-4 text-blue-400 border-blue-400 my-2" onClick={() => setCurrentBlankId(id)}>
|
<input
|
||||||
{userSolution ? userSolution.solution : id}
|
className={clsx(
|
||||||
</button>
|
"rounded-full hover:text-white focus:ring-0 focus:outline-none focus:!text-white focus:bg-mti-purple transition duration-300 ease-in-out my-1 px-5 py-2 text-center",
|
||||||
|
!userSolution && "text-center text-mti-purple-light bg-mti-purple-ultralight",
|
||||||
|
userSolution && "px-5 py-2 text-center text-mti-purple-dark bg-mti-purple-ultralight",
|
||||||
|
)}
|
||||||
|
onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), {id, solution: e.target.value}])}
|
||||||
|
value={userSolution?.solution}></input>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</span>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||||
<WordsPopout
|
<span className="text-sm w-full leading-6">
|
||||||
words={words.map((word) => ({word, isDisabled: allowRepetition ? false : userSolutions.map((x) => x.solution).includes(word)}))}
|
|
||||||
isOpen={!!currentBlankId}
|
|
||||||
onCancel={() => setCurrentBlankId(undefined)}
|
|
||||||
onAnswer={(solution: string) => {
|
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.id !== currentBlankId), {id: currentBlankId!, solution}]);
|
|
||||||
setCurrentBlankId(undefined);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="text-base md:text-lg font-medium text-center px-2 md:px-4 lg:px-48">
|
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
{line}
|
{line}
|
||||||
@@ -118,31 +136,51 @@ export default function FillBlanks({id, allowRepetition, type, prompt, solutions
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
||||||
{text.split("\\n").map((line, index) => (
|
{text.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<p key={index}>
|
||||||
{renderLines(line)}
|
{renderLines(line)}
|
||||||
<br />
|
<br />
|
||||||
</Fragment>
|
</p>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
|
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-4">
|
||||||
|
<span className="font-medium text-mti-purple-dark">Options</span>
|
||||||
|
<div className="flex gap-4 flex-wrap">
|
||||||
|
{words.map((v) => {
|
||||||
|
const text = typeof v === "string" ? v : `${v.letter} - ${v.word}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"border border-mti-purple-light rounded-full px-3 py-0.5 transition ease-in-out duration-300",
|
||||||
|
!!answers.find((x) => x.solution.toLowerCase() === (typeof v === "string" ? v : v.letter).toLowerCase()) &&
|
||||||
|
"bg-mti-purple-dark text-white",
|
||||||
|
)}
|
||||||
|
key={text}>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
|
<Button
|
||||||
<div className="absolute left-4">
|
color="purple"
|
||||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
variant="outline"
|
||||||
</div>
|
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] w-full">
|
||||||
Back
|
Back
|
||||||
</button>
|
</Button>
|
||||||
<button
|
|
||||||
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
|
<Button
|
||||||
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}>
|
color="purple"
|
||||||
|
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
<div className="absolute right-4">
|
</Button>
|
||||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
297
src/components/Exercises/InteractiveSpeaking.tsx
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import {InteractiveSpeakingExercise} from "@/interfaces/exam";
|
||||||
|
import {CommonProps} from ".";
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill} from "react-icons/bs";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import Button from "../Low/Button";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import {downloadBlob} from "@/utils/evaluation";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||||
|
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function InteractiveSpeaking({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
first_title,
|
||||||
|
second_title,
|
||||||
|
examID,
|
||||||
|
type,
|
||||||
|
prompts,
|
||||||
|
userSolutions,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
}: InteractiveSpeakingExercise & CommonProps) {
|
||||||
|
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||||
|
const [answers, setAnswers] = useState<{prompt: string; blob: string; questionIndex: number}[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
|
||||||
|
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||||
|
|
||||||
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
|
const back = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const answer = await saveAnswer(questionIndex);
|
||||||
|
if (questionIndex - 1 >= 0) {
|
||||||
|
setQuestionIndex(questionIndex - 1);
|
||||||
|
setIsLoading(false);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
|
||||||
|
onBack({
|
||||||
|
exercise: id,
|
||||||
|
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||||
|
score: {correct: 100, total: 100, missing: 0},
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const answer = await saveAnswer(questionIndex);
|
||||||
|
if (questionIndex + 1 < prompts.length) {
|
||||||
|
setQuestionIndex(questionIndex + 1);
|
||||||
|
setIsLoading(false);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
setQuestionIndex(0);
|
||||||
|
|
||||||
|
onNext({
|
||||||
|
exercise: id,
|
||||||
|
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||||
|
score: {correct: 100, total: 100, missing: 0},
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (userSolutions.length > 0 && answers.length === 0) {
|
||||||
|
const solutions = userSolutions as unknown as typeof answers;
|
||||||
|
setAnswers(solutions);
|
||||||
|
|
||||||
|
if (!mediaBlob) setMediaBlob(solutions.find((x) => x.questionIndex === questionIndex)?.blob);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [userSolutions, mediaBlob, answers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasExamEnded) {
|
||||||
|
const answer = {
|
||||||
|
questionIndex,
|
||||||
|
prompt: prompts[questionIndex].text,
|
||||||
|
blob: mediaBlob!,
|
||||||
|
};
|
||||||
|
|
||||||
|
onNext({
|
||||||
|
exercise: id,
|
||||||
|
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||||
|
score: {correct: 100, total: 100, missing: 0},
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let recordingInterval: NodeJS.Timer | undefined = undefined;
|
||||||
|
if (isRecording) {
|
||||||
|
recordingInterval = setInterval(() => setRecordingDuration((prev) => prev + 1), 1000);
|
||||||
|
} else if (recordingInterval) {
|
||||||
|
clearInterval(recordingInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (recordingInterval) clearInterval(recordingInterval);
|
||||||
|
};
|
||||||
|
}, [isRecording]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (questionIndex <= answers.length - 1) {
|
||||||
|
const blob = answers.find((x) => x.questionIndex === questionIndex)?.blob;
|
||||||
|
setMediaBlob(blob);
|
||||||
|
}
|
||||||
|
}, [answers, questionIndex]);
|
||||||
|
|
||||||
|
const saveAnswer = async (index: number) => {
|
||||||
|
const answer = {
|
||||||
|
questionIndex,
|
||||||
|
prompt: prompts[questionIndex].text,
|
||||||
|
blob: mediaBlob!,
|
||||||
|
};
|
||||||
|
|
||||||
|
setAnswers((prev) => [...prev.filter((x) => x.questionIndex !== index), answer]);
|
||||||
|
setMediaBlob(undefined);
|
||||||
|
|
||||||
|
setUserSolutions([
|
||||||
|
...storeUserSolutions.filter((x) => x.exercise !== id),
|
||||||
|
{
|
||||||
|
exercise: id,
|
||||||
|
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||||
|
score: {correct: 100, total: 100, missing: 0},
|
||||||
|
module: "speaking",
|
||||||
|
exam: examID,
|
||||||
|
type,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return answer;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full w-full gap-9">
|
||||||
|
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<span className="font-semibold">{!!first_title && !!second_title ? `${first_title} & ${second_title}` : title}</span>
|
||||||
|
</div>
|
||||||
|
{prompts && prompts.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-4 w-full items-center">
|
||||||
|
<video key={questionIndex} autoPlay controls className="max-w-3xl rounded-xl">
|
||||||
|
<source src={prompts[questionIndex].video_url} />
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReactMediaRecorder
|
||||||
|
audio
|
||||||
|
key={questionIndex}
|
||||||
|
onStop={(blob) => setMediaBlob(blob)}
|
||||||
|
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => (
|
||||||
|
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
||||||
|
<p className="text-base font-normal">Record your answer:</p>
|
||||||
|
<div className="flex gap-8 items-center justify-center py-8">
|
||||||
|
{status === "idle" && (
|
||||||
|
<>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
{status === "idle" && (
|
||||||
|
<BsMicFill
|
||||||
|
onClick={() => {
|
||||||
|
setRecordingDuration(0);
|
||||||
|
startRecording();
|
||||||
|
setIsRecording(true);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "recording" && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<span className="text-xs w-9">
|
||||||
|
{Math.floor(recordingDuration / 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
:
|
||||||
|
{Math.floor(recordingDuration % 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsPauseCircle
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
pauseRecording();
|
||||||
|
}}
|
||||||
|
className="text-red-500 w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<BsCheckCircleFill
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
stopRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "paused" && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<span className="text-xs w-9">
|
||||||
|
{Math.floor(recordingDuration / 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
:
|
||||||
|
{Math.floor(recordingDuration % 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsPlayCircle
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(true);
|
||||||
|
resumeRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<BsCheckCircleFill
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
stopRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "stopped" && mediaBlobUrl && (
|
||||||
|
<>
|
||||||
|
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsTrashFill
|
||||||
|
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||||
|
onClick={() => {
|
||||||
|
setRecordingDuration(0);
|
||||||
|
clearBlobUrl();
|
||||||
|
setMediaBlob(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BsMicFill
|
||||||
|
onClick={() => {
|
||||||
|
clearBlobUrl();
|
||||||
|
setRecordingDuration(0);
|
||||||
|
startRecording();
|
||||||
|
setIsRecording(true);
|
||||||
|
setMediaBlob(undefined);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="self-end flex justify-between w-full gap-8">
|
||||||
|
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button color="purple" disabled={!mediaBlob} isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
|
||||||
|
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,37 +1,101 @@
|
|||||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
||||||
import {MatchSentencesExercise} from "@/interfaces/exam";
|
import {MatchSentenceExerciseOption, MatchSentenceExerciseSentence, MatchSentencesExercise} from "@/interfaces/exam";
|
||||||
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
||||||
import Icon from "@mdi/react";
|
import Icon from "@mdi/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {Fragment, useState} from "react";
|
import {Fragment, useEffect, useState} from "react";
|
||||||
import LineTo from "react-lineto";
|
import LineTo from "react-lineto";
|
||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
|
import Button from "../Low/Button";
|
||||||
|
import Xarrow from "react-xarrows";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import {DndContext, DragEndEvent, useDraggable, useDroppable} from "@dnd-kit/core";
|
||||||
|
|
||||||
export default function MatchSentences({id, options, type, prompt, sentences, onNext, onBack}: MatchSentencesExercise & CommonProps) {
|
function DroppableQuestionArea({question, answer}: {question: MatchSentenceExerciseSentence; answer?: string}) {
|
||||||
const [selectedQuestion, setSelectedQuestion] = useState<string>();
|
const {isOver, setNodeRef} = useDroppable({id: `droppable_sentence_${question.id}`});
|
||||||
const [userSolutions, setUserSolutions] = useState<{question: string; option: string}[]>([]);
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-4" ref={setNodeRef}>
|
||||||
|
<div className="flex items-center gap-3 cursor-pointer col-span-2">
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
)}>
|
||||||
|
{question.id}
|
||||||
|
</button>
|
||||||
|
<span>{question.sentence}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
key={`answer_${question.id}_${answer}`}
|
||||||
|
className={clsx("w-48 h-10 border rounded-xl flex items-center justify-center", isOver && "border-mti-purple-light")}>
|
||||||
|
{answer && `Paragraph ${answer}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DraggableOptionArea({option}: {option: MatchSentenceExerciseOption}) {
|
||||||
|
const {attributes, listeners, setNodeRef, transform} = useDraggable({
|
||||||
|
id: `draggable_option_${option.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = transform
|
||||||
|
? {
|
||||||
|
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||||
|
zIndex: 99,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx("flex items-center justify-start gap-6 cursor-pointer")} ref={setNodeRef} style={style} {...listeners} {...attributes}>
|
||||||
|
<button
|
||||||
|
id={`option_${option.id}`}
|
||||||
|
// onClick={() => selectOption(id)}
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple px-3 py-2 rounded-full z-10",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
option.id,
|
||||||
|
)}>
|
||||||
|
Paragraph {option.id}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MatchSentences({id, options, type, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) {
|
||||||
|
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
||||||
|
|
||||||
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
if (event.over && event.over.id.toString().startsWith("droppable")) {
|
||||||
|
const optionID = event.active.id.toString().replace("draggable_option_", "");
|
||||||
|
const sentenceID = event.over.id.toString().replace("droppable_sentence_", "");
|
||||||
|
|
||||||
|
setAnswers((prev) => [...prev.filter((x) => x.question.toString() !== sentenceID), {question: sentenceID, option: optionID}]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = sentences.length;
|
const total = sentences.length;
|
||||||
const correct = userSolutions.filter((x) => sentences.find((y) => y.id === x.question)?.solution === x.option || false).length;
|
const correct = answers.filter(
|
||||||
|
(x) => sentences.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false,
|
||||||
|
).length;
|
||||||
|
const missing = total - answers.filter((x) => sentences.find((y) => y.id.toString() === x.question.toString())).length;
|
||||||
|
|
||||||
return {total, correct};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectOption = (option: string) => {
|
useEffect(() => {
|
||||||
if (!selectedQuestion) return;
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.question !== selectedQuestion), {question: selectedQuestion, option}]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
setSelectedQuestion(undefined);
|
}, [hasExamEnded]);
|
||||||
};
|
|
||||||
|
|
||||||
const getSentenceColor = (id: string) => {
|
|
||||||
return sentences.find((x) => x.id === id)?.color || "";
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col items-center gap-8">
|
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||||
<span className="text-base md:text-lg font-medium text-center px-2 md:px-4 lg:px-48">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
{line}
|
{line}
|
||||||
@@ -39,74 +103,45 @@ export default function MatchSentences({id, options, type, prompt, sentences, on
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
<div className="grid grid-cols-2 gap-16 place-items-center">
|
|
||||||
<div className="flex flex-col gap-1">
|
<DndContext onDragEnd={handleDragEnd}>
|
||||||
{sentences.map(({sentence, id, color}) => (
|
<div className="flex flex-col gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
|
||||||
<div
|
<div className="flex flex-col gap-4">
|
||||||
key={`question_${id}`}
|
{sentences.map((question) => (
|
||||||
className="flex items-center justify-end gap-2 cursor-pointer"
|
<DroppableQuestionArea
|
||||||
onClick={() => setSelectedQuestion((prev) => (prev === id ? undefined : id))}>
|
key={`question_${question.id}`}
|
||||||
<span>
|
question={question}
|
||||||
<span className="font-semibold">{id}.</span> {sentence}{" "}
|
answer={answers.find((x) => x.question.toString() === question.id.toString())?.option}
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
style={{borderColor: color, backgroundColor: selectedQuestion === id ? color : "transparent"}}
|
|
||||||
className={clsx("border-2 border-blue-500 w-4 h-4 rounded-full", id)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
))}
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{options.map(({sentence, id}) => (
|
|
||||||
<div
|
|
||||||
key={`answer_${id}`}
|
|
||||||
className={clsx("flex items-center justify-start gap-2 cursor-pointer")}
|
|
||||||
onClick={() => selectOption(id)}>
|
|
||||||
<div
|
|
||||||
style={
|
|
||||||
userSolutions.find((x) => x.option === id)
|
|
||||||
? {
|
|
||||||
border: `2px solid ${getSentenceColor(userSolutions.find((x) => x.option === id)!.question)}`,
|
|
||||||
}
|
|
||||||
: {}
|
|
||||||
}
|
|
||||||
className={clsx("border-2 border-green-500 bg-transparent w-4 h-4 rounded-full", id)}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<span className="font-semibold">{id}.</span> {sentence}{" "}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{userSolutions.map((solution, index) => (
|
|
||||||
<div key={`solution_${index}`} className="absolute">
|
|
||||||
<LineTo
|
|
||||||
className="rounded-full"
|
|
||||||
from={solution.question}
|
|
||||||
to={solution.option}
|
|
||||||
borderColor={sentences.find((x) => x.id === solution.question)!.color}
|
|
||||||
borderWidth={5}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="flex flex-col gap-4">
|
||||||
</div>
|
<span>Drag one of these paragraphs into the slots above:</span>
|
||||||
|
<div className="flex gap-4 flex-wrap justify-center items-center max-w-lg">
|
||||||
|
{options.map((option) => (
|
||||||
|
<DraggableOptionArea key={`answer_${option.id}`} option={option} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DndContext>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
|
<Button
|
||||||
<div className="absolute left-4">
|
color="purple"
|
||||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
variant="outline"
|
||||||
</div>
|
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] w-full">
|
||||||
Back
|
Back
|
||||||
</button>
|
</Button>
|
||||||
<button
|
|
||||||
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
|
<Button
|
||||||
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}>
|
color="purple"
|
||||||
|
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
<div className="absolute right-4">
|
</Button>
|
||||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,80 +1,62 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
|
||||||
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
|
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
|
||||||
import {mdiArrowLeft, mdiArrowRight, mdiCheck, mdiClose} from "@mdi/js";
|
import useExamStore from "@/stores/examStore";
|
||||||
import Icon from "@mdi/react";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
|
import reactStringReplace from "react-string-replace";
|
||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
|
import Button from "../Low/Button";
|
||||||
|
|
||||||
function Question({
|
function Question({
|
||||||
|
id,
|
||||||
variant,
|
variant,
|
||||||
prompt,
|
prompt,
|
||||||
solution,
|
|
||||||
options,
|
options,
|
||||||
userSolution,
|
userSolution,
|
||||||
onSelectOption,
|
onSelectOption,
|
||||||
showSolution = false,
|
|
||||||
}: 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 renderPrompt = (prompt: string) => {
|
||||||
if (!showSolution) {
|
return reactStringReplace(prompt, /((<u>)[\w\s']+(<\/u>))/g, (match) => {
|
||||||
return userSolution === option ? "border-blue-400" : "";
|
const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
|
||||||
}
|
return word.length > 0 ? <u>{word}</u> : null;
|
||||||
|
});
|
||||||
if (option === solution) {
|
|
||||||
return "border-green-500 text-green-500";
|
|
||||||
}
|
|
||||||
|
|
||||||
return userSolution === option ? "border-red-500 text-red-500" : "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const optionBadge = (option: string) => {
|
|
||||||
if (option === userSolution) {
|
|
||||||
if (solution === option) {
|
|
||||||
return (
|
|
||||||
<div className="badge badge-lg bg-green-500 border-green-500 absolute -top-2 -right-4">
|
|
||||||
<div className="tooltip" data-tip="You have correctly answered!">
|
|
||||||
<Icon path={mdiCheck} color="white" size={0.8} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="badge badge-lg bg-red-500 border-red-500 absolute -top-2 -right-4">
|
|
||||||
<div className="tooltip" data-tip="You have wrongly answered!">
|
|
||||||
<Icon path={mdiClose} color="white" size={0.8} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col gap-10">
|
||||||
<span className="text-center">{prompt}</span>
|
{isNaN(Number(id)) ? (
|
||||||
<div className="grid grid-rows-4 md:grid-rows-1 md:grid-cols-4 gap-4 place-items-center">
|
<span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
||||||
|
) : (
|
||||||
|
<span className="">
|
||||||
|
<>
|
||||||
|
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
||||||
|
</>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-4 justify-between">
|
||||||
{variant === "image" &&
|
{variant === "image" &&
|
||||||
options.map((option) => (
|
options.map((option) => (
|
||||||
<div
|
<div
|
||||||
key={option.id}
|
key={option.id.toString()}
|
||||||
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
|
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex flex-col items-center border-2 p-4 rounded-xl gap-4 cursor-pointer bg-white relative",
|
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative",
|
||||||
optionColor(option.id),
|
userSolution === option.id.toString() && "border-mti-purple-light",
|
||||||
)}>
|
)}>
|
||||||
{showSolution && optionBadge(option.id)}
|
<span className={clsx("text-sm", userSolution !== option.id.toString() && "opacity-50")}>{option.id.toString()}</span>
|
||||||
<img src={option.src!} alt={`Option ${option.id}`} />
|
<img src={option.src!} alt={`Option ${option.id.toString()}`} />
|
||||||
<span>{option.id}</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{variant === "text" &&
|
{variant === "text" &&
|
||||||
options.map((option) => (
|
options.map((option) => (
|
||||||
<div
|
<div
|
||||||
key={option.id}
|
key={option.id.toString()}
|
||||||
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
|
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
|
||||||
className={clsx("flex border-2 p-4 rounded-xl gap-2 cursor-pointer bg-white", optionColor(option.id))}>
|
className={clsx(
|
||||||
<span className="font-bold">{option.id}.</span>
|
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm",
|
||||||
|
userSolution === option.id.toString() && "border-mti-purple-light",
|
||||||
|
)}>
|
||||||
|
<span className="font-semibold">{option.id.toString()}.</span>
|
||||||
<span>{option.text}</span>
|
<span>{option.text}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -83,64 +65,81 @@ function Question({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MultipleChoice({id, prompt, type, questions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
|
export default function MultipleChoice({id, prompt, type, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
|
||||||
const [userSolutions, setUserSolutions] = useState<{question: string; option: string}[]>([]);
|
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
||||||
const [questionIndex, setQuestionIndex] = useState(0);
|
|
||||||
|
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
|
||||||
|
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||||
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
|
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUserSolutions([...storeUserSolutions.filter((x) => x.exercise !== id), {exercise: id, solutions: answers, score: calculateScore(), type}]);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [answers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
const onSelectOption = (option: string) => {
|
const onSelectOption = (option: string) => {
|
||||||
const question = questions[questionIndex];
|
const question = questions[questionIndex];
|
||||||
setUserSolutions((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}]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = questions.length;
|
const total = questions.length;
|
||||||
const correct = userSolutions.filter((x) => questions.find((y) => y.id === x.question)?.solution === x.option || false).length;
|
const correct = answers.filter(
|
||||||
|
(x) => questions.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false,
|
||||||
|
).length;
|
||||||
|
const missing = total - answers.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length;
|
||||||
|
|
||||||
return {total, correct};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
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: 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();
|
onBack({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
} else {
|
} else {
|
||||||
setQuestionIndex((prev) => prev - 1);
|
setQuestionIndex(questionIndex - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scrollToTop();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col gap-2 mt-4 h-fit w-full mb-20 bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||||
<span className="text-base md:text-lg font-medium text-center px-2 md:px-4 lg:px-48">{prompt}</span>
|
<span className="text-xl font-semibold">{prompt}</span>
|
||||||
{questionIndex < questions.length && (
|
{questionIndex < questions.length && (
|
||||||
<Question
|
<Question
|
||||||
{...questions[questionIndex]}
|
{...questions[questionIndex]}
|
||||||
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option}
|
userSolution={answers.find((x) => questions[questionIndex].id === x.question)?.option}
|
||||||
onSelectOption={onSelectOption}
|
onSelectOption={onSelectOption}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={back}>
|
<Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full">
|
||||||
<div className="absolute left-4">
|
|
||||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
|
||||||
</div>
|
|
||||||
Back
|
Back
|
||||||
</button>
|
</Button>
|
||||||
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={next}>
|
|
||||||
|
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
<div className="absolute right-4">
|
</Button>
|
||||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,52 +1,303 @@
|
|||||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
import { SpeakingExercise } from "@/interfaces/exam";
|
||||||
import {SpeakingExercise, WritingExercise} from "@/interfaces/exam";
|
import { CommonProps } from ".";
|
||||||
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
import { Fragment, useEffect, useState } from "react";
|
||||||
import Icon from "@mdi/react";
|
import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs";
|
||||||
import clsx from "clsx";
|
import dynamic from "next/dynamic";
|
||||||
import {CommonProps} from ".";
|
import Button from "../Low/Button";
|
||||||
import {Fragment, useEffect, useState} from "react";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {toast} from "react-toastify";
|
import { downloadBlob } from "@/utils/evaluation";
|
||||||
|
import axios from "axios";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
|
||||||
|
const Waveform = dynamic(() => import("../Waveform"), { ssr: false });
|
||||||
|
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function Speaking({ id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack }: SpeakingExercise & CommonProps) {
|
||||||
|
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||||
|
const [audioURL, setAudioURL] = useState<string>();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isPromptsModalOpen, setIsPromptsModalOpen] = useState(false);
|
||||||
|
const [inputText, setInputText] = useState("");
|
||||||
|
|
||||||
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
|
const saveToStorage = async () => {
|
||||||
|
if (mediaBlob && mediaBlob.startsWith("blob")) {
|
||||||
|
const blobBuffer = await downloadBlob(mediaBlob);
|
||||||
|
const audioFile = new File([blobBuffer], "audio.wav", { type: "audio/wav" });
|
||||||
|
|
||||||
|
const seed = Math.random().toString().replace("0.", "");
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("audio", audioFile, `${seed}.wav`);
|
||||||
|
formData.append("root", "speaking_recordings");
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "audio/wav",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await axios.post<{ path: string }>("/api/storage/insert", formData, config);
|
||||||
|
if (audioURL) await axios.post("/api/storage/delete", { path: audioURL });
|
||||||
|
return response.data.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (userSolutions.length > 0) {
|
||||||
|
const { solution } = userSolutions[0] as { solution?: string };
|
||||||
|
if (solution && !mediaBlob) setMediaBlob(solution);
|
||||||
|
if (solution && !solution.startsWith("blob")) setAudioURL(solution);
|
||||||
|
}
|
||||||
|
}, [userSolutions, mediaBlob]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasExamEnded) next();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let recordingInterval: NodeJS.Timer | undefined = undefined;
|
||||||
|
if (isRecording) {
|
||||||
|
recordingInterval = setInterval(() => setRecordingDuration((prev) => prev + 1), 1000);
|
||||||
|
} else if (recordingInterval) {
|
||||||
|
clearInterval(recordingInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (recordingInterval) clearInterval(recordingInterval);
|
||||||
|
};
|
||||||
|
}, [isRecording]);
|
||||||
|
|
||||||
|
const next = async () => {
|
||||||
|
onNext({
|
||||||
|
exercise: id,
|
||||||
|
solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [],
|
||||||
|
score: { correct: 0, total: 100, missing: 0 },
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const back = async () => {
|
||||||
|
onBack({
|
||||||
|
exercise: id,
|
||||||
|
solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [],
|
||||||
|
score: { correct: 0, total: 100, missing: 0 },
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNoteWriting = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const newText = e.target.value;
|
||||||
|
const words = newText.match(/\S+/g);
|
||||||
|
const wordCount = words ? words.length : 0;
|
||||||
|
|
||||||
|
if (wordCount <= 100) {
|
||||||
|
setInputText(newText);
|
||||||
|
} else {
|
||||||
|
let count = 0;
|
||||||
|
let lastIndex = 0;
|
||||||
|
const matches = newText.matchAll(/\S+/g);
|
||||||
|
for (const match of matches) {
|
||||||
|
count++;
|
||||||
|
if (count > 100) break;
|
||||||
|
lastIndex = match.index! + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInputText(newText.slice(0, lastIndex));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default function Speaking({id, title, text, type, prompts, onNext, onBack}: SpeakingExercise & CommonProps) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-2/3 items-center justify-center gap-8">
|
<div className="flex flex-col h-full w-full gap-9">
|
||||||
<div className="flex flex-col max-w-2xl gap-4">
|
<Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}>
|
||||||
<span className="font-bold">{title}</span>
|
<div className="flex flex-col items-center justify-center gap-4 w-full h-full">
|
||||||
<span className="font-regular ml-8">
|
<div className="flex flex-col gap-1 ml-4">
|
||||||
{text.split("\\n").map((line, index) => (
|
|
||||||
<Fragment key={index}>
|
|
||||||
<span>{line}</span>
|
|
||||||
<br />
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-8">
|
|
||||||
<span>You should talk about the following things:</span>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{prompts.map((x, index) => (
|
{prompts.map((x, index) => (
|
||||||
<li className="italic" key={index}>
|
<li className="italic" key={index}>
|
||||||
{x}
|
{x}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{!!suffix && <span className="font-bold">{suffix}</span>}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
<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-0">
|
||||||
|
<span className="font-semibold">{title}</span>
|
||||||
|
{prompts.length > 0 && (
|
||||||
|
<span className="font-semibold">You should talk for at least 1 minute and 30 seconds for your answer to be valid.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!video_url && (
|
||||||
|
<span className="font-regular">
|
||||||
|
{text.split("\\n").map((line, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
<span>{line}</span>
|
||||||
|
<br />
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-6 items-center">
|
||||||
|
{video_url && (
|
||||||
|
<div className="flex flex-col gap-4 w-full items-center">
|
||||||
|
<video key={id} autoPlay controls className="max-w-3xl rounded-xl">
|
||||||
|
<source src={video_url} />
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{prompts && prompts.length > 0 && <Button onClick={() => setIsPromptsModalOpen(true)}>View Prompts</Button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
|
{prompts && prompts.length > 0 && (
|
||||||
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
|
<div className="w-full h-full flex flex-col gap-4">
|
||||||
<div className="absolute left-4">
|
<textarea
|
||||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
className="w-full h-full min-h-[200px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
|
||||||
|
onChange={handleNoteWriting}
|
||||||
|
value={inputText}
|
||||||
|
placeholder="Write your notes here..."
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<span className="text-base self-end text-mti-gray-cool">Word Count: {(inputText.match(/\S+/g) || []).length}/100</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ReactMediaRecorder
|
||||||
|
audio
|
||||||
|
onStop={(blob) => setMediaBlob(blob)}
|
||||||
|
render={({ status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl }) => (
|
||||||
|
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
||||||
|
<p className="text-base font-normal">Record your answer:</p>
|
||||||
|
<div className="flex gap-8 items-center justify-center py-8">
|
||||||
|
{status === "idle" && !mediaBlob && (
|
||||||
|
<>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
{status === "idle" && (
|
||||||
|
<BsMicFill
|
||||||
|
onClick={() => {
|
||||||
|
setRecordingDuration(0);
|
||||||
|
startRecording();
|
||||||
|
setIsRecording(true);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "recording" && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<span className="text-xs w-9">
|
||||||
|
{Math.floor(recordingDuration / 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
:
|
||||||
|
{Math.floor(recordingDuration % 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsPauseCircle
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
pauseRecording();
|
||||||
|
}}
|
||||||
|
className="text-red-500 w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<BsCheckCircleFill
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
stopRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "paused" && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<span className="text-xs w-9">
|
||||||
|
{Math.floor(recordingDuration / 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
:
|
||||||
|
{Math.floor(recordingDuration % 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsPlayCircle
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(true);
|
||||||
|
resumeRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<BsCheckCircleFill
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
stopRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && (
|
||||||
|
<>
|
||||||
|
<Waveform audio={mediaBlobUrl ? mediaBlobUrl : mediaBlob!} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsTrashFill
|
||||||
|
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||||
|
onClick={() => {
|
||||||
|
setRecordingDuration(0);
|
||||||
|
clearBlobUrl();
|
||||||
|
setMediaBlob(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BsMicFill
|
||||||
|
onClick={() => {
|
||||||
|
clearBlobUrl();
|
||||||
|
setRecordingDuration(0);
|
||||||
|
startRecording();
|
||||||
|
setIsRecording(true);
|
||||||
|
setMediaBlob(undefined);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="self-end flex justify-between w-full gap-8">
|
||||||
|
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} 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">
|
||||||
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
|
|
||||||
onClick={() => onNext({exercise: id, solutions: [], score: {correct: 1, total: 1}, type})}>
|
|
||||||
Next
|
Next
|
||||||
<div className="absolute right-4">
|
</Button>
|
||||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
121
src/components/Exercises/TrueFalse.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import {TrueFalseExercise} from "@/interfaces/exam";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import {Fragment, useEffect, useState} from "react";
|
||||||
|
import {CommonProps} from ".";
|
||||||
|
import Button from "../Low/Button";
|
||||||
|
|
||||||
|
export default function TrueFalse({id, type, prompt, questions, userSolutions, onNext, onBack}: TrueFalseExercise & CommonProps) {
|
||||||
|
const [answers, setAnswers] = useState<{id: string; solution: "true" | "false" | "not_given"}[]>(userSolutions);
|
||||||
|
|
||||||
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
|
const calculateScore = () => {
|
||||||
|
const total = questions.length || 0;
|
||||||
|
const correct = answers.filter(
|
||||||
|
(x) =>
|
||||||
|
questions
|
||||||
|
.find((y) => x.id.toString() === y.id.toString())
|
||||||
|
?.solution?.toString()
|
||||||
|
.toLowerCase() === x.solution.toLowerCase() || false,
|
||||||
|
).length;
|
||||||
|
const missing = total - answers.filter((x) => questions.find((y) => x.id.toString() === y.id.toString())).length;
|
||||||
|
|
||||||
|
return {total, correct, missing};
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAnswer = (solution: "true" | "false" | "not_given", questionId: string) => {
|
||||||
|
const answer = answers.find((x) => x.id === questionId);
|
||||||
|
if (answer && answer.solution === solution) {
|
||||||
|
setAnswers((prev) => prev.filter((x) => x.id !== questionId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAnswers((prev) => [...prev.filter((x) => x.id !== questionId), {id: questionId, solution}]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||||
|
<span className="text-sm w-full leading-6">
|
||||||
|
{prompt.split("\\n").map((line, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
{line}
|
||||||
|
<br />
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-col gap-6 mb-4">
|
||||||
|
<p>For each of the questions below, select</p>
|
||||||
|
<div className="pl-8 flex gap-8">
|
||||||
|
<span className="flex flex-col gap-4">
|
||||||
|
<span className="font-bold italic">TRUE</span>
|
||||||
|
<span className="font-bold italic">FALSE</span>
|
||||||
|
<span className="font-bold italic">NOT GIVEN</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex flex-col gap-4">
|
||||||
|
<span>if the statement agrees with the information</span>
|
||||||
|
<span>if the statement contradicts with the information</span>
|
||||||
|
<span>if there is no information on this</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
{questions.map((question, index) => {
|
||||||
|
const id = question.id.toString();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={question.id.toString()} className="flex flex-col gap-4">
|
||||||
|
<span>
|
||||||
|
{index + 1}. {question.prompt}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
variant={answers.find((x) => x.id.toString() === id)?.solution === "true" ? "solid" : "outline"}
|
||||||
|
onClick={() => toggleAnswer("true", id)}
|
||||||
|
className="!py-2">
|
||||||
|
True
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={answers.find((x) => x.id.toString() === id)?.solution === "false" ? "solid" : "outline"}
|
||||||
|
onClick={() => toggleAnswer("false", id)}
|
||||||
|
className="!py-2">
|
||||||
|
False
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={answers.find((x) => x.id.toString() === id)?.solution === "not_given" ? "solid" : "outline"}
|
||||||
|
onClick={() => toggleAnswer("not_given", id)}
|
||||||
|
className="!py-2">
|
||||||
|
Not Given
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] w-full">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,14 +3,17 @@ import {WriteBlanksExercise} from "@/interfaces/exam";
|
|||||||
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
||||||
import Icon from "@mdi/react";
|
import Icon from "@mdi/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useEffect, useState} from "react";
|
import {Fragment, useEffect, useState} from "react";
|
||||||
import reactStringReplace from "react-string-replace";
|
import reactStringReplace from "react-string-replace";
|
||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
|
import Button from "../Low/Button";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
|
||||||
function Blank({
|
function Blank({
|
||||||
id,
|
id,
|
||||||
maxWords,
|
maxWords,
|
||||||
|
userSolution,
|
||||||
showSolutions = false,
|
showSolutions = false,
|
||||||
setUserSolution,
|
setUserSolution,
|
||||||
}: {
|
}: {
|
||||||
@@ -19,13 +22,13 @@ function Blank({
|
|||||||
userSolution?: string;
|
userSolution?: string;
|
||||||
maxWords: number;
|
maxWords: number;
|
||||||
showSolutions?: boolean;
|
showSolutions?: boolean;
|
||||||
setUserSolution?: (solution: string) => void;
|
setUserSolution: (solution: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [userInput, setUserInput] = useState("");
|
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());
|
||||||
}
|
}
|
||||||
@@ -33,39 +36,48 @@ function Blank({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
className={clsx("input border rounded-xl px-2 py-1 bg-white text-blue-400 border-blue-400 my-2")}
|
className="py-2 px-3 mx-2 rounded-2xl w-48 bg-white focus:outline-none my-2"
|
||||||
placeholder={id}
|
placeholder={id}
|
||||||
onChange={(e) => setUserInput(e.target.value)}
|
onChange={(e) => setUserInput(e.target.value)}
|
||||||
|
onBlur={() => setUserSolution(userInput)}
|
||||||
value={userInput}
|
value={userInput}
|
||||||
contentEditable={showSolutions}
|
contentEditable={showSolutions}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WriteBlanks({id, prompt, type, maxWords, solutions, text, onNext, onBack}: WriteBlanksExercise & CommonProps) {
|
export default function WriteBlanks({id, prompt, type, maxWords, solutions, userSolutions, text, onNext, onBack}: WriteBlanksExercise & CommonProps) {
|
||||||
const [userSolutions, setUserSolutions] = useState<{id: string; solution: string}[]>([]);
|
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
|
||||||
|
|
||||||
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = text.match(/({{\d+}})/g)?.length || 0;
|
const total = text.match(/({{\d+}})/g)?.length || 0;
|
||||||
const correct = userSolutions.filter(
|
const correct = answers.filter(
|
||||||
(x) =>
|
(x) =>
|
||||||
solutions
|
solutions
|
||||||
.find((y) => x.id === y.id)
|
.find((y) => x.id.toString() === y.id.toString())
|
||||||
?.solution.map((y) => y.toLowerCase())
|
?.solution.map((y) => y.toLowerCase().trim())
|
||||||
.includes(x.solution.toLowerCase()) || false,
|
.includes(x.solution.toLowerCase().trim()) || false,
|
||||||
).length;
|
).length;
|
||||||
|
const missing = total - answers.filter((x) => solutions.find((y) => x.id === y.id)).length;
|
||||||
|
|
||||||
return {total, correct};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderLines = (line: string) => {
|
const renderLines = (line: string) => {
|
||||||
return (
|
return (
|
||||||
<span>
|
<span className="text-base leading-5">
|
||||||
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
||||||
const id = match.replaceAll(/[\{\}]/g, "");
|
const id = match.replaceAll(/[\{\}]/g, "");
|
||||||
const userSolution = userSolutions.find((x) => x.id === id);
|
const userSolution = answers.find((x) => x.id === id);
|
||||||
const setUserSolution = (solution: string) => {
|
const setUserSolution = (solution: string) => {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.id !== id), {id, solution}]);
|
setAnswers((prev) => [...prev.filter((x) => x.id !== id), {id, solution}]);
|
||||||
};
|
};
|
||||||
|
|
||||||
return <Blank userSolution={userSolution?.solution} maxWords={maxWords} id={id} setUserSolution={setUserSolution} />;
|
return <Blank userSolution={userSolution?.solution} maxWords={maxWords} id={id} setUserSolution={setUserSolution} />;
|
||||||
@@ -76,33 +88,40 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, text
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||||
<span className="text-lg font-medium text-center px-48">{prompt}</span>
|
<span className="text-sm w-full leading-6">
|
||||||
<span>
|
{prompt.split("\\n").map((line, index) => (
|
||||||
{text.split("\\n").map((line) => (
|
<span key={index}>
|
||||||
<>
|
{line}
|
||||||
|
<br />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
||||||
|
{text.split("\\n").map((line, index) => (
|
||||||
|
<p key={index}>
|
||||||
{renderLines(line)}
|
{renderLines(line)}
|
||||||
<br />
|
<br />
|
||||||
</>
|
</p>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
|
<Button
|
||||||
<div className="absolute left-4">
|
color="purple"
|
||||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
variant="outline"
|
||||||
</div>
|
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] w-full">
|
||||||
Back
|
Back
|
||||||
</button>
|
</Button>
|
||||||
<button
|
|
||||||
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
|
<Button
|
||||||
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}>
|
color="purple"
|
||||||
|
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
<div className="absolute right-4">
|
</Button>
|
||||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,73 @@
|
|||||||
/* 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 React, {Fragment, useEffect, useRef, useState} from "react";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
|
import Button from "../Low/Button";
|
||||||
|
import {Dialog, Transition} from "@headlessui/react";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
|
||||||
export default function Writing({id, prompt, info, type, wordCounter, attachment, onNext, onBack}: WritingExercise & CommonProps) {
|
export default function Writing({
|
||||||
const [inputText, setInputText] = useState("");
|
id,
|
||||||
|
prompt,
|
||||||
|
prefix,
|
||||||
|
suffix,
|
||||||
|
type,
|
||||||
|
wordCounter,
|
||||||
|
attachment,
|
||||||
|
userSolutions,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
}: WritingExercise & CommonProps) {
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saveTimerInterval = setInterval(() => {
|
||||||
|
setSaveTimer((prev) => prev + 1);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(saveTimerInterval);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inputText.length > 0 && saveTimer % 10 === 0) {
|
||||||
|
setUserSolutions([
|
||||||
|
...storeUserSolutions.filter((x) => x.exercise !== id),
|
||||||
|
{exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type, module: "writing"},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [saveTimer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (localStorage.getItem("enable_paste")) return;
|
||||||
|
|
||||||
|
const listener = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "v") {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", listener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", listener);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasExamEnded)
|
||||||
|
onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type, module: "writing"});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const words = inputText.split(" ").filter((x) => x !== "");
|
const words = inputText.split(" ").filter((x) => x !== "");
|
||||||
@@ -27,59 +84,92 @@ export default function Writing({id, prompt, info, type, wordCounter, attachment
|
|||||||
}, [inputText, wordCounter]);
|
}, [inputText, wordCounter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-2/3 items-center justify-center gap-8">
|
<>
|
||||||
<div className="flex flex-col max-w-2xl gap-2">
|
{attachment && (
|
||||||
<span>{info}</span>
|
<Transition show={isModalOpen} as={Fragment}>
|
||||||
<span className="font-bold ml-8">
|
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
<Transition.Child
|
||||||
<Fragment key={index}>
|
as={Fragment}
|
||||||
<span>{line}</span>
|
enter="ease-out duration-300"
|
||||||
<br />
|
enterFrom="opacity-0"
|
||||||
</Fragment>
|
enterTo="opacity-100"
|
||||||
))}
|
leave="ease-in duration-200"
|
||||||
</span>
|
leaveFrom="opacity-100"
|
||||||
<span>
|
leaveTo="opacity-0">
|
||||||
You should write {wordCounter.type === "min" ? "at least" : "at most"} {wordCounter.limit} words.
|
<div className="fixed inset-0 bg-black/30" />
|
||||||
</span>
|
</Transition.Child>
|
||||||
{attachment && <img src={attachment} alt="Exercise attachment" />}
|
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95">
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||||
|
<Dialog.Panel className="w-fit h-fit rounded-xl bg-white">
|
||||||
|
<img src={attachment.url} alt={attachment.description} className="max-w-4xl w-full self-center rounded-xl p-4" />
|
||||||
|
</Dialog.Panel>
|
||||||
|
</div>
|
||||||
|
</Transition.Child>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
)}
|
||||||
|
<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">
|
||||||
|
<span className="whitespace-pre-wrap">{prefix.replaceAll("\\n", "\n")}</span>
|
||||||
|
<span className="font-semibold whitespace-pre-wrap">{prompt.replaceAll("\\n", "\n")}</span>
|
||||||
|
{attachment && (
|
||||||
|
<img
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
src={attachment.url}
|
||||||
|
alt={attachment.description}
|
||||||
|
className="max-w-md self-center rounded-xl cursor-pointer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full h-full flex flex-col gap-4">
|
||||||
|
<span className="whitespace-pre-wrap">{suffix}</span>
|
||||||
|
<textarea
|
||||||
|
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"
|
||||||
|
onChange={(e) => setInputText(e.target.value)}
|
||||||
|
value={inputText}
|
||||||
|
placeholder="Write your text here..."
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<span className="text-base self-end text-mti-gray-cool">Word Count: {inputText.split(" ").filter((x) => x !== "").length}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<textarea
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
className="w-full h-1/3 cursor-text p-2 input input-bordered bg-white"
|
<Button
|
||||||
onChange={(e) => setInputText(e.target.value)}
|
color="purple"
|
||||||
value={inputText}
|
variant="outline"
|
||||||
placeholder="Write your text here..."
|
onClick={() =>
|
||||||
/>
|
onBack({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type})
|
||||||
|
}
|
||||||
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
|
className="max-w-[200px] self-end w-full">
|
||||||
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
|
|
||||||
<div className="absolute left-4">
|
|
||||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
|
||||||
</div>
|
|
||||||
Back
|
Back
|
||||||
</button>
|
</Button>
|
||||||
{!isSubmitEnabled && (
|
<Button
|
||||||
<div className="tooltip" data-tip={`You have not yet reached your minimum word count of ${wordCounter.limit} words!`}>
|
color="purple"
|
||||||
<button
|
disabled={!isSubmitEnabled}
|
||||||
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
|
onClick={() =>
|
||||||
disabled={!isSubmitEnabled}
|
onNext({
|
||||||
onClick={() => onNext({exercise: id, solutions: [inputText], score: {correct: 1, total: 1}, type})}>
|
exercise: id,
|
||||||
Next
|
solutions: [{id, solution: inputText.replaceAll(/\s{2,}/g, " ")}],
|
||||||
</button>
|
score: {correct: 100, total: 100, missing: 0},
|
||||||
</div>
|
type,
|
||||||
)}
|
module: "writing",
|
||||||
{isSubmitEnabled && (
|
})
|
||||||
<button
|
}
|
||||||
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
|
className="max-w-[200px] self-end w-full">
|
||||||
disabled={!isSubmitEnabled}
|
Next
|
||||||
onClick={() => onNext({exercise: id, solutions: [inputText], score: {correct: 1, total: 1}, type})}>
|
</Button>
|
||||||
Next
|
|
||||||
<div className="absolute right-4">
|
|
||||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
Exercise,
|
Exercise,
|
||||||
FillBlanksExercise,
|
FillBlanksExercise,
|
||||||
|
InteractiveSpeakingExercise,
|
||||||
MatchSentencesExercise,
|
MatchSentencesExercise,
|
||||||
MultipleChoiceExercise,
|
MultipleChoiceExercise,
|
||||||
SpeakingExercise,
|
SpeakingExercise,
|
||||||
|
TrueFalseExercise,
|
||||||
UserSolution,
|
UserSolution,
|
||||||
WriteBlanksExercise,
|
WriteBlanksExercise,
|
||||||
WritingExercise,
|
WritingExercise,
|
||||||
@@ -14,27 +16,47 @@ import MultipleChoice from "./MultipleChoice";
|
|||||||
import WriteBlanks from "./WriteBlanks";
|
import WriteBlanks from "./WriteBlanks";
|
||||||
import Writing from "./Writing";
|
import Writing from "./Writing";
|
||||||
import Speaking from "./Speaking";
|
import Speaking from "./Speaking";
|
||||||
|
import TrueFalse from "./TrueFalse";
|
||||||
|
import 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;
|
||||||
onNext: (userSolutions: UserSolution) => void;
|
onNext: (userSolutions: UserSolution) => void;
|
||||||
onBack: () => void;
|
onBack: (userSolutions: UserSolution) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const renderExercise = (exercise: Exercise, onNext: (userSolutions: UserSolution) => void, onBack: () => void) => {
|
export const renderExercise = (
|
||||||
|
exercise: Exercise,
|
||||||
|
examID: string,
|
||||||
|
onNext: (userSolutions: UserSolution) => void,
|
||||||
|
onBack: (userSolutions: UserSolution) => 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} examID={examID} />;
|
||||||
|
case "trueFalse":
|
||||||
|
return <TrueFalse key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||||
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} examID={examID} />;
|
||||||
case "multipleChoice":
|
case "multipleChoice":
|
||||||
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
|
return <MultipleChoice key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||||
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} examID={examID} />;
|
||||||
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} examID={examID} />;
|
||||||
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} examID={examID} />;
|
||||||
|
case "interactiveSpeaking":
|
||||||
|
return (
|
||||||
|
<InteractiveSpeaking
|
||||||
|
key={exercise.id}
|
||||||
|
{...(exercise as InteractiveSpeakingExercise)}
|
||||||
|
examID={examID}
|
||||||
|
onNext={onNext}
|
||||||
|
onBack={onBack}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
9
src/components/FocusLayer.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import {useEffect, useState} from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onFocusLayerMouseEnter?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FocusLayer({onFocusLayerMouseEnter}: Props) {
|
||||||
|
return <div className="absolute top-0 left-0 bottom-0 right-0" onMouseDown={onFocusLayerMouseEnter} />;
|
||||||
|
}
|
||||||
82
src/components/Generation/fill.blanks.edit.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import {FillBlanksExercise} from "@/interfaces/exam";
|
||||||
|
import React from "react";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
exercise: FillBlanksExercise;
|
||||||
|
updateExercise: (data: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FillBlanksEdit = (props: Props) => {
|
||||||
|
const {exercise, updateExercise} = props;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="Prompt"
|
||||||
|
name="prompt"
|
||||||
|
required
|
||||||
|
value={exercise.prompt}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
prompt: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="Text"
|
||||||
|
name="text"
|
||||||
|
required
|
||||||
|
value={exercise.text}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
text: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<h1>Solutions</h1>
|
||||||
|
<div className="w-full flex flex-wrap -mx-2">
|
||||||
|
{exercise.solutions.map((solution, index) => (
|
||||||
|
<div key={solution.id} className="flex sm:w-1/2 lg:w-1/4 px-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label={`Solution ${index + 1}`}
|
||||||
|
name="solution"
|
||||||
|
required
|
||||||
|
value={solution.solution}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
solutions: exercise.solutions.map((sol) => (sol.id === solution.id ? {...sol, solution: value} : sol)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<h1>Words</h1>
|
||||||
|
<div className="w-full flex flex-wrap -mx-2">
|
||||||
|
{exercise.words.map((word, index) => (
|
||||||
|
<div key={index} className="flex sm:w-1/2 lg:w-1/4 px-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label={`Word ${index + 1}`}
|
||||||
|
name="word"
|
||||||
|
required
|
||||||
|
value={typeof word === "string" ? word : word.word}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
words: exercise.words.map((sol, idx) =>
|
||||||
|
index === idx ? (typeof word === "string" ? value : {...word, word: value}) : sol,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FillBlanksEdit;
|
||||||
7
src/components/Generation/interactive.speaking.edit.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const InteractiveSpeakingEdit = () => {
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InteractiveSpeakingEdit;
|
||||||
130
src/components/Generation/match.sentences.edit.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { MatchSentencesExercise } from "@/interfaces/exam";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
|
import Select from "@/components/Low/Select";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
exercise: MatchSentencesExercise;
|
||||||
|
updateExercise: (data: any) => void;
|
||||||
|
}
|
||||||
|
const MatchSentencesEdit = (props: Props) => {
|
||||||
|
const { exercise, updateExercise } = props;
|
||||||
|
|
||||||
|
const selectOptions = exercise.options.map((option) => ({
|
||||||
|
value: option.id,
|
||||||
|
label: option.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="Prompt"
|
||||||
|
name="prompt"
|
||||||
|
required
|
||||||
|
value={exercise.prompt}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
prompt: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<h1>Solutions</h1>
|
||||||
|
<div className="w-full flex flex-wrap -mx-2">
|
||||||
|
{exercise.sentences.map((sentence, index) => (
|
||||||
|
<div key={sentence.id} className="flex flex-col w-full px-2">
|
||||||
|
<div className="flex w-full">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label={`Sentence ${index + 1}`}
|
||||||
|
name="sentence"
|
||||||
|
required
|
||||||
|
value={sentence.sentence}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
sentences: exercise.sentences.map((iSol) =>
|
||||||
|
iSol.id === sentence.id
|
||||||
|
? {
|
||||||
|
...iSol,
|
||||||
|
sentence: value,
|
||||||
|
}
|
||||||
|
: iSol
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="px-2"
|
||||||
|
/>
|
||||||
|
<div className="w-48 flex items-end px-2">
|
||||||
|
<Select
|
||||||
|
value={selectOptions.find(
|
||||||
|
(o) => o.value === sentence.solution
|
||||||
|
)}
|
||||||
|
options={selectOptions}
|
||||||
|
onChange={(value) => {
|
||||||
|
updateExercise({
|
||||||
|
sentences: exercise.sentences.map((iSol) =>
|
||||||
|
iSol.id === sentence.id
|
||||||
|
? {
|
||||||
|
...iSol,
|
||||||
|
solution: value?.value,
|
||||||
|
}
|
||||||
|
: iSol
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<h1>Options</h1>
|
||||||
|
<div className="w-full flex flex-wrap -mx-2">
|
||||||
|
{exercise.options.map((option, index) => (
|
||||||
|
<div key={option.id} className="flex flex-col w-full px-2">
|
||||||
|
<div className="flex w-full">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label={`Option ${index + 1}`}
|
||||||
|
name="option"
|
||||||
|
required
|
||||||
|
value={option.sentence}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
options: exercise.options.map((iSol) =>
|
||||||
|
iSol.id === option.id
|
||||||
|
? {
|
||||||
|
...iSol,
|
||||||
|
sentence: value,
|
||||||
|
}
|
||||||
|
: iSol
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="px-2"
|
||||||
|
/>
|
||||||
|
<div className="w-48 flex items-end px-2">
|
||||||
|
<Select
|
||||||
|
value={{
|
||||||
|
value: option.id,
|
||||||
|
label: option.id,
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: option.id,
|
||||||
|
label: option.id,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
disabled
|
||||||
|
onChange={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MatchSentencesEdit;
|
||||||
137
src/components/Generation/multiple.choice.edit.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
|
import {
|
||||||
|
MultipleChoiceExercise,
|
||||||
|
MultipleChoiceQuestion,
|
||||||
|
} from "@/interfaces/exam";
|
||||||
|
import Select from "@/components/Low/Select";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
exercise: MultipleChoiceExercise;
|
||||||
|
updateExercise: (data: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantOptions = [
|
||||||
|
{ value: "text", label: "Text", key: "text" },
|
||||||
|
{ value: "image", label: "Image", key: "src" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MultipleChoiceEdit = (props: Props) => {
|
||||||
|
const { exercise, updateExercise } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>Questions</h1>
|
||||||
|
<div className="w-full flex-no-wrap -mx-2">
|
||||||
|
{exercise.questions.map((question: MultipleChoiceQuestion, index) => {
|
||||||
|
const variantValue = variantOptions.find(
|
||||||
|
(o) => o.value === question.variant
|
||||||
|
);
|
||||||
|
|
||||||
|
const solutionsOptions = question.options.map((option) => ({
|
||||||
|
value: option.id,
|
||||||
|
label: option.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const solutionValue = solutionsOptions.find(
|
||||||
|
(o) => o.value === question.solution
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div key={question.id} className="flex w-full px-2 flex-col">
|
||||||
|
<span>Question ID: {question.id}</span>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="Prompt"
|
||||||
|
name="prompt"
|
||||||
|
required
|
||||||
|
value={question.prompt}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
questions: exercise.questions.map((sol) =>
|
||||||
|
sol.id === question.id ? { ...sol, prompt: value } : sol
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex w-full">
|
||||||
|
<div className="w-48 flex items-end px-2">
|
||||||
|
<Select
|
||||||
|
value={solutionValue}
|
||||||
|
options={solutionsOptions}
|
||||||
|
onChange={(value) => {
|
||||||
|
updateExercise({
|
||||||
|
questions: exercise.questions.map((sol) =>
|
||||||
|
sol.id === question.id
|
||||||
|
? { ...sol, solution: value?.value }
|
||||||
|
: sol
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-48 flex items-end px-2">
|
||||||
|
<Select
|
||||||
|
value={variantValue}
|
||||||
|
options={variantOptions}
|
||||||
|
onChange={(value) => {
|
||||||
|
updateExercise({
|
||||||
|
questions: exercise.questions.map((sol) =>
|
||||||
|
sol.id === question.id
|
||||||
|
? { ...sol, variant: value?.value }
|
||||||
|
: sol
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-wrap -mx-2">
|
||||||
|
{question.options.map((option) => (
|
||||||
|
<div
|
||||||
|
key={option.id}
|
||||||
|
className="flex sm:w-1/2 lg:w-1/4 px-2 px-2"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label={`Option ${option.id}`}
|
||||||
|
name="option"
|
||||||
|
required
|
||||||
|
value={option.text}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
questions: exercise.questions.map((sol) =>
|
||||||
|
sol.id === question.id
|
||||||
|
? {
|
||||||
|
...sol,
|
||||||
|
options: sol.options.map((opt) => {
|
||||||
|
if (
|
||||||
|
opt.id === option.id &&
|
||||||
|
variantValue?.key
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...opt,
|
||||||
|
[variantValue.key]: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return opt;
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: sol
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MultipleChoiceEdit;
|
||||||
7
src/components/Generation/speaking.edit.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const SpeakingEdit = () => {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SpeakingEdit;
|
||||||
71
src/components/Generation/true.false.edit.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { TrueFalseExercise } from "@/interfaces/exam";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
|
import Select from "@/components/Low/Select";
|
||||||
|
interface Props {
|
||||||
|
exercise: TrueFalseExercise;
|
||||||
|
updateExercise: (data: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ value: "true", label: "True" },
|
||||||
|
{ value: "false", label: "False" },
|
||||||
|
{ value: "not_given", label: "Not Given" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TrueFalseEdit = (props: Props) => {
|
||||||
|
const { exercise, updateExercise } = props;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="Prompt"
|
||||||
|
name="prompt"
|
||||||
|
required
|
||||||
|
value={exercise.prompt}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
prompt: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<h1>Questions</h1>
|
||||||
|
<div className="w-full flex-no-wrap -mx-2">
|
||||||
|
{exercise.questions.map((question, index) => (
|
||||||
|
<div key={question.id} className="flex w-full px-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label={`Question ${index + 1}`}
|
||||||
|
name="question"
|
||||||
|
required
|
||||||
|
value={question.prompt}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
questions: exercise.questions.map((sol) =>
|
||||||
|
sol.id === question.id ? { ...sol, prompt: value } : sol
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="w-48 flex items-end px-2">
|
||||||
|
<Select
|
||||||
|
value={options.find((o) => o.value === question.solution)}
|
||||||
|
options={options}
|
||||||
|
onChange={(value) => {
|
||||||
|
updateExercise({
|
||||||
|
questions: exercise.questions.map((sol) =>
|
||||||
|
sol.id === question.id ? { ...sol, solution: value?.value } : sol
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-18"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TrueFalseEdit;
|
||||||
94
src/components/Generation/write.blanks.edit.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
|
import { WriteBlanksExercise } from "@/interfaces/exam";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
exercise: WriteBlanksExercise;
|
||||||
|
updateExercise: (data: any) => void;
|
||||||
|
}
|
||||||
|
const WriteBlankEdits = (props: Props) => {
|
||||||
|
const { exercise, updateExercise } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="Prompt"
|
||||||
|
name="prompt"
|
||||||
|
required
|
||||||
|
value={exercise.prompt}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
prompt: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="Text"
|
||||||
|
name="text"
|
||||||
|
required
|
||||||
|
value={exercise.text}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
text: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="Max Words"
|
||||||
|
name="number"
|
||||||
|
required
|
||||||
|
value={exercise.maxWords}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
maxWords: Number(value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<h1>Solutions</h1>
|
||||||
|
<div className="w-full flex flex-wrap -mx-2">
|
||||||
|
{exercise.solutions.map((solution) => (
|
||||||
|
<div key={solution.id} className="flex flex-col w-full px-2">
|
||||||
|
<span>Solution ID: {solution.id}</span>
|
||||||
|
{/* TODO: Consider adding an add and delete button */}
|
||||||
|
<div className="flex flex-wrap">
|
||||||
|
{solution.solution.map((sol, solIndex) => (
|
||||||
|
<Input
|
||||||
|
key={`${sol}-${solIndex}`}
|
||||||
|
type="text"
|
||||||
|
label={`Solution ${solIndex + 1}`}
|
||||||
|
name="solution"
|
||||||
|
required
|
||||||
|
value={sol}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
solutions: exercise.solutions.map((iSol) =>
|
||||||
|
iSol.id === solution.id
|
||||||
|
? {
|
||||||
|
...iSol,
|
||||||
|
solution: iSol.solution.map((iiSol, iiIndex) => {
|
||||||
|
if (iiIndex === solIndex) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return iiSol;
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: iSol
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="sm:w-1/2 lg:w-1/4 px-2"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WriteBlankEdits;
|
||||||
7
src/components/Generation/writing.edit.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const WritingEdit = () => {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WritingEdit;
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/components/High/Layout.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import {User} from "@/interfaces/user";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import BottomBar from "../BottomBar";
|
||||||
|
import Navbar from "../Navbar";
|
||||||
|
import Sidebar from "../Sidebar";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
navDisabled?: boolean;
|
||||||
|
focusMode?: boolean;
|
||||||
|
onFocusLayerMouseEnter?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Layout({user, children, className, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative">
|
||||||
|
<Navbar
|
||||||
|
path={router.pathname}
|
||||||
|
user={user}
|
||||||
|
navDisabled={navDisabled}
|
||||||
|
focusMode={focusMode}
|
||||||
|
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
|
||||||
|
/>
|
||||||
|
<div className="h-full w-full flex gap-2">
|
||||||
|
<Sidebar
|
||||||
|
path={router.pathname}
|
||||||
|
navDisabled={navDisabled}
|
||||||
|
focusMode={focusMode}
|
||||||
|
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
|
||||||
|
className="-md:hidden"
|
||||||
|
user={user}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"w-full min-h-full h-fit md:mr-8 bg-white shadow-md rounded-2xl p-4 xl:p-10 pb-8 flex flex-col gap-8 relative overflow-hidden mt-2",
|
||||||
|
className,
|
||||||
|
)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
255
src/components/High/TicketDisplay.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
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";
|
||||||
|
import { checkAccess } from "@/utils/permissions";
|
||||||
|
|
||||||
|
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) => checkAccess(x, ["admin", "developer", "agent"]))
|
||||||
|
.map((u) => ({
|
||||||
|
value: u.id,
|
||||||
|
label: `${u.name} - ${u.email}`,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
disabled={checkAccess(user, ["agent"])}
|
||||||
|
value={
|
||||||
|
assignedTo
|
||||||
|
? {
|
||||||
|
value: assignedTo,
|
||||||
|
label: `${users.find((u) => u.id === assignedTo)?.name} - ${users.find((u) => u.id === assignedTo)?.email}`,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onChange={(value) =>
|
||||||
|
value
|
||||||
|
? setAssignedTo(value.value === "me" ? user.id : value.value)
|
||||||
|
: setAssignedTo(null)
|
||||||
|
}
|
||||||
|
placeholder="Assignee..."
|
||||||
|
isClearable
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="-md:flex-col flex w-full items-center gap-4">
|
||||||
|
<Input
|
||||||
|
label="Reported From"
|
||||||
|
type="text"
|
||||||
|
name="reportedFrom"
|
||||||
|
onChange={() => null}
|
||||||
|
value={reportedFrom}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Date"
|
||||||
|
type="text"
|
||||||
|
name="date"
|
||||||
|
onChange={() => null}
|
||||||
|
value={moment(ticket.date).format("DD/MM/YYYY - HH:mm")}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="-md:flex-col flex w-full items-center gap-4">
|
||||||
|
<Input
|
||||||
|
label="Reporter's Name"
|
||||||
|
type="text"
|
||||||
|
name="reporter"
|
||||||
|
onChange={() => null}
|
||||||
|
value={reporter.name}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Reporter's E-mail"
|
||||||
|
type="text"
|
||||||
|
name="reporter"
|
||||||
|
onChange={() => null}
|
||||||
|
value={reporter.email}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Reporter's Type"
|
||||||
|
type="text"
|
||||||
|
name="reporterType"
|
||||||
|
onChange={() => null}
|
||||||
|
value={USER_TYPE_LABELS[reporter.type]}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8"
|
||||||
|
placeholder="Write your ticket's description here..."
|
||||||
|
contentEditable={false}
|
||||||
|
value={description}
|
||||||
|
spellCheck
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="-md:flex-col-reverse mt-2 flex w-full items-center justify-between gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="red"
|
||||||
|
className="w-full md:max-w-[200px]"
|
||||||
|
variant="outline"
|
||||||
|
onClick={del}
|
||||||
|
isLoading={isLoading}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="-md:flex-col-reverse flex w-full items-center justify-end gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="red"
|
||||||
|
className="w-full md:max-w-[200px]"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
isLoading={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full md:max-w-[200px]"
|
||||||
|
isLoading={isLoading}
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
src/components/High/TicketSubmission.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import {Ticket, TicketType, TicketTypeLabel} from "@/interfaces/ticket";
|
||||||
|
import {User} from "@/interfaces/user";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import axios from "axios";
|
||||||
|
import {useState} from "react";
|
||||||
|
import {toast} from "react-toastify";
|
||||||
|
import ShortUniqueId from "short-unique-id";
|
||||||
|
import Button from "../Low/Button";
|
||||||
|
import Input from "../Low/Input";
|
||||||
|
import Select from "../Low/Select";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
page: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TicketSubmission({user, page, onClose}: Props) {
|
||||||
|
const [subject, setSubject] = useState("");
|
||||||
|
const [type, setType] = useState<TicketType>();
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const examState = useExamStore((state) => state);
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (!type) return toast.error("Please choose a type!", {toastId: "missing-type"});
|
||||||
|
if (subject.trim() === "")
|
||||||
|
return toast.error("Please input a subject!", {
|
||||||
|
toastId: "missing-subject",
|
||||||
|
});
|
||||||
|
if (description.trim() === "")
|
||||||
|
return toast.error("Please describe your ticket!", {
|
||||||
|
toastId: "missing-desc",
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const shortUID = new ShortUniqueId();
|
||||||
|
const ticket: Ticket = {
|
||||||
|
id: shortUID.randomUUID(8),
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
reporter: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
type: user.type,
|
||||||
|
},
|
||||||
|
status: "submitted",
|
||||||
|
subject,
|
||||||
|
type,
|
||||||
|
reportedFrom: page,
|
||||||
|
description,
|
||||||
|
examInformation:
|
||||||
|
page.includes("exam") || page.includes("exercises")
|
||||||
|
? {
|
||||||
|
exam: examState.exam?.id || "",
|
||||||
|
exams: examState.exams.map((x) => x.id),
|
||||||
|
exerciseIndex: examState.exerciseIndex,
|
||||||
|
moduleIndex: examState.moduleIndex,
|
||||||
|
partIndex: examState.partIndex,
|
||||||
|
questionIndex: examState.questionIndex,
|
||||||
|
selectedModules: examState.selectedModules,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
axios
|
||||||
|
.post(`/api/tickets`, ticket)
|
||||||
|
.then(() => {
|
||||||
|
toast.success(`Your ticket has been submitted! You will be contacted by e-mail for further discussion.`, {toastId: "submitted"});
|
||||||
|
onClose();
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
toast.error("Something went wrong, please try again later!", {
|
||||||
|
toastId: "error",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="flex flex-col gap-4 pt-8">
|
||||||
|
<Input label="Subject" type="text" name="subject" placeholder="Subject..." onChange={(e) => setSubject(e)} />
|
||||||
|
<div className="-md:flex-col flex w-full items-center gap-4">
|
||||||
|
<div className="flex w-full flex-col gap-3">
|
||||||
|
<label className="text-mti-gray-dim text-base font-normal">Type</label>
|
||||||
|
<Select
|
||||||
|
options={Object.keys(TicketTypeLabel).map((x) => ({
|
||||||
|
value: x,
|
||||||
|
label: TicketTypeLabel[x as keyof typeof TicketTypeLabel],
|
||||||
|
}))}
|
||||||
|
onChange={(value) => setType((value?.value as TicketType) ?? undefined)}
|
||||||
|
placeholder="Type..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input label="Reporter" type="text" name="reporter" onChange={() => null} value={`${user.name} - ${user.email}`} disabled />
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8"
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Write your ticket's description here..."
|
||||||
|
spellCheck
|
||||||
|
/>
|
||||||
|
<div className="mt-2 flex w-full items-center justify-end gap-4">
|
||||||
|
<Button type="button" color="red" className="w-full max-w-[200px]" variant="outline" onClick={onClose} isLoading={isLoading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" className="w-full max-w-[200px]" isLoading={isLoading} onClick={submit}>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
src/components/InfiniteCarousel.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import React, { useRef, useEffect, useState, useCallback, ReactNode } from 'react';
|
||||||
|
import { useSpring, animated } from '@react-spring/web';
|
||||||
|
import { useDrag } from '@use-gesture/react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
interface InfiniteCarouselProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
height: string;
|
||||||
|
speed?: number;
|
||||||
|
gap?: number;
|
||||||
|
overlay?: ReactNode;
|
||||||
|
overlayFunc?: (index: number) => void;
|
||||||
|
overlayClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InfiniteCarousel: React.FC<InfiniteCarouselProps> = ({
|
||||||
|
children,
|
||||||
|
height,
|
||||||
|
speed = 20000,
|
||||||
|
gap = 16,
|
||||||
|
overlay = undefined,
|
||||||
|
overlayFunc = undefined,
|
||||||
|
overlayClassName = ""
|
||||||
|
}) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [containerWidth, setContainerWidth] = useState<number>(0);
|
||||||
|
const itemCount = React.Children.count(children);
|
||||||
|
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||||
|
const [itemWidth, setItemWidth] = useState<number>(0);
|
||||||
|
const [isInfinite, setIsInfinite] = useState<boolean>(true);
|
||||||
|
const dragStartX = useRef<number>(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
const containerWidth = containerRef.current.clientWidth;
|
||||||
|
setContainerWidth(containerWidth);
|
||||||
|
|
||||||
|
const firstChild = containerRef.current.firstElementChild?.firstElementChild as HTMLElement;
|
||||||
|
if (firstChild) {
|
||||||
|
const childWidth = firstChild.offsetWidth;
|
||||||
|
setItemWidth(childWidth);
|
||||||
|
|
||||||
|
const totalContentWidth = (childWidth + gap) * itemCount - gap;
|
||||||
|
setIsInfinite(totalContentWidth > containerWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleResize();
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
}, [gap, itemCount]);
|
||||||
|
|
||||||
|
const totalWidth = (itemWidth + gap) * itemCount;
|
||||||
|
|
||||||
|
const [{ x }, api] = useSpring(() => ({
|
||||||
|
from: { x: 0 },
|
||||||
|
to: { x: -totalWidth },
|
||||||
|
config: { duration: speed },
|
||||||
|
loop: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const startAnimation = useCallback(() => {
|
||||||
|
if (isInfinite) {
|
||||||
|
api.start({
|
||||||
|
from: { x: x.get() },
|
||||||
|
to: { x: x.get() - totalWidth },
|
||||||
|
config: { duration: speed },
|
||||||
|
loop: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
api.stop();
|
||||||
|
api.start({ x: 0, immediate: true });
|
||||||
|
}
|
||||||
|
}, [api, x, totalWidth, speed, isInfinite]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (containerWidth > 0 && !isDragging) {
|
||||||
|
startAnimation();
|
||||||
|
}
|
||||||
|
}, [containerWidth, isDragging, startAnimation]);
|
||||||
|
|
||||||
|
const bind = useDrag(({ down, movement: [mx], first }) => {
|
||||||
|
if (!isInfinite) return;
|
||||||
|
if (first) {
|
||||||
|
setIsDragging(true);
|
||||||
|
api.stop();
|
||||||
|
dragStartX.current = x.get();
|
||||||
|
}
|
||||||
|
if (down) {
|
||||||
|
let newX = dragStartX.current + mx;
|
||||||
|
newX = ((newX % totalWidth) + totalWidth) % totalWidth;
|
||||||
|
if (newX > 0) newX -= totalWidth;
|
||||||
|
api.start({ x: newX, immediate: true });
|
||||||
|
} else {
|
||||||
|
setIsDragging(false);
|
||||||
|
startAnimation();
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
filterTaps: true,
|
||||||
|
from: () => [x.get(), 0],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="overflow-hidden relative select-none"
|
||||||
|
style={{ height, touchAction: 'pan-y' }}
|
||||||
|
ref={containerRef}
|
||||||
|
{...(isInfinite ? bind() : {})}
|
||||||
|
>
|
||||||
|
<animated.div
|
||||||
|
className="flex"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
willChange: 'transform',
|
||||||
|
transform: isInfinite
|
||||||
|
? x.to((x) => `translate3d(${x}px, 0, 0)`)
|
||||||
|
: 'none',
|
||||||
|
gap: `${gap}px`,
|
||||||
|
width: 'fit-content',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{React.Children.map(children, (child, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex-shrink-0 relative"
|
||||||
|
>
|
||||||
|
{overlay !== undefined && overlayFunc !== undefined && (
|
||||||
|
<div className={clsx('absolute', overlayClassName)} onClick={() => overlayFunc(i)}>
|
||||||
|
{overlay}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="select-none"
|
||||||
|
style={{ pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
{child}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{isInfinite && React.Children.map(children, (child, i) => (
|
||||||
|
<div
|
||||||
|
key={`clone-${i}`}
|
||||||
|
className="flex-shrink-0 relative"
|
||||||
|
>
|
||||||
|
{overlay !== undefined && overlayFunc !== undefined && (
|
||||||
|
<div className={clsx('absolute', overlayClassName)} onClick={() => overlayFunc(i)}>
|
||||||
|
{overlay}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="select-none"
|
||||||
|
style={{ pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
{child}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</animated.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InfiniteCarousel;
|
||||||
96
src/components/Low/AudioPlayer.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import {Module} from "@/interfaces";
|
||||||
|
import {formatTimeInMinutes} from "@/utils/string";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {useEffect, useRef, useState} from "react";
|
||||||
|
import {BsPauseFill, BsPlayFill} from "react-icons/bs";
|
||||||
|
import ProgressBar from "./ProgressBar";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
src: string;
|
||||||
|
color: "red" | "rose" | "purple" | Module;
|
||||||
|
autoPlay?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onEnd?: () => void;
|
||||||
|
disablePause?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AudioPlayer({src, color, autoPlay = false, disabled = false, onEnd, disablePause = false}: Props) {
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
|
||||||
|
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const durationInterval = setInterval(() => {
|
||||||
|
if (duration > 0) clearInterval(durationInterval);
|
||||||
|
|
||||||
|
const seconds = Math.floor(audioPlayerRef?.current?.duration || 0);
|
||||||
|
if (seconds > 0) setDuration(seconds);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
if (duration > 0) clearInterval(durationInterval);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(durationInterval);
|
||||||
|
};
|
||||||
|
}, [duration]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let playingInterval: NodeJS.Timer | undefined = undefined;
|
||||||
|
if (isPlaying) {
|
||||||
|
playingInterval = setInterval(() => setCurrentTime((prev) => prev + 1), 1000);
|
||||||
|
} else if (playingInterval) {
|
||||||
|
clearInterval(playingInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (playingInterval) clearInterval(playingInterval);
|
||||||
|
};
|
||||||
|
}, [isPlaying]);
|
||||||
|
|
||||||
|
const togglePlayPause = () => {
|
||||||
|
const prevValue = isPlaying;
|
||||||
|
setIsPlaying(!prevValue);
|
||||||
|
if (!prevValue) {
|
||||||
|
audioPlayerRef?.current?.play();
|
||||||
|
} else {
|
||||||
|
audioPlayerRef?.current?.pause();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-fit flex gap-4 items-center mt-2">
|
||||||
|
{isPlaying && (
|
||||||
|
<BsPauseFill
|
||||||
|
className={clsx("text-mti-gray-cool cursor-pointer w-5 h-5", (disabled || disablePause) && "opacity-60 cursor-not-allowed")}
|
||||||
|
onClick={disabled || disablePause ? undefined : togglePlayPause}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isPlaying && (
|
||||||
|
<BsPlayFill
|
||||||
|
className={clsx("text-mti-gray-cool cursor-pointer w-5 h-5", disabled && "opacity-60 cursor-not-allowed")}
|
||||||
|
onClick={disabled ? undefined : togglePlayPause}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<audio
|
||||||
|
src={src}
|
||||||
|
autoPlay={autoPlay}
|
||||||
|
ref={audioPlayerRef}
|
||||||
|
preload="metadata"
|
||||||
|
onEnded={() => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentTime(0);
|
||||||
|
if (onEnd) onEnd();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-2 w-full relative">
|
||||||
|
<div className="absolute w-full flex justify-between -top-5 text-xs px-1">
|
||||||
|
<span>{formatTimeInMinutes(currentTime)}</span>
|
||||||
|
<span>{formatTimeInMinutes(duration)}</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar label="" color={color} useColor percentage={(currentTime * 100) / duration} className="h-3 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/components/Low/Button.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import {ReactNode} from "react";
|
||||||
|
import {BsArrowRepeat} from "react-icons/bs";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
color?: "rose" | "purple" | "red" | "green" | "gray" | "pink";
|
||||||
|
variant?: "outline" | "solid";
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
type?: "button" | "reset" | "submit";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Button({
|
||||||
|
color = "purple",
|
||||||
|
variant = "solid",
|
||||||
|
disabled = false,
|
||||||
|
isLoading = false,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
type,
|
||||||
|
onClick,
|
||||||
|
}: Props) {
|
||||||
|
const colorClassNames: {[key in typeof color]: {[key in typeof variant]: string}} = {
|
||||||
|
green: {
|
||||||
|
solid: "bg-mti-green-light text-white border border-mti-green-light hover:bg-mti-green disabled:text-mti-green disabled:bg-mti-green-ultralight selection:bg-mti-green-dark",
|
||||||
|
outline:
|
||||||
|
"bg-transparent text-mti-green-light border border-mti-green-light hover:bg-mti-green-light disabled:text-mti-green disabled:bg-mti-green-ultralight disabled:border-none selection:bg-mti-green-dark hover:text-white selection:text-white",
|
||||||
|
},
|
||||||
|
purple: {
|
||||||
|
solid: "bg-mti-purple-light text-white border border-mti-purple-light hover:bg-mti-purple disabled:text-mti-purple disabled:bg-mti-purple-ultralight selection:bg-mti-purple-dark",
|
||||||
|
outline:
|
||||||
|
"bg-transparent text-mti-purple-light border border-mti-purple-light hover:bg-mti-purple-light disabled:text-mti-purple disabled:bg-mti-purple-ultralight disabled:border-none selection:bg-mti-purple-dark hover:text-white selection:text-white",
|
||||||
|
},
|
||||||
|
red: {
|
||||||
|
solid: "bg-mti-red-light text-white border border-mti-red-light hover:bg-mti-red disabled:text-mti-red disabled:bg-mti-red-ultralight selection:bg-mti-red-dark",
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
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: {
|
||||||
|
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:
|
||||||
|
"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 (
|
||||||
|
<button
|
||||||
|
type={type}
|
||||||
|
onClick={onClick}
|
||||||
|
className={clsx(
|
||||||
|
"py-4 px-6 rounded-full transition ease-in-out duration-300 disabled:cursor-not-allowed cursor-pointer",
|
||||||
|
colorClassNames[color][variant],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
disabled={disabled || isLoading}>
|
||||||
|
{!isLoading && children}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/components/Low/Checkbox.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import {ReactElement, ReactNode} from "react";
|
||||||
|
import {BsCheck} from "react-icons/bs";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isChecked: boolean;
|
||||||
|
onChange: (isChecked: boolean) => void;
|
||||||
|
children: ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Checkbox({isChecked, onChange, children, disabled}: Props) {
|
||||||
|
return (
|
||||||
|
<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" />
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
isChecked && "!bg-mti-purple-light ",
|
||||||
|
)}>
|
||||||
|
<BsCheck color="white" className="w-full h-full" />
|
||||||
|
</div>
|
||||||
|
<span>{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
src/components/Low/CountrySelect.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import {countries, TCountries} from "countries-list";
|
||||||
|
import {Fragment, useState} from "react";
|
||||||
|
import {Combobox, Transition} from "@headlessui/react";
|
||||||
|
import {BsChevronExpand} from "react-icons/bs";
|
||||||
|
import countryCodes from "country-codes-list";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapCountries = (codes: string[]) => {
|
||||||
|
return codes.map((code) => ({
|
||||||
|
label: `${countryCodes.findOne("countryCode" as any, code).flag} ${countries[code as unknown as keyof TCountries].name} (+${
|
||||||
|
countries[code as unknown as keyof TCountries].phone
|
||||||
|
})`,
|
||||||
|
code,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CountrySelect({value, disabled = false, onChange}: Props) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const filteredCountries =
|
||||||
|
query === ""
|
||||||
|
? mapCountries(Object.keys(countries))
|
||||||
|
: mapCountries(
|
||||||
|
Object.keys(countries).filter((x) =>
|
||||||
|
countries[x as unknown as keyof TCountries].name.toLowerCase().includes(query.toLowerCase()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Combobox value={value} onChange={onChange} disabled={disabled}>
|
||||||
|
<div className="relative mt-1">
|
||||||
|
<div className="relative w-full cursor-default overflow-hidden ">
|
||||||
|
<Combobox.Input
|
||||||
|
className="py-6 w-full px-8 text-sm font-normal placeholder:text-mti-gray-cool bg-white disabled:bg-mti-gray-platinum/40 rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
displayValue={(code: string) => {
|
||||||
|
const country = countries[code as unknown as keyof TCountries];
|
||||||
|
|
||||||
|
return `${countryCodes.findOne("countryCode" as any, code)?.flag || ""} ${country?.name || "N/A"} (+${
|
||||||
|
country?.phone || "N/A"
|
||||||
|
})`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-8">
|
||||||
|
<BsChevronExpand />
|
||||||
|
</Combobox.Button>
|
||||||
|
</div>
|
||||||
|
<Transition
|
||||||
|
as={Fragment}
|
||||||
|
leave="transition ease-in duration-100"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
afterLeave={() => setQuery("")}>
|
||||||
|
<Combobox.Options className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-xl bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
|
||||||
|
{filteredCountries.length === 0 && query !== "" ? (
|
||||||
|
<div className="relative cursor-default select-none py-2 px-4 text-gray-700">Nothing found.</div>
|
||||||
|
) : (
|
||||||
|
filteredCountries.map((country) => (
|
||||||
|
<Combobox.Option
|
||||||
|
key={country.code}
|
||||||
|
value={country.code}
|
||||||
|
className={({active}) =>
|
||||||
|
`relative cursor-default select-none py-2 pl-10 pr-4 ${
|
||||||
|
active ? "bg-mti-purple-light text-white" : "text-gray-900"
|
||||||
|
}`
|
||||||
|
}>
|
||||||
|
{country.label}
|
||||||
|
</Combobox.Option>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Combobox.Options>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</Combobox>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
src/components/Low/Input.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import {useState} from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
type: "email" | "text" | "password" | "tel" | "number";
|
||||||
|
roundness?: "full" | "xl";
|
||||||
|
required?: boolean;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
defaultValue?: string | number;
|
||||||
|
value?: string | number;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
name: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Input({
|
||||||
|
type,
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
name,
|
||||||
|
required = false,
|
||||||
|
value,
|
||||||
|
defaultValue,
|
||||||
|
className,
|
||||||
|
roundness = "full",
|
||||||
|
disabled = false,
|
||||||
|
onChange,
|
||||||
|
}: Props) {
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
if (type === "password") {
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-col gap-3 w-full">
|
||||||
|
{label && (
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
{label}
|
||||||
|
{required ? " *" : ""}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="w-full h-fit relative">
|
||||||
|
<input
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
name={name}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
className="w-full px-8 py-6 text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
role="button"
|
||||||
|
onClick={() => setShowPassword((prev) => !prev)}
|
||||||
|
className="text-xs cursor-pointer absolute bottom-1/2 translate-y-1/2 right-8">
|
||||||
|
{showPassword ? "Hide" : "Show"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx("flex flex-col gap-3 w-full", className)}>
|
||||||
|
{label && (
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
{label}
|
||||||
|
{required ? " *" : ""}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
name={name}
|
||||||
|
disabled={disabled}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
min={type === "number" ? 0 : undefined}
|
||||||
|
placeholder={placeholder}
|
||||||
|
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}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/components/Low/ProgressBar.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import {Module} from "@/interfaces";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
percentage: number;
|
||||||
|
color: "red" | "rose" | "purple" | Module;
|
||||||
|
mark?: number;
|
||||||
|
markLabel?: string;
|
||||||
|
useColor?: boolean;
|
||||||
|
className?: string;
|
||||||
|
textClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProgressBar({label, percentage, color, mark, markLabel, useColor = false, className, textClassName}: Props) {
|
||||||
|
const progressColorClass: {[key in typeof color]: string} = {
|
||||||
|
red: "bg-mti-red-light",
|
||||||
|
rose: "bg-mti-rose-light",
|
||||||
|
purple: "bg-mti-purple-light",
|
||||||
|
reading: "bg-ielts-reading",
|
||||||
|
listening: "bg-ielts-listening",
|
||||||
|
writing: "bg-ielts-writing",
|
||||||
|
speaking: "bg-ielts-speaking",
|
||||||
|
level: "bg-ielts-level",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"relative rounded-full overflow-hidden flex items-center justify-center",
|
||||||
|
className,
|
||||||
|
!useColor ? "bg-mti-gray-anti-flash" : progressColorClass[color],
|
||||||
|
useColor && "bg-opacity-20",
|
||||||
|
)}>
|
||||||
|
{mark && (
|
||||||
|
<div style={{left: `${mark}%`}} className={clsx("w-3 h-2 bg-mti-gray-davy/60 absolute -translate-x-1/2 top-0 z-20 cursor-pointer")} />
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{width: `${percentage}%`}}
|
||||||
|
className={clsx("absolute transition-all duration-300 ease-in-out top-0 left-0 h-full overflow-hidden", progressColorClass[color])}
|
||||||
|
/>
|
||||||
|
<span className={clsx("z-[1] justify-self-center text-white text-sm font-bold", textClassName)}>{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
src/components/Low/Select.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import {ComponentProps, useEffect, useState} from "react";
|
||||||
|
import ReactSelect, {GroupBase, StylesConfig} from "react-select";
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
[key: string]: any;
|
||||||
|
value: string | null;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
defaultValue?: Option;
|
||||||
|
value?: Option | null;
|
||||||
|
options: Option[];
|
||||||
|
disabled?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
onChange: (value: Option | null) => void;
|
||||||
|
isClearable?: boolean;
|
||||||
|
styles?: StylesConfig<Option, boolean, GroupBase<Option>>;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Select({value, defaultValue, options, placeholder, disabled, onChange, styles, isClearable, className}: Props) {
|
||||||
|
const [target, setTarget] = useState<HTMLElement>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (document) setTarget(document.body);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactSelect
|
||||||
|
className={
|
||||||
|
styles
|
||||||
|
? undefined
|
||||||
|
: clsx(
|
||||||
|
"placeholder:text-mti-gray-cool border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none",
|
||||||
|
disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
|
||||||
|
className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
options={options}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange as any}
|
||||||
|
placeholder={placeholder}
|
||||||
|
menuPortalTarget={target}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
styles={
|
||||||
|
styles || {
|
||||||
|
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||||
|
control: (styles) => ({
|
||||||
|
...styles,
|
||||||
|
paddingLeft: "4px",
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
":focus": {
|
||||||
|
outline: "none",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
option: (styles, state) => ({
|
||||||
|
...styles,
|
||||||
|
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||||
|
color: state.isFocused ? "black" : styles.color,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isDisabled={disabled}
|
||||||
|
isClearable={isClearable}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/components/Low/TImezoneSelect.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
src/components/Medium/ModuleTitle.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import {Module} from "@/interfaces";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import {moduleLabels} from "@/utils/moduleUtils";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {motion} from "framer-motion";
|
||||||
|
import {ReactNode, useEffect, useState} from "react";
|
||||||
|
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
|
||||||
|
import ProgressBar from "../Low/ProgressBar";
|
||||||
|
import TimerEndedModal from "../TimerEndedModal";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
minTimer: number;
|
||||||
|
module: Module;
|
||||||
|
label?: string;
|
||||||
|
exerciseIndex: number;
|
||||||
|
totalExercises: number;
|
||||||
|
disableTimer?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ModuleTitle({minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false}: Props) {
|
||||||
|
const [timer, setTimer] = useState(minTimer * 60);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [warningMode, setWarningMode] = useState(false);
|
||||||
|
|
||||||
|
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
|
||||||
|
const {timeSpent} = useExamStore((state) => state);
|
||||||
|
|
||||||
|
useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!disableTimer) {
|
||||||
|
const timerInterval = setInterval(() => setTimer((prev) => prev - 1), 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(timerInterval);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [disableTimer, minTimer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timer <= 0) setShowModal(true);
|
||||||
|
}, [timer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timer < 300 && !warningMode) setWarningMode(true);
|
||||||
|
}, [timer, warningMode]);
|
||||||
|
|
||||||
|
const moduleIcon: {[key in Module]: ReactNode} = {
|
||||||
|
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
|
||||||
|
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,
|
||||||
|
writing: <BsPen className="text-ielts-writing w-6 h-6" />,
|
||||||
|
speaking: <BsMegaphone className="text-ielts-speaking w-6 h-6" />,
|
||||||
|
level: <BsClipboard className="text-ielts-level w-6 h-6" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TimerEndedModal
|
||||||
|
isOpen={showModal}
|
||||||
|
onClose={() => {
|
||||||
|
setHasExamEnded(true);
|
||||||
|
setShowModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className={clsx(
|
||||||
|
"absolute top-4 right-6 bg-mti-gray-seasalt px-4 py-3 flex items-center gap-2 rounded-full text-mti-gray-davy",
|
||||||
|
warningMode && !disableTimer && "bg-mti-red-light text-mti-gray-seasalt",
|
||||||
|
)}
|
||||||
|
initial={{scale: warningMode && !disableTimer ? 0.8 : 1}}
|
||||||
|
animate={{scale: warningMode && !disableTimer ? 1.1 : 1}}
|
||||||
|
transition={{repeat: Infinity, repeatType: "reverse", duration: 0.5, ease: "easeInOut"}}>
|
||||||
|
<BsStopwatch className="w-6 h-6" />
|
||||||
|
<span className="text-base font-semibold w-12">
|
||||||
|
{timer > 0 && (
|
||||||
|
<>
|
||||||
|
{Math.floor(timer / 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
:
|
||||||
|
{Math.floor(timer % 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{timer <= 0 && <>00:00</>}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
<div className="flex gap-6 w-full h-fit items-center mt-5">
|
||||||
|
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg">{moduleIcon[module]}</div>
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<div className="w-full flex justify-between">
|
||||||
|
<span className="text-base font-semibold">
|
||||||
|
{moduleLabels[module]} exam {label && `- ${label}`}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold self-end">
|
||||||
|
Exercise {exerciseIndex}/{totalExercises}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
src/components/Medium/TopicModal.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import topics from "@/resources/topics";
|
||||||
|
import {useState} from "react";
|
||||||
|
import {BsArrowLeft, BsArrowRight} from "react-icons/bs";
|
||||||
|
import Button from "../Low/Button";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
initialTopics: string[];
|
||||||
|
onClose: VoidFunction;
|
||||||
|
selectTopics: (topics: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TopicModal({isOpen, initialTopics, onClose, selectTopics}: Props) {
|
||||||
|
const [selectedTopics, setSelectedTopics] = useState([...initialTopics]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title="Preferred Topics">
|
||||||
|
<div className="flex flex-col w-full h-full gap-4 mt-4">
|
||||||
|
<div className="w-full h-full grid grid-cols-2 -md:gap-1 gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="border-b border-b-neutral-400/30">Available Topics</span>
|
||||||
|
<div className=" max-h-[500px] overflow-y-scroll scrollbar-hide">
|
||||||
|
{topics
|
||||||
|
.filter((x) => !selectedTopics.includes(x))
|
||||||
|
.map((x) => (
|
||||||
|
<div key={x} className="odd:bg-mti-purple-ultralight/40 p-2 flex justify-between items-center">
|
||||||
|
<span>{x}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedTopics((prev) => [...prev, x])}
|
||||||
|
className="border border-mti-purple-light cursor-pointer p-2 rounded-lg bg-white drop-shadow transition ease-in-out duration-300 hover:bg-mti-purple hover:text-white">
|
||||||
|
<BsArrowRight />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="border-b border-b-neutral-400/30">Preferred Topics ({selectedTopics.length || "All"})</span>
|
||||||
|
<div className=" max-h-[500px] overflow-y-scroll scrollbar-hide">
|
||||||
|
{selectedTopics.map((x) => (
|
||||||
|
<div key={x} className="odd:bg-mti-purple-ultralight/40 p-2 flex justify-between items-center text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedTopics((prev) => [...prev.filter((y) => y !== x)])}
|
||||||
|
className="border border-mti-purple-light cursor-pointer p-2 rounded-lg bg-white drop-shadow transition ease-in-out duration-300 hover:bg-mti-purple hover:text-white">
|
||||||
|
<BsArrowLeft />
|
||||||
|
</button>
|
||||||
|
<span>{x}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex gap-4 items-center justify-end">
|
||||||
|
<Button variant="outline" color="rose" className="w-full max-w-[200px]" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
onClick={() => {
|
||||||
|
selectTopics(selectedTopics);
|
||||||
|
onClose();
|
||||||
|
}}>
|
||||||
|
Select
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
src/components/MobileMenu.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { User } from "@/interfaces/user";
|
||||||
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
|
import axios from "axios";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { Fragment } from "react";
|
||||||
|
import { BsXLg } from "react-icons/bs";
|
||||||
|
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
path: string;
|
||||||
|
user: User;
|
||||||
|
disableNavigation?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MobileMenu({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
path,
|
||||||
|
user,
|
||||||
|
disableNavigation,
|
||||||
|
}: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
axios.post("/api/logout").finally(() => {
|
||||||
|
setTimeout(() => router.reload(), 500);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
|
<Dialog as="div" className="relative z-10" onClose={onClose}>
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-center justify-center text-center">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<Dialog.Panel className="flex h-screen w-full transform flex-col gap-8 overflow-hidden bg-white text-left align-middle text-black shadow-xl transition-all">
|
||||||
|
<Dialog.Title
|
||||||
|
as="header"
|
||||||
|
className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden"
|
||||||
|
>
|
||||||
|
<Link href={disableNavigation ? "" : "/"}>
|
||||||
|
<Image
|
||||||
|
src="/logo_title.png"
|
||||||
|
alt="EnCoach logo"
|
||||||
|
width={69}
|
||||||
|
height={69}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<div
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={onClose}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<BsXLg
|
||||||
|
className="text-mti-purple-light text-2xl"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Dialog.Title>
|
||||||
|
<div className="flex h-full flex-col gap-6 px-8 text-lg">
|
||||||
|
<Link
|
||||||
|
href={disableNavigation ? "" : "/"}
|
||||||
|
className={clsx(
|
||||||
|
"w-fit transition duration-300 ease-in-out",
|
||||||
|
path === "/" &&
|
||||||
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
{checkAccess(user, ["student", "teacher", "developer"]) && (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href={disableNavigation ? "" : "/exam"}
|
||||||
|
className={clsx(
|
||||||
|
"w-fit transition duration-300 ease-in-out",
|
||||||
|
path === "/exam" &&
|
||||||
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Exams
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={disableNavigation ? "" : "/exercises"}
|
||||||
|
className={clsx(
|
||||||
|
"w-fit transition duration-300 ease-in-out",
|
||||||
|
path === "/exercises" &&
|
||||||
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Exercises
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href={disableNavigation ? "" : "/stats"}
|
||||||
|
className={clsx(
|
||||||
|
"w-fit transition duration-300 ease-in-out",
|
||||||
|
path === "/stats" &&
|
||||||
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Stats
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={disableNavigation ? "" : "/record"}
|
||||||
|
className={clsx(
|
||||||
|
"w-fit transition duration-300 ease-in-out",
|
||||||
|
path === "/record" &&
|
||||||
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Record
|
||||||
|
</Link>
|
||||||
|
{checkAccess(user, [
|
||||||
|
"admin",
|
||||||
|
"developer",
|
||||||
|
"agent",
|
||||||
|
"corporate",
|
||||||
|
"mastercorporate",
|
||||||
|
]) && (
|
||||||
|
<Link
|
||||||
|
href={disableNavigation ? "" : "/payment-record"}
|
||||||
|
className={clsx(
|
||||||
|
"w-fit transition duration-300 ease-in-out",
|
||||||
|
path === "/payment-record" &&
|
||||||
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Payment Record
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{checkAccess(user, [
|
||||||
|
"admin",
|
||||||
|
"developer",
|
||||||
|
"corporate",
|
||||||
|
"teacher",
|
||||||
|
"mastercorporate",
|
||||||
|
]) && (
|
||||||
|
<Link
|
||||||
|
href={disableNavigation ? "" : "/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>
|
||||||
|
)}
|
||||||
|
{checkAccess(user, ["admin", "developer", "agent"]) && (
|
||||||
|
<Link
|
||||||
|
href={disableNavigation ? "" : "/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
|
||||||
|
href={disableNavigation ? "" : "/profile"}
|
||||||
|
className={clsx(
|
||||||
|
"w-fit transition duration-300 ease-in-out",
|
||||||
|
path === "/profile" &&
|
||||||
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Profile
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out"
|
||||||
|
)}
|
||||||
|
onClick={logout}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/components/Modal.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import {Dialog, Transition} from "@headlessui/react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {Fragment, ReactElement} from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
className?: string;
|
||||||
|
children?: ReactElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Modal({isOpen, title, className, onClose, children}: Props) {
|
||||||
|
return (
|
||||||
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
|
<Dialog as="div" className="relative z-[200]" onClose={onClose}>
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0">
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95">
|
||||||
|
<Dialog.Panel
|
||||||
|
className={clsx(
|
||||||
|
"w-full max-w-6xl transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all",
|
||||||
|
className,
|
||||||
|
)}>
|
||||||
|
{title && (
|
||||||
|
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
|
||||||
|
{title}
|
||||||
|
</Dialog.Title>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/components/ModuleBadge.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
|
||||||
|
|
||||||
|
const ModuleBadge: React.FC<{ module: string; level?: number }> = ({ module, level }) => (
|
||||||
|
<div
|
||||||
|
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" />}
|
||||||
|
{/* do not switch to level && it will convert the 0.0 to 0*/}
|
||||||
|
{level !== undefined && (<span className="text-sm">{level.toFixed(1)}</span>)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ModuleBadge;
|
||||||
@@ -1,77 +1,219 @@
|
|||||||
import {Type} from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import axios from "axios";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {useRouter} from "next/router";
|
import FocusLayer from "@/components/FocusLayer";
|
||||||
import {Button} from "primereact/button";
|
import { preventNavigation } from "@/utils/navigation.disabled";
|
||||||
import {Menubar} from "primereact/menubar";
|
import { useRouter } from "next/router";
|
||||||
import {MenuItem} from "primereact/menuitem";
|
import { BsList, BsQuestionCircle, BsQuestionCircleFill } from "react-icons/bs";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import moment from "moment";
|
||||||
|
import MobileMenu from "./MobileMenu";
|
||||||
|
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";
|
||||||
|
import { Module } from "@/interfaces";
|
||||||
|
import Badge from "./Low/Badge";
|
||||||
|
|
||||||
|
import {
|
||||||
|
BsArrowRepeat,
|
||||||
|
BsBook,
|
||||||
|
BsCheck,
|
||||||
|
BsCheckCircle,
|
||||||
|
BsClipboard,
|
||||||
|
BsHeadphones,
|
||||||
|
BsMegaphone,
|
||||||
|
BsPen,
|
||||||
|
BsXCircle,
|
||||||
|
} from "react-icons/bs";
|
||||||
interface Props {
|
interface Props {
|
||||||
profilePicture: string;
|
user: User;
|
||||||
userType: Type;
|
navDisabled?: boolean;
|
||||||
timer?: number;
|
focusMode?: boolean;
|
||||||
showExamEnd?: boolean;
|
onFocusLayerMouseEnter?: () => void;
|
||||||
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
export default function Navbar({profilePicture, userType, timer, showExamEnd = false}: Props) {
|
export default function Navbar({
|
||||||
const router = useRouter();
|
user,
|
||||||
|
path,
|
||||||
|
navDisabled = false,
|
||||||
|
focusMode = false,
|
||||||
|
onFocusLayerMouseEnter,
|
||||||
|
}: Props) {
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
|
||||||
|
const [isTicketOpen, setIsTicketOpen] = useState(false);
|
||||||
|
|
||||||
const logout = async () => {
|
const router = useRouter();
|
||||||
axios.post("/api/logout").finally(() => {
|
|
||||||
router.push("/login");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const items: MenuItem[] = [
|
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||||
{
|
|
||||||
label: "Home",
|
|
||||||
icon: "pi pi-fw pi-home",
|
|
||||||
url: "/",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Account",
|
|
||||||
icon: "pi pi-fw pi-user",
|
|
||||||
url: "/profile",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Exam",
|
|
||||||
icon: "pi pi-fw pi-plus-circle",
|
|
||||||
url: "/exam",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Users",
|
|
||||||
icon: "pi pi-fw pi-users",
|
|
||||||
items: [
|
|
||||||
...(userType === "student" ? [] : [{label: "List", icon: "pi pi-fw pi-users", url: "/users"}]),
|
|
||||||
{label: "Stats", icon: "pi pi-fw pi-chart-pie", url: "/stats"},
|
|
||||||
{label: "History", icon: "pi pi-fw pi-history", url: "/history"},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Logout",
|
|
||||||
icon: "pi pi-fw pi-power-off",
|
|
||||||
command: logout,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const endTimer = timer && (
|
const expirationDateColor = (date: Date) => {
|
||||||
<span className="pr-2 font-semibold">
|
const momentDate = moment(date);
|
||||||
{Math.floor(timer / 60) < 10 ? "0" : ""}
|
const today = moment(new Date());
|
||||||
{Math.floor(timer / 60)}:{timer % 60 < 10 ? "0" : ""}
|
|
||||||
{timer % 60}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
const endNewExam = (
|
if (today.add(1, "days").isAfter(momentDate))
|
||||||
<Link href="/exam" className="pr-2">
|
return "!bg-mti-red-ultralight border-mti-red-light";
|
||||||
<Button text label="Exam" severity="secondary" size="small" />
|
if (today.add(3, "days").isAfter(momentDate))
|
||||||
</Link>
|
return "!bg-mti-rose-ultralight border-mti-rose-light";
|
||||||
);
|
if (today.add(7, "days").isAfter(momentDate))
|
||||||
|
return "!bg-mti-orange-ultralight border-mti-orange-light";
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
const showExpirationDate = () => {
|
||||||
<div className="bg-neutral-100 z-10 w-full p-2">
|
if (!user.subscriptionExpirationDate) return false;
|
||||||
<Menubar model={items} end={showExamEnd ? endNewExam : endTimer} />
|
|
||||||
</div>
|
const momentDate = moment(user.subscriptionExpirationDate);
|
||||||
);
|
const today = moment(new Date());
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
const badges = [
|
||||||
|
{
|
||||||
|
module: "reading",
|
||||||
|
icon: () => <BsBook className="h-4 w-4 text-white" />,
|
||||||
|
achieved: user.levels.reading >= user.desiredLevels.reading,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
module: "listening",
|
||||||
|
icon: () => <BsHeadphones className="h-4 w-4 text-white" />,
|
||||||
|
achieved: user.levels.listening >= user.desiredLevels.listening,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "writing",
|
||||||
|
icon: () => <BsPen className="h-4 w-4 text-white" />,
|
||||||
|
achieved: user.levels.writing >= user.desiredLevels.writing,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "speaking",
|
||||||
|
icon: () => <BsMegaphone className="h-4 w-4 text-white" />,
|
||||||
|
achieved: user.levels.speaking >= user.desiredLevels.speaking,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "level",
|
||||||
|
icon: () => <BsClipboard className="h-4 w-4 text-white" />,
|
||||||
|
achieved: user.levels.level >= user.desiredLevels.level,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
isOpen={isTicketOpen}
|
||||||
|
onClose={() => setIsTicketOpen(false)}
|
||||||
|
title="Submit a ticket"
|
||||||
|
>
|
||||||
|
<TicketSubmission
|
||||||
|
user={user}
|
||||||
|
page={router.asPath}
|
||||||
|
onClose={() => setIsTicketOpen(false)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<MobileMenu
|
||||||
|
disableNavigation={disableNavigation}
|
||||||
|
path={path}
|
||||||
|
isOpen={isMenuOpen}
|
||||||
|
onClose={() => setIsMenuOpen(false)}
|
||||||
|
user={user}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4">
|
||||||
|
<Link
|
||||||
|
href={disableNavigation ? "" : "/"}
|
||||||
|
className=" flex items-center gap-8 md:px-8"
|
||||||
|
>
|
||||||
|
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" />
|
||||||
|
<h1 className="-md:hidden w-1/6 text-2xl font-bold">EnCoach</h1>
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center justify-end gap-4 md:mr-8 md:w-5/6">
|
||||||
|
{user.type === "student" &&
|
||||||
|
badges.map((badge) => (
|
||||||
|
<div
|
||||||
|
key={badge.module}
|
||||||
|
className={`${badge.achieved ? `bg-ielts-${badge.module}`: 'bg-mti-gray-anti-flash'} flex h-8 w-8 items-center justify-center rounded-full`}
|
||||||
|
>
|
||||||
|
{badge.icon()}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* OPEN TICKET SYSTEM */}
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
"border-mti-purple-light tooltip tooltip-bottom flex h-8 w-8 flex-col items-center justify-center rounded-full border p-1",
|
||||||
|
"hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white z-20"
|
||||||
|
)}
|
||||||
|
data-tip="Submit a help/feedback ticket"
|
||||||
|
onClick={() => setIsTicketOpen(true)}
|
||||||
|
>
|
||||||
|
<BsQuestionCircleFill />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showExpirationDate() && (
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
!!user.subscriptionExpirationDate && !disablePaymentPage
|
||||||
|
? "/payment"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
data-tip="Expiry date"
|
||||||
|
className={clsx(
|
||||||
|
"flex w-fit cursor-pointer justify-center rounded-full border px-6 py-2 text-sm font-normal focus:outline-none",
|
||||||
|
"tooltip tooltip-bottom transition duration-300 ease-in-out",
|
||||||
|
!user.subscriptionExpirationDate
|
||||||
|
? "bg-mti-green-ultralight border-mti-green-light"
|
||||||
|
: expirationDateColor(user.subscriptionExpirationDate),
|
||||||
|
"border-mti-gray-platinum bg-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!user.subscriptionExpirationDate && "Unlimited"}
|
||||||
|
{user.subscriptionExpirationDate &&
|
||||||
|
moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href={disableNavigation ? "" : "/profile"}
|
||||||
|
className="-md:hidden flex items-center justify-end gap-6"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={user.profilePicture}
|
||||||
|
alt={user.name}
|
||||||
|
className="h-10 w-10 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
<span className="-md:hidden text-right">
|
||||||
|
{user.type === "corporate"
|
||||||
|
? `${user.corporateInformation?.companyInformation.name} |`
|
||||||
|
: ""}{" "}
|
||||||
|
{user.name} | {USER_TYPE_LABELS[user.type]}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<div
|
||||||
|
className="cursor-pointer md:hidden"
|
||||||
|
onClick={() => setIsMenuOpen(true)}
|
||||||
|
>
|
||||||
|
<BsList className="text-mti-purple-light h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{focusMode && (
|
||||||
|
<FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
||||||
77
src/components/PaymobPayment.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import {PaymentIntention} from "@/interfaces/paymob";
|
||||||
|
import {DurationUnit} from "@/interfaces/paypal";
|
||||||
|
import {User} from "@/interfaces/user";
|
||||||
|
import axios from "axios";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import {useState} from "react";
|
||||||
|
import Button from "./Low/Button";
|
||||||
|
import Input from "./Low/Input";
|
||||||
|
import Modal from "./Modal";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
currency: string;
|
||||||
|
price: number;
|
||||||
|
setIsPaymentLoading: (v: boolean) => void;
|
||||||
|
duration: number;
|
||||||
|
duration_unit: DurationUnit;
|
||||||
|
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PaymobPayment({user, price, setIsPaymentLoading, currency, duration, duration_unit, onSuccess}: Props) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleCardPayment = async () => {
|
||||||
|
try {
|
||||||
|
setIsPaymentLoading(true);
|
||||||
|
|
||||||
|
const paymentIntention: PaymentIntention = {
|
||||||
|
amount: price * 1000,
|
||||||
|
currency: "OMR",
|
||||||
|
items: [],
|
||||||
|
payment_methods: [],
|
||||||
|
customer: {
|
||||||
|
email: user.email,
|
||||||
|
first_name: user.name.split(" ")[0],
|
||||||
|
last_name: [...user.name.split(" ")].pop() || "N/A",
|
||||||
|
extras: {
|
||||||
|
re: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
billing_data: {
|
||||||
|
apartment: "N/A",
|
||||||
|
building: "N/A",
|
||||||
|
country: user.demographicInformation?.country || "N/A",
|
||||||
|
email: user.email,
|
||||||
|
first_name: user.name.split(" ")[0],
|
||||||
|
last_name: [...user.name.split(" ")].pop() || "N/A",
|
||||||
|
floor: "N/A",
|
||||||
|
phone_number: user.demographicInformation?.phone || "N/A",
|
||||||
|
state: "N/A",
|
||||||
|
street: "N/A",
|
||||||
|
},
|
||||||
|
extras: {
|
||||||
|
userID: user.id,
|
||||||
|
duration,
|
||||||
|
duration_unit,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await axios.post<{iframeURL: string}>(`/api/paymob`, paymentIntention);
|
||||||
|
|
||||||
|
router.push(response.data.iframeURL);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error starting card payment process:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button isLoading={isLoading} onClick={handleCardPayment}>
|
||||||
|
Select
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/components/PermissionList.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import {Permission} from "@/interfaces/permissions";
|
||||||
|
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {convertCamelCaseToReadable} from "@/utils/string";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
permissions: Permission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<Permission>();
|
||||||
|
|
||||||
|
const defaultColumns = [
|
||||||
|
columnHelper.accessor("type", {
|
||||||
|
header: () => <span>Type</span>,
|
||||||
|
cell: ({row, getValue}) => (
|
||||||
|
<Link
|
||||||
|
href={`/permissions/${row.original.id}`}
|
||||||
|
key={row.id}
|
||||||
|
className="underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer">
|
||||||
|
{convertCamelCaseToReadable(getValue() as string)}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function PermissionList({permissions}: Props) {
|
||||||
|
const table = useReactTable({
|
||||||
|
data: permissions,
|
||||||
|
columns: defaultColumns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="w-full flex flex-col gap-2">
|
||||||
|
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||||
|
<thead>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<th className="py-4 px-4 text-left" key={header.id}>
|
||||||
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody className="px-2">
|
||||||
|
{table.getRowModel().rows.map((row) => (
|
||||||
|
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<td className="px-4 py-2 items-center w-fit" key={cell.id}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" />}
|
|
||||||
{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" />}
|
|
||||||
{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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
90
src/components/ProfileSummary.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import {User} from "@/interfaces/user";
|
||||||
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
|
import {calculateAverageLevel} from "@/utils/score";
|
||||||
|
import {capitalize} from "lodash";
|
||||||
|
import {ReactElement} from "react";
|
||||||
|
import ProgressBar from "./Low/ProgressBar";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
items: {
|
||||||
|
icon: ReactElement;
|
||||||
|
value: string | number;
|
||||||
|
label: string;
|
||||||
|
tooltip?: string;
|
||||||
|
}[];
|
||||||
|
children?: ReactElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfileSummary({user, items}: Props) {
|
||||||
|
return (
|
||||||
|
<section className="w-full flex -md:flex-col gap-4 md:gap-8">
|
||||||
|
<img
|
||||||
|
src={user.profilePicture}
|
||||||
|
alt={user.name}
|
||||||
|
className="aspect-square h-20 md:h-64 rounded-3xl drop-shadow-xl object-cover -md:hidden"
|
||||||
|
/>
|
||||||
|
<div className="flex md:flex-col gap-4 md:py-4 w-full -md:items-center">
|
||||||
|
<img src={user.profilePicture} alt={user.name} className="aspect-square h-24 md:hidden rounded-3xl drop-shadow-xl object-cover" />
|
||||||
|
<div className="flex -md:flex-col justify-between w-full gap-8">
|
||||||
|
<div className="flex flex-col gap-2 py-2">
|
||||||
|
<h1 className="font-bold text-2xl md:text-4xl">{user.name}</h1>
|
||||||
|
<h6 className="font-normal text-base text-mti-gray-taupe">{USER_TYPE_LABELS[user.type]}</h6>
|
||||||
|
</div>
|
||||||
|
<ProgressBar
|
||||||
|
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
|
||||||
|
percentage={100}
|
||||||
|
color="purple"
|
||||||
|
className="max-w-xs w-32 md:self-end h-10 -md:hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ProgressBar
|
||||||
|
label=""
|
||||||
|
percentage={Math.round((calculateAverageLevel(user.levels) * 100) / calculateAverageLevel(user.desiredLevels))}
|
||||||
|
color="red"
|
||||||
|
className="w-full h-3 drop-shadow-lg -md:hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-between w-full mt-8 -md:hidden">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div className="flex gap-4 items-center" key={item.label}>
|
||||||
|
<div
|
||||||
|
className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl relative group tooltip tooltip-bottom"
|
||||||
|
data-tip={item.tooltip}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-bold text-xl">{item.value}</span>
|
||||||
|
<span className="font-normal text-base text-mti-gray-dim">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
|
||||||
|
percentage={Math.round((calculateAverageLevel(user.levels) * 100) / calculateAverageLevel(user.desiredLevels))}
|
||||||
|
color="purple"
|
||||||
|
className="w-full md:hidden h-8"
|
||||||
|
textClassName="!text-mti-black"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 w-full mt-4 md:hidden">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div className="flex gap-4 items-center" key={item.label}>
|
||||||
|
<div className="w-12 h-12 md:w-16 md:h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-lg md:rounded-xl">
|
||||||
|
{item.icon}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-bold text-lg md:text-xl">{item.value}</span>
|
||||||
|
<span className="font-normal text-sm md:text-base text-mti-gray-dim">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/components/RadialProgressBar.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
|
||||||
|
interface RadialProgressBarProps {
|
||||||
|
percentage: number;
|
||||||
|
text: string;
|
||||||
|
color: string;
|
||||||
|
spanClassName?: string;
|
||||||
|
size?: number;
|
||||||
|
strokeWidth?: number;
|
||||||
|
strokeOpacity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// https://gist.github.com/eYinka/873be69fae3ef27b103681b8a9f5e379 Omarmarei's answer
|
||||||
|
const RadialProgressBar: React.FC<RadialProgressBarProps> = ({
|
||||||
|
percentage,
|
||||||
|
text,
|
||||||
|
color,
|
||||||
|
spanClassName = "",
|
||||||
|
size = 100,
|
||||||
|
strokeWidth = 10,
|
||||||
|
strokeOpacity = 0.5
|
||||||
|
}) => {
|
||||||
|
const radius = (size - strokeWidth) / 2;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
const offset = circumference - (percentage / 100) * circumference;
|
||||||
|
return (
|
||||||
|
<div className='relative flex items-center justify-center' style={{ width: size, height: size}}>
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox={`0 0 ${size} ${size}`
|
||||||
|
}
|
||||||
|
className="circular-progress-bar"
|
||||||
|
>
|
||||||
|
<circle className="circle-bg" stroke="#e6e6e6" strokeWidth={strokeWidth}
|
||||||
|
fill="none"
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
strokeOpacity={strokeOpacity}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
className="circle-progress"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
fill="none"
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||||
|
strokeOpacity={strokeOpacity}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className={clsx('absolute', spanClassName)}>{text}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default RadialProgressBar;
|
||||||
48
src/components/SegmentedProgressBar.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
interface Segment {
|
||||||
|
percentage: number;
|
||||||
|
subtitle: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
interface SegmentedProgressBarProps {
|
||||||
|
segments: Segment[];
|
||||||
|
height?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
const SegmentedProgressBar: React.FC<SegmentedProgressBarProps> = ({ segments, height=15, className="" }) => {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<div className="relative flex rounded-full overflow-hidden bg-gray-200" style={{height: `${height}px`}}>
|
||||||
|
{segments.map((segment, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={clsx(
|
||||||
|
'h-full opacity-50',
|
||||||
|
'transition-all duration-500 ease-out',
|
||||||
|
`bg-${segment.color}`,
|
||||||
|
{
|
||||||
|
'rounded-l-full': index === 0,
|
||||||
|
'rounded-r-full': index === segments.length - 1,
|
||||||
|
'rounded-none': index !== 0 && index !== segments.length - 1
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
style={{width: `${segment.percentage}%`}}
|
||||||
|
></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex text-sm justify-between">
|
||||||
|
{segments.map((segment, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex flex-col text-center w-fit"
|
||||||
|
>
|
||||||
|
<span className={clsx('font-semibold',`text-${segment.color}`)}>{segment.subtitle}</span>
|
||||||
|
<span>{`${segment.percentage}%`}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default SegmentedProgressBar;
|
||||||