Compare commits

..

271 Commits

Author SHA1 Message Date
Tiago Ribeiro
9b37b60be0 Improved the table 2024-07-30 23:22:30 +01:00
Tiago Ribeiro
4347d0cabb Merge branch 'develop' into feature/update-permission-ui 2024-07-30 23:20:16 +01:00
Tiago Ribeiro
0403773b8e Changed the IDs to now be words and allows the assignment to be like chosen 2024-07-30 23:18:50 +01:00
mzerone
8d99a6b03c ui updates for permissions. 2024-07-29 16:16:14 +02:00
Tiago Ribeiro
02320b9484 Tiny issue 2024-07-28 11:22:46 +01:00
Tiago Ribeiro
fb077fd8cc Updated the multiple choice 2024-07-27 17:43:26 +01:00
Tiago Ribeiro
b5a305485f Finalized the reading generation 2024-07-27 14:55:48 +01:00
Tiago Ribeiro
8f5b27e9ce Updated the FillBlanks to the new format 2024-07-27 14:38:45 +01:00
Tiago Ribeiro
9ef04b822a Updated the design of the feedback 2024-07-27 00:04:52 +01:00
Tiago Ribeiro
a6160c3cf0 Updated and fixed the level generation 2024-07-26 23:43:23 +01:00
Tiago Ribeiro
8f6639b7fc Finalized the Level Generation 2024-07-26 14:09:08 +01:00
Tiago Ribeiro
6a803fe137 Merge branch 'develop' of bitbucket.org:ecropdev/ielts-ui into develop 2024-07-26 11:51:03 +01:00
Carlos Mesquita
d7f6a4dde7 Undefined was causing record page to crash 2024-07-26 11:00:18 +01:00
Tiago Ribeiro
6058e510de Added the ability to generate custom level exams, still WIP in some parts 2024-07-26 10:29:36 +01:00
carlos.mesquita
7208530879 Merged in feature/ai-detection (pull request #57)
Used main branch as base branch in the last time

Approved-by: Tiago Ribeiro
2024-07-25 21:01:56 +00:00
Tiago Ribeiro
9b6c545932 Merged develop into feature/ai-detection 2024-07-25 21:00:40 +00:00
João Ramos
afb9071758 Merged in ENCOA-56_Permissions (pull request #55)
ENCOA-56 Permissions

Approved-by: Tiago Ribeiro
2024-07-25 21:00:28 +00:00
Tiago Ribeiro
d50393930e Merged develop into ENCOA-56_Permissions 2024-07-25 21:00:19 +00:00
João Ramos
03e1f2cfa3 Merged in master-corporate (pull request #54)
Master corporate

Approved-by: Tiago Ribeiro
2024-07-25 21:00:04 +00:00
Carlos Mesquita
877d2f359f Used main branch as base branch in the last time 2024-07-25 16:59:15 +01:00
Joao Ramos
45df9837e7 Added permissions to filter out the user update 2024-07-25 11:23:11 +01:00
Joao Ramos
923319051c Added code role validation 2024-07-25 09:43:11 +01:00
Joao Ramos
f6b4d6ad52 Initial approach where I replaced all the entries for checkAccess 2024-07-25 09:13:13 +01:00
Joao Ramos
19d16c9cef Added new permission system 2024-07-24 18:52:02 +01:00
Joao Ramos
daa27e41b3 Merge branch 'develop' into master-corporate 2024-07-24 09:50:53 +01:00
Joao Ramos
916fa66446 Added linked account 2024-07-24 09:25:29 +01:00
Tiago Ribeiro
10a3243756 Updated it to work with the new canges 2024-07-23 14:43:24 +01:00
Tiago Ribeiro
a1c7f70329 Solved some small issues 2024-07-22 23:35:22 +01:00
Tiago Ribeiro
bd2efb0ef5 Updated the color of the task response 2024-07-22 23:30:22 +01:00
Joao Ramos
34065f1f6e Merge branch 'develop' into master-corporate 2024-07-20 12:38:52 +01:00
Tiago Ribeiro
41873f80d7 ENCOA-66: Payment record not sorting 2024-07-18 15:45:28 +01:00
João Ramos
a1b67c017d Merged in ENCOA-57_EditExamsGenerate (pull request #53)
ENCOA-57 EditExamsGenerate

Approved-by: Tiago Ribeiro
2024-07-18 09:32:05 +00:00
Joao Ramos
13fd7e1ee5 Updated Level Generation 2024-07-17 23:44:53 +01:00
Joao Ramos
4996417218 Fixed sentence header mapping 2024-07-17 23:00:16 +01:00
Joao Ramos
60d436b5b9 Fixed wrong count 2024-07-13 17:25:10 +01:00
Joao Ramos
8d39a20267 Fixed broken list 2024-07-13 17:22:55 +01:00
Joao Ramos
5d46d7e453 Added initial support for "mastercorporate" 2024-07-13 17:19:42 +01:00
Joao Ramos
15f9fb320d Changed approach on available type selection 2024-07-09 23:42:16 +01:00
Joao Ramos
494fc9bab6 Changed approach on available type selection 2024-07-07 16:59:49 +01:00
Joao Ramos
0c5c024098 Added Match Sentences to Reading Generation 2024-07-07 16:43:14 +01:00
Joao Ramos
903a567805 Fixed error with update part 2024-07-04 01:35:10 +01:00
Joao Ramos
df3929d5e6 Added parse for speaking generation 2024-07-04 01:34:07 +01:00
Joao Ramos
6d62500596 Added write the blanks for Listening 2024-07-04 01:23:59 +01:00
Joao Ramos
e5e4e87752 Added Listening Multiple Choice Edit 2024-07-04 01:15:33 +01:00
Joao Ramos
0b3e686f3f Match sentence log 2024-07-04 00:25:04 +01:00
Joao Ramos
3da87cce60 Added write blanks edit 2024-07-04 00:19:07 +01:00
Joao Ramos
c9daba17e1 Added Fill Blanks Edits + True False Edit 2024-07-03 11:13:13 +01:00
João Ramos
5cfd6d56a6 Merged in bug-fixing-19-Jun-24 (pull request #52)
Bug fixing 19 Jun 24

Approved-by: Tiago Ribeiro
2024-06-25 11:47:24 +00:00
Joao Ramos
ec8c06ca94 Filtered out the fields from the UserCard in the list wouldn't have anything to display 2024-06-24 11:12:16 +01:00
Joao Ramos
77a22b3ab3 Added a screen to display the desired levels for the students of a specific corporate 2024-06-22 00:16:30 +01:00
Joao Ramos
e79139174b Added an initial filter for corporate on records 2024-06-21 23:22:49 +01:00
Joao Ramos
61a86394ed Added persistance to the selected user record 2024-06-20 23:20:52 +01:00
Joao Ramos
f6741dd80e Added date filter to code list 2024-06-20 22:57:34 +01:00
Joao Ramos
ce6708be6e Minor fixing on a duplicated key on table 2024-06-20 22:34:02 +01:00
Joao Ramos
b62cae2e3a Filtered done ticket out of the view 2024-06-19 23:14:05 +01:00
Joao Ramos
d73b6d9d12 Added badges for students 2024-06-19 23:00:43 +01:00
Joao Ramos
c11906a395 Standardized the access to the list of users 2024-06-19 21:59:35 +01:00
Joao Ramos
a29b0b56d9 Changed to the correct format for CSV 2024-06-19 21:39:27 +01:00
Tiago Ribeiro
53dbf99fba Fixed some more issues with the Speaking 2024-06-18 22:16:31 +01:00
Tiago Ribeiro
cb49e15cb0 Updated the speaking and interactive speaking to the new format 2024-06-18 10:02:03 +01:00
Tiago Ribeiro
0eddded560 Updated part of the speaking accordingly 2024-06-13 18:17:07 +01:00
Tiago Ribeiro
11c6f70576 Updated a simple bug 2024-06-11 22:44:00 +01:00
Tiago Ribeiro
6712e89c47 Updated the format of the interactive speaking 2024-06-11 11:20:54 +01:00
Tiago Ribeiro
9959cf4294 Removed the persistence for the Speaking exam for now 2024-06-08 10:39:26 +01:00
Tiago Ribeiro
daec246835 Updated the Level Exam to work based on Parts 2024-06-07 13:25:18 +01:00
Tiago Ribeiro
8ea97ee944 Added a new feature to check for and register inactivity during an exam 2024-06-04 22:18:45 +01:00
Tiago Ribeiro
975f4c8285 Updated Firebase to use a service account depending on the environment 2024-05-29 09:06:46 +01:00
Tiago Ribeiro
f0b85409c9 Merge branch 'main' into develop 2024-05-27 16:37:09 +01:00
Tiago Ribeiro
bdd862c633 Updated the state to be active on payment 2024-05-27 13:08:11 +01:00
Tiago Ribeiro
4166781f7e Improved some issues with the payment 2024-05-27 13:05:38 +01:00
Tiago Ribeiro
1f8e9106de Updated the code to set the expiry date to the end of the day 2024-05-27 12:19:25 +01:00
Tiago Ribeiro
9e651358d5 Updated the code to return a 400 when it is not a success 2024-05-27 12:16:08 +01:00
Tiago Ribeiro
5aed336c96 Added a log 2024-05-27 11:02:11 +01:00
Tiago Ribeiro
85b94512e9 Merge branch 'develop' into ENCOA-38/add-validity-date-for-discounts 2024-05-23 19:22:31 +01:00
Tiago Ribeiro
906646ebce Created the validity dates for discounts 2024-05-23 19:21:52 +01:00
Tiago Ribeiro
96108a4958 Reverted to have checks 2024-05-23 17:22:57 +01:00
Tiago Ribeiro
fb449f2054 Updated the status when the transaction is not successful 2024-05-21 15:40:18 +01:00
Tiago Ribeiro
d5ee3d9519 Added a log for debugging 2024-05-21 15:35:57 +01:00
Tiago Ribeiro
4e20ec6575 Removed a check from the webhook 2024-05-21 12:04:31 +01:00
Tiago Ribeiro
836b674076 Added some changes to the propagate corporate changes 2024-05-21 11:21:14 +01:00
Tiago Ribeiro
5086c6fb09 Solved a visual bug 2024-05-21 11:09:36 +01:00
Tiago Ribeiro
489c9c3b7e Possibly solved part of the issue with speaking 2024-05-20 21:28:45 +01:00
Tiago Ribeiro
e3ded29e77 Merge branch 'develop' 2024-05-20 21:09:43 +01:00
Tiago Ribeiro
16419a5584 Fixed a bug introduced on the last one 2024-05-20 11:23:52 +01:00
Tiago Ribeiro
3e3b24cc30 Solved a bug for level test 2024-05-20 11:18:46 +01:00
Tiago Ribeiro
841698ba10 Updated the profile to also have the focus in it 2024-05-20 11:13:09 +01:00
Tiago Ribeiro
d50904611c Added a missing space 2024-05-16 15:42:13 +01:00
Tiago Ribeiro
e77fd16d26 Added a space to it 2024-05-16 15:03:31 +01:00
Tiago Ribeiro
649f24e4ae Updated the showcase 2024-05-16 14:51:19 +01:00
Tiago Ribeiro
2f0cbfe74e Removed the billing details modal 2024-05-16 14:30:44 +01:00
Tiago Ribeiro
d022bd078a Updated the currencies to have OMR as well 2024-05-16 13:44:27 +01:00
Tiago Ribeiro
c18afee9ad Updated the packages 2024-05-16 13:34:18 +01:00
Tiago Ribeiro
a65b72adad Updated the payment integration to be dynamic 2024-05-16 13:30:38 +01:00
Tiago Ribeiro
e13aea9f7d Updated the table 2024-05-15 23:41:45 +01:00
Tiago Ribeiro
2920fa7f3a Updated the payment to work with Paymob 2024-05-15 22:59:51 +01:00
Tiago Ribeiro
7af96ecccc Created a webhook to allow the transaction to be completed 2024-05-15 00:25:44 +01:00
Tiago Ribeiro
70716b3483 Merge branch 'develop' into feature/ENCOA-42/update-payment-system-paymob 2024-05-13 11:02:35 +01:00
Tiago Ribeiro
d7bb64e7e0 Merge branch 'main' into develop 2024-05-13 11:02:16 +01:00
Tiago Ribeiro
dd19b5746c Updated the times listened to not be global 2024-05-13 11:01:37 +01:00
Tiago Ribeiro
f967282f71 Started implementing the Paymob integration 2024-05-13 10:38:05 +01:00
Tiago Ribeiro
8b2459c304 ENCOA-37: Added the ability for users to download a list of the shown users 2024-05-08 15:46:24 +01:00
Tiago Ribeiro
72fb934d4f Updated the propagated changes to also affect expiry date changes for corporates 2024-05-07 23:53:15 +01:00
Tiago Ribeiro
ed0b8bcb99 ENCOA-36: Allow Corporate Users to select invitation expiry date lower than theirs 2024-05-07 11:42:05 +01:00
Tiago Ribeiro
6f211d8435 Added the corporate user balance to the User Card 2024-05-07 09:48:49 +01:00
Tiago Ribeiro
b59589b855 ENCOA-26: Student profile count stats was invalid 2024-05-07 09:07:17 +01:00
Tiago Ribeiro
db20feaa00 Added the ID of the multiple choice question 2024-05-07 08:48:16 +01:00
Tiago Ribeiro
8fc2cf571e Disabled the Play Again for admins 2024-05-07 08:37:09 +01:00
Tiago Ribeiro
3128fea8c9 Merge branch 'develop' 2024-05-05 12:03:36 +01:00
Tiago Ribeiro
0e53b4a454 Added the ability to view archived assignments and unarchive them 2024-05-05 12:02:53 +01:00
Tiago Ribeiro
cbb61d18fe Made sure to only send the e-mail for previously invited users instead of also creating a new code 2024-04-30 14:59:55 +01:00
Tiago Ribeiro
dff51cf6ea Merged from develop 2024-04-28 20:20:25 +01:00
Tiago Ribeiro
15dbadcc53 Solved a small bug 2024-04-28 20:19:21 +01:00
Tiago Ribeiro
624a3fb88e Created a discount system related to the user's e-mail address and applied to the packages 2024-04-26 20:41:46 +01:00
Tiago Ribeiro
00feee2179 Disabled the short length exams 2024-04-24 08:53:53 +01:00
Tiago Ribeiro
0f8f9bc05b Added a button to review the exam from the selected module forward 2024-04-21 21:29:43 +01:00
Tiago Ribeiro
f76b7578a6 Disabled the editing of the country manager of a corporate from the payment record 2024-04-21 12:22:02 +01:00
Tiago Ribeiro
1a17689cd2 Updated the code to name the field companyArabName and made it so it returns it when arabic 2024-04-21 00:37:08 +01:00
Tiago Ribeiro
a958e2ff0d Added a field for the agent where they can put their arab name 2024-04-20 16:01:35 +01:00
Tiago Ribeiro
36b861266f ENCOA-18: Improve the loading of the company names on the Group and Users lists 2024-04-18 16:03:09 +01:00
Tiago Ribeiro
771262fc18 ENCOA-16: Added a creation date to the Code List 2024-04-18 14:18:29 +01:00
Tiago Ribeiro
0f03ce95e7 Remove a console.log 2024-04-18 11:27:40 +01:00
Tiago Ribeiro
6a6e010daa ENCOA-13: Add filter for "In Use" and "Unused" for the Code List
ENCOA-15: Checkbox to select/unselect all for the Code List
2024-04-18 09:40:47 +01:00
Tiago Ribeiro
13496387c4 ENCOA-6: Updated the Linked Corporate column in the Group List 2024-04-11 11:29:08 +01:00
Tiago Ribeiro
4ecb21e0ae ENCOA-4: Added the ability to filter by Creator on the Code List 2024-04-11 11:23:13 +01:00
Tiago Ribeiro
8663fe13bd Prevented users from deleted in use codes 2024-04-11 10:56:40 +01:00
Tiago Ribeiro
de4638bc46 - ENCOA-3: Added the ability to delete multiple codes at once;
- ENCOA-5 Added a column for the Creator on the code list;
2024-04-11 10:22:02 +01:00
Tiago Ribeiro
c9740fe8ee ENCOA-1: Added expired teachers on the Admin dashboard 2024-04-11 09:53:34 +01:00
Tiago Ribeiro
9b9b67c6cd Added a "Linked Corporate" column to the Groups list 2024-04-05 09:04:40 +01:00
Tiago Ribeiro
fe2abaacae Added a list for codes, for users to delete unused ones 2024-04-04 23:05:12 +01:00
João Ramos
11e2ea3249 Merged in bug-fixing-2-Abril (pull request #51)
Minor change regarding user id on the pdf footer

Approved-by: Tiago Ribeiro
2024-04-04 08:25:39 +00:00
Tiago Ribeiro
2de4b7c715 Show the Company Name of the Teachers and students that are linked in the User List View 2024-04-03 22:46:31 +01:00
Joao Ramos
a8ffebe944 Minor change regarding user id on the pdf footer 2024-04-02 22:19:32 +01:00
Tiago Ribeiro
9ab7c3ed59 Merge branch 'develop' 2024-04-02 14:53:48 +01:00
Tiago Ribeiro
f374d91ef8 Solved an issue where the company name of country managers wasn't able to be updated 2024-04-02 10:53:34 +01:00
Tiago Ribeiro
62ecc4e395 Added the ability to edit the options of a Level Exam 2024-04-02 10:32:59 +01:00
Tiago Ribeiro
46764cacfa Updated the stats 2024-04-02 00:25:49 +01:00
Tiago Ribeiro
0b9e1bd734 Merged in develop (pull request #50)
Update 31/02/2024
2024-03-31 22:33:55 +00:00
João Ramos
bddb2ed18e Merged in bug-fixing-30-MAR (pull request #49)
Added missing cards

Approved-by: Tiago Ribeiro
2024-03-31 22:33:15 +00:00
Joao Ramos
e8fbeff77a Added missing cards 2024-03-30 14:43:08 +00:00
Tiago Ribeiro
b64593df90 Solved a problem with the record page not being able to reload 2024-03-28 12:28:24 +00:00
Tiago Ribeiro
2657cb409c Solved a bug where it would not send the correct link to the e-mail 2024-03-28 08:21:45 +00:00
Tiago Ribeiro
329ed573b3 Improved a bit more the error prevention 2024-03-26 21:48:55 +00:00
Tiago Ribeiro
bb7558afb8 Updated the writing evaluation to use the different endpoints 2024-03-26 21:46:53 +00:00
Tiago Ribeiro
259ed03ee4 Solved a bug where users could change their e-mail to another user's email 2024-03-26 16:13:39 +00:00
Tiago Ribeiro
bf6c805487 Updated the Group List to show the name of the corporate 2024-03-26 14:03:58 +00:00
Tiago Ribeiro
1086e78936 Updated the MatchSentences exercise to work better now 2024-03-26 00:42:39 +00:00
Tiago Ribeiro
7d0d930140 Updated the Listening partial to not show the introductory audio 2024-03-25 01:34:58 +00:00
Tiago Ribeiro
f02fff55e7 Solved the exercise counter bug 2024-03-25 01:16:56 +00:00
Tiago Ribeiro
08e71c4dd8 Updated the ID of the matchSentences when generating 2024-03-25 00:47:03 +00:00
João Ramos
6f5a74844c Merged in pdf-bullet-points (pull request #48)
Added support for PDF bulletpoints

Approved-by: Tiago Ribeiro
2024-03-24 23:51:53 +00:00
João Ramos
c4011cd456 Merged develop into pdf-bullet-points 2024-03-24 23:43:42 +00:00
Joao Ramos
5ef2568aa5 Added support for PDF bulletpoints 2024-03-24 23:42:02 +00:00
Tiago Ribeiro
6d817e6d27 Added Match Sentences as a possible exercise type for Reading 2024-03-24 23:32:14 +00:00
Tiago Ribeiro
5decfb098d Removed the "Correct" and stuff from the Finish for the Writing and Speaking 2024-03-24 12:28:42 +00:00
Tiago Ribeiro
c2b6be4425 Solved the solution duplication bug 2024-03-24 12:22:52 +00:00
Tiago Ribeiro
f320fee416 This is better 2024-03-24 03:24:12 +00:00
Tiago Ribeiro
445e486cd2 Added a filter that should not exist but whatever 2024-03-24 03:23:01 +00:00
Tiago Ribeiro
ee26b50cf6 Helped solve a bug where it would get stuck 2024-03-24 03:18:00 +00:00
Tiago Ribeiro
22f2b43692 Prevented the bug where the application is crashing 2024-03-24 02:32:12 +00:00
Tiago Ribeiro
29b2c8b3b8 Updated the code to solve the double stats creation 2024-03-24 00:46:42 +00:00
Tiago Ribeiro
51cc1e3f36 Oops 2024-03-23 18:40:37 +00:00
Tiago Ribeiro
d9fce10538 Updated the level to be out of its total and not 9.0 2024-03-23 18:30:43 +00:00
Tiago Ribeiro
bd74313bd5 Updated the radial result accordingly 2024-03-23 18:28:12 +00:00
Tiago Ribeiro
18df890ef9 Updated the PDF report to show the level instead of the score 2024-03-23 17:16:52 +00:00
Tiago Ribeiro
13ebb9bbd8 Solved a bug where the speaking and interactive speaking were not being correctly evaluated 2024-03-23 15:43:25 +00:00
João Ramos
38c0c823e1 Merged in bug-fixing-19-MAR (pull request #47)
Bug fixing 19 MAR

Approved-by: Tiago Ribeiro
2024-03-22 11:17:43 +00:00
Tiago Ribeiro
b50e15d1d9 Merge branch 'develop' into bug-fixing-19-MAR 2024-03-22 11:15:30 +00:00
Tiago Ribeiro
969698d8b8 Updated the PDF report to give the value out of 9.0 2024-03-22 09:07:36 +00:00
Tiago Ribeiro
7d83ebc5c5 Maybe this helps I guess 2024-03-21 16:30:01 +00:00
Tiago Ribeiro
e99650ecd8 Removed unused console.logs 2024-03-21 11:28:09 +00:00
João Ramos
7287a9ce9a Merged develop into bug-fixing-19-MAR 2024-03-21 11:21:28 +00:00
Joao Ramos
8cc7e6a57d Removed change setup for debug 2024-03-21 10:57:55 +00:00
Joao Ramos
0a24cb9978 Added PDF Manuals 2024-03-21 10:56:56 +00:00
Joao Ramos
a5c1286748 Removed decimals from export pdf 2024-03-21 10:48:46 +00:00
Tiago Ribeiro
06684a4900 Added the exam information to the ticket submission 2024-03-20 21:24:09 +00:00
Tiago Ribeiro
1823538058 Added a link for admins to go to the CMS 2024-03-20 12:46:55 +00:00
Joao Ramos
60ccc822b5 Fixed missing threshold 2024-03-19 19:22:28 +00:00
Joao Ramos
9abd69c5e5 Stats and Records are now hidden for country managers 2024-03-19 19:01:37 +00:00
Joao Ramos
2667891bdd If the subscrition is unlimited, do not provide the link 2024-03-19 18:57:10 +00:00
Tiago Ribeiro
65485a0d1f Solved a problem with the API call 2024-03-13 23:31:11 +00:00
Tiago Ribeiro
74dd96d000 Applied the same fix for other pages 2024-03-13 09:21:16 +00:00
Tiago Ribeiro
49ee3c45e5 Merge branch 'develop' 2024-03-13 09:19:26 +00:00
Tiago Ribeiro
49d2680a07 Solved a bug with the redirection 2024-03-13 09:18:14 +00:00
Tiago Ribeiro
9dac7fd19e Merge branch 'develop' 2024-03-12 19:06:18 +00:00
Tiago Ribeiro
528299571c Fixed a small bug 2024-03-12 19:05:44 +00:00
Tiago Ribeiro
dcc630b8e5 Merge branch 'develop' 2024-03-12 17:56:57 +00:00
João Ramos
be5125e5b0 Merged in bug-fixing-11-MAR (pull request #46)
Bug fixing 11 MAR

Approved-by: Tiago Ribeiro
2024-03-12 17:51:36 +00:00
Joao Ramos
0adf45c6ad Added propagate status changes 2024-03-12 15:52:10 +00:00
Joao Ramos
d9b93a3470 Added default value for postal_code 2024-03-11 17:13:48 +00:00
Joao Ramos
83e4173750 removed broken debugger 2024-03-11 17:05:20 +00:00
Joao Ramos
e2d5f6ac9d Removed debugger; 2024-03-11 17:04:10 +00:00
Joao Ramos
37c3c6f7f4 Updated redirect implementation 2024-03-11 17:00:38 +00:00
Joao Ramos
3b4dfb9648 Merge branch 'develop' 2024-03-11 15:45:49 +00:00
Joao Ramos
330c177ff9 Paypal integration improvements 2024-03-07 11:18:48 +00:00
Tiago Ribeiro
0cff310354 Turned the e-mails to be dependent on the environment 2024-03-07 10:21:13 +00:00
Joao Ramos
87a1d7c288 Minor imporvements and logs 2024-03-06 18:59:11 +00:00
Joao Ramos
8e1fe15a24 Fixed loading blocking the paypal box 2024-03-06 11:38:08 +00:00
Joao Ramos
c95c0eff9b Removed vault: true from paypal as requested 2024-03-06 11:03:31 +00:00
Joao Ramos
eaf94f458a Added console.error on RAAS 2024-03-05 18:18:26 +00:00
Tiago Ribeiro
ba85596e79 Merged in develop (pull request #45)
Update - 05/03/2024
2024-03-05 17:29:33 +00:00
João Ramos
c6a478c406 Merged in feature-paypal-simple (pull request #44)
Improvements on the Paypal Integration

Approved-by: Tiago Ribeiro
2024-03-05 16:04:28 +00:00
Joao Ramos
2a27fbd02f Merge branch 'develop' into feature-paypal-simple 2024-03-05 14:45:51 +00:00
Joao Ramos
a86ed9f76c Added RAAS api and tracking
Improvements on paypal UI
2024-03-05 14:37:02 +00:00
Tiago Ribeiro
20b52d049d Updated the default to be 100 2024-03-04 00:14:04 +00:00
Tiago Ribeiro
165e33b188 Updated an error with the session 2024-03-03 12:01:05 +00:00
João Ramos
04cbcbc4cb Merged in feature-homepage-contacts-languages (pull request #43)
Feature homepage contacts languages

Approved-by: Tiago Ribeiro
2024-02-29 23:54:04 +00:00
Tiago Ribeiro
2feb9223c1 Merged develop into feature-homepage-contacts-languages 2024-02-29 23:53:42 +00:00
Joao Ramos
02d2d07f6c Paypal tab is now only displayed to admin or devs 2024-02-29 21:33:47 +00:00
Joao Ramos
ecd66d61f2 Updated exam abandon message 2024-02-29 21:33:07 +00:00
Joao Ramos
424b72efaf Uppercased the country code to prevent API errors 2024-02-29 18:35:30 +00:00
Joao Ramos
79e51d6294 Added support for the homepage languages 2024-02-29 18:26:41 +00:00
Tiago Ribeiro
773480875f Extremely wrong approach, but want to test 2024-02-28 22:44:18 +00:00
Joao Ramos
96d1b85f56 Removed HeaderGroup types on CSV export 2024-02-28 22:34:58 +00:00
Joao Ramos
cf20920fd8 Updated possible HeaderGroup types on CSV export 2024-02-28 22:29:07 +00:00
Joao Ramos
4114971244 Prevented date from wrapping 2024-02-28 20:01:43 +00:00
João Ramos
bee20388d9 Merged in feature-paypal-payments (pull request #40)
Added integration for paypal payments
2024-02-27 14:20:35 +00:00
João Ramos
bd97529658 Merged in feature-tickets-with-admin (pull request #42)
Added corporate display to Tickets table

Approved-by: Tiago Ribeiro
2024-02-27 14:17:43 +00:00
Tiago Ribeiro
d3c24d738c Merged develop into feature-tickets-with-admin 2024-02-27 14:17:28 +00:00
João Ramos
eac43a160d Merged in tickets-source (pull request #41)
Added Approach to accept ticket source

Approved-by: Tiago Ribeiro
2024-02-27 14:17:06 +00:00
Joao Ramos
24c3f506c6 Added corporate display to Tickets table 2024-02-26 23:27:10 +00:00
Joao Ramos
3e13ed5830 Added Approach to accept ticket source 2024-02-26 19:09:42 +00:00
Joao Ramos
9b5ff70037 Added search text to Paypal payments
Updated CSV Downlaod
Reordered the UI of the page
2024-02-26 18:46:21 +00:00
Joao Ramos
d7f1a4f6b2 Added integration for paypal payments 2024-02-25 16:59:21 +00:00
Tiago Ribeiro
b663e5c706 Updated the labels for the level 2024-02-24 22:35:13 +00:00
Tiago Ribeiro
efb99b31f2 Merge branch 'develop' 2024-02-23 15:20:10 +00:00
Tiago Ribeiro
03882d2a7e Improved some visual things requested by the client 2024-02-23 15:18:45 +00:00
Tiago Ribeiro
a3087567ea Merge branch 'develop' 2024-02-23 15:07:42 +00:00
João Ramos
e37afd5bbc Merged in feature-homepage-users (pull request #39)
Added API endpoints for agents load for the homepage

Approved-by: Alvaro Doria
2024-02-19 14:35:29 +00:00
João Ramos
cf5e827ca7 Merged in feature-archive-assignments (pull request #38)
Added approach to archive past assignments

Approved-by: Alvaro Doria
2024-02-19 14:35:20 +00:00
João Ramos
bfa9d039e2 Merged in bug-user-level-progress (pull request #37)
Bug user level progress

Approved-by: Alvaro Doria
2024-02-19 14:35:08 +00:00
Joao Ramos
62b915fbc1 Added API endpoints for agents load for the homepage 2024-02-18 18:04:54 +00:00
Joao Ramos
cdfafb3eea Added approach to archive past assignments 2024-02-18 11:46:08 +00:00
Joao Ramos
29cae5c3d2 Stats for Level exam are now being properly calculated 2024-02-18 11:09:25 +00:00
Joao Ramos
04f97b62c3 Filtered out level from students history display 2024-02-17 14:22:46 +00:00
Joao Ramos
52d309e7f4 Fixed NaN display on level progress 2024-02-17 10:43:55 +00:00
Tiago Ribeiro
dbf5b17f64 Merge branch 'develop' 2024-02-15 16:10:06 +00:00
Tiago Ribeiro
703fb0df5f Updated the generic Listening intro 2024-02-15 14:07:30 +00:00
Tiago Ribeiro
be4d2de76f Updated the module title to keep in mind the previous time spent 2024-02-14 11:31:35 +00:00
Tiago Ribeiro
44c61c2e5d Fixed a bug with the module evaluation, no idea why it was happening 2024-02-14 11:19:55 +00:00
João Ramos
764064bc28 Merged in feature-ticket-badge (pull request #36)
Added a badge with the amount of pending tickets assigned to the user

Approved-by: Tiago Ribeiro
2024-02-14 00:06:27 +00:00
Tiago Ribeiro
d87de9fea9 Updated the styling a little bit 2024-02-14 00:05:08 +00:00
Joao Ramos
b63ba3f316 Added a badge with the amount of pending tickets assigned to the user 2024-02-13 17:29:55 +00:00
Tiago Ribeiro
64b1d9266e Updated the Speaking Generation to save the topic as well 2024-02-13 16:26:56 +00:00
Tiago Ribeiro
b7cd1fb141 Merge branch 'feature/user-choose-topics' into develop 2024-02-13 16:07:12 +00:00
Tiago Ribeiro
30cb2f460c Changed the label on the Save button 2024-02-13 16:05:13 +00:00
Tiago Ribeiro
6a38b7a32e Updated the exam selection to get exams related to the user's topic preference 2024-02-13 16:04:46 +00:00
Joao Ramos
2a1b5236ee Revert "Updated the label for the Tickets button"
This reverts commit a99f6fd20e.
2024-02-13 15:43:24 +00:00
Joao Ramos
a99f6fd20e Updated the label for the Tickets button 2024-02-13 15:37:37 +00:00
Tiago Ribeiro
c0c9d22864 Added the ability for a user to select their preferred topics 2024-02-13 11:39:19 +00:00
Tiago Ribeiro
718782cfd5 Merged in develop (pull request #35)
Updated the main branch - 13/02/24
2024-02-13 00:52:45 +00:00
Tiago Ribeiro
f643430068 Merge branch 'develop' into feature/user-choose-topics 2024-02-13 00:45:56 +00:00
João Ramos
2823af7ef8 Merged in privacy-policy (pull request #33)
Added checkbox for accepted terms

Approved-by: Tiago Ribeiro
2024-02-13 00:44:55 +00:00
Tiago Ribeiro
57116f50e8 Hard coded the website link because there are some problems with ENV variables in the frontend 2024-02-13 00:44:16 +00:00
Tiago Ribeiro
e382a09ae8 Added the key "topic" to Writing, Speaking and Interactive Speaking exercises 2024-02-13 00:42:09 +00:00
João Ramos
b4c7c9a911 Merged in contact-us-form (pull request #32)
Contact Us support

Approved-by: Tiago Ribeiro
2024-02-13 00:40:23 +00:00
Tiago Ribeiro
86e920f102 Merged develop into contact-us-form 2024-02-13 00:39:59 +00:00
João Ramos
6f12a4a1db Merged in email-notification-ticket-close (pull request #34)
Added an email notification when the ticket is submitted
2024-02-13 00:39:39 +00:00
Tiago Ribeiro
a27a3c1fb0 Merged develop into contact-us-form 2024-02-13 00:38:56 +00:00
Joao Ramos
63618405bc Added an email notification when the ticket is submitted 2024-02-12 22:37:16 +00:00
João Ramos
7ab67fdf15 Merged develop into privacy-policy 2024-02-12 21:49:11 +00:00
Joao Ramos
17ec004a59 Added checkbox for accepted terms 2024-02-12 21:45:37 +00:00
Tiago Ribeiro
417bd7fecb Added tooltips to the student dashboard 2024-02-12 19:26:55 +00:00
Joao Ramos
e82895351d Published the tickets POST route 2024-02-12 18:18:25 +00:00
Tiago Ribeiro
4802310474 Added the ability to delete already finished assignments 2024-02-12 11:15:04 +00:00
Tiago Ribeiro
dc3373be6a Added the ability to choose a difficulty when generating an exam 2024-02-10 13:26:08 +00:00
Tiago Ribeiro
2e894622d0 Updated users to get exams related to their level 2024-02-10 09:10:52 +00:00
Tiago Ribeiro
1895b9e183 Disabled the navigation on the mobile menu 2024-02-09 16:48:25 +00:00
Tiago Ribeiro
03f78ceb46 Added the same functionality to the Assignments 2024-02-09 13:23:35 +00:00
Tiago Ribeiro
872cc62fe4 - Added the ability for a student/developer to choose a gender for a speaking instructor;
- Made it so, if chosen, the user will only get speaking exams with their chosen gender;
- Added the ability for speaking exams to select a gender when generating;
2024-02-09 12:14:47 +00:00
Joao Ramos
ce7032c8a7 Added agent as a possible idsplay on the list for ticket 2024-02-08 19:49:53 +00:00
Tiago Ribeiro
71f07af2eb Added "instructorGender" key to Speaking exams 2024-02-08 14:04:52 +00:00
203 changed files with 25720 additions and 9876 deletions

2
.gitignore vendored
View File

@@ -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

View File

@@ -1,4 +1,5 @@
/** @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", output: "standalone",
@@ -8,7 +9,7 @@ const nextConfig = {
source: "/api/packages", source: "/api/packages",
headers: [ headers: [
{key: "Access-Control-Allow-Credentials", value: "false"}, {key: "Access-Control-Allow-Credentials", value: "false"},
{key: "Access-Control-Allow-Origin", value: "https://encoach.com"}, {key: "Access-Control-Allow-Origin", value: websiteUrl},
{ {
key: "Access-Control-Allow-Methods", key: "Access-Control-Allow-Methods",
value: "GET", value: "GET",
@@ -19,6 +20,36 @@ const nextConfig = {
}, },
], ],
}, },
{
source: "/api/tickets",
headers: [
{key: "Access-Control-Allow-Credentials", value: "false"},
{key: "Access-Control-Allow-Origin", value: websiteUrl},
{
key: "Access-Control-Allow-Methods",
value: "POST,OPTIONS",
},
{
key: "Access-Control-Allow-Headers",
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
},
],
},
{
source: "/api/users/agents",
headers: [
{key: "Access-Control-Allow-Credentials", value: "false"},
{key: "Access-Control-Allow-Origin", value: websiteUrl},
{
key: "Access-Control-Allow-Methods",
value: "POST,OPTIONS",
},
{
key: "Access-Control-Allow-Headers",
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
},
],
},
]; ];
}, },
}; };

7594
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,8 @@
}, },
"dependencies": { "dependencies": {
"@beam-australia/react-env": "^3.1.1", "@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",
@@ -66,9 +68,10 @@
"react-select": "^5.7.5", "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", "react-xarrows": "^2.0.2",
"read-excel-file": "^5.7.1", "read-excel-file": "^5.7.1",
"short-unique-id": "^5.0.2", "short-unique-id": "5.0.2",
"stripe": "^13.10.0", "stripe": "^13.10.0",
"swr": "^2.1.3", "swr": "^2.1.3",
"tailwind-scrollbar-hide": "^1.1.7", "tailwind-scrollbar-hide": "^1.1.7",
@@ -94,7 +97,6 @@
"autoprefixer": "^10.4.13", "autoprefixer": "^10.4.13",
"husky": "^8.0.3", "husky": "^8.0.3",
"postcss": "^8.4.21", "postcss": "^8.4.21",
"tailwindcss": "^3.2.4", "tailwindcss": "^3.2.4"
"types/": "paypal/react-paypal-js"
} }
} }

Binary file not shown.

BIN
public/manuals/student.pdf Normal file

Binary file not shown.

BIN
public/manuals/teacher.pdf Normal file

Binary file not shown.

1
public/mat-icon-info.svg Normal file
View 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

View 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 = `
GTP's Zero 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. GTP Zero<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: "GPT Zero is uncertain about this text. If GPT Zero had to classify it, it would be considered",
human: "GPT Zero is uncertain about this text. If GPT Zero had to classify it, it would likely be considered",
mixed: "GPT Zero is uncertain about this text. If GPT Zero had to classify it, it would likely be a"
},
medium: {
ai: "GPT Zero is moderately confident this text was",
human: "GPT Zero is moderately confident this text is entirely",
mixed: "GPT Zero is moderately confident this text is a"
},
high: {
ai: "GPT Zero is highly confident this text was",
human: "GPT Zero is highly confident this text is entirely",
mixed: "GPT Zero 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">GPT Zero AI 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;

View File

@@ -77,15 +77,8 @@ export default function FillBlanks({
onBack, onBack,
}: FillBlanksExercise & CommonProps) { }: FillBlanksExercise & CommonProps) {
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions); const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
const [currentBlankId, setCurrentBlankId] = useState<string>();
const [isDrawerShowing, setIsDrawerShowing] = useState(false);
const hasExamEnded = useExamStore((state) => state.hasExamEnded); const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const allBlanks = Array.from(text.match(/({{\d+}})/g) || []).map((x) => x.replaceAll("{", "").replaceAll("}", ""));
useEffect(() => {
setTimeout(() => setIsDrawerShowing(!!currentBlankId), 100);
}, [currentBlankId]);
useEffect(() => { useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type}); if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
@@ -94,9 +87,17 @@ export default function FillBlanks({
const calculateScore = () => { const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0; const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = answers.filter( const correct = answers.filter((x) => {
(x) => solutions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false, const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution.toLowerCase();
).length; if (!solution) return false;
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; const missing = total - answers.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return {total, correct, missing}; return {total, correct, missing};
@@ -104,49 +105,29 @@ export default function FillBlanks({
const renderLines = (line: string) => { const renderLines = (line: string) => {
return ( return (
<span className="text-base leading-5"> <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 = answers.find((x) => x.id === id); const userSolution = answers.find((x) => x.id === id);
return ( return (
<button <input
className={clsx( className={clsx(
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1", "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 && "w-6 h-6 text-center text-mti-purple-light bg-mti-purple-ultralight", !userSolution && "text-center text-mti-purple-light bg-mti-purple-ultralight",
currentBlankId === id && "text-white !bg-mti-purple-light ", userSolution && "px-5 py-2 text-center text-mti-purple-dark bg-mti-purple-ultralight",
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
)} )}
onClick={() => setCurrentBlankId(id)}> onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), {id, solution: e.target.value}])}
{userSolution ? userSolution.solution : id} value={userSolution?.solution}></input>
</button>
); );
})} })}
</span> </div>
); );
}; };
return ( return (
<> <>
<div className="flex flex-col gap-4 mt-4 h-full mb-20"> <div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
{(!!currentBlankId || isDrawerShowing) && (
<WordsDrawer
key={currentBlankId}
blankId={currentBlankId}
words={words.map((word) => ({word, isDisabled: allowRepetition ? false : answers.map((x) => x.solution).includes(word)}))}
previouslySelectedWord={currentBlankId ? answers.find((x) => x.id === currentBlankId)?.solution : undefined}
isOpen={isDrawerShowing}
onCancel={() => setCurrentBlankId(undefined)}
onAnswer={(solution: string) => {
setAnswers((prev) => [...prev.filter((x) => x.id !== currentBlankId), {id: currentBlankId!, solution}]);
if (allBlanks.findIndex((x) => x === currentBlankId) + 1 < allBlanks.length) {
setCurrentBlankId(allBlanks[allBlanks.findIndex((x) => x === currentBlankId) + 1]);
return;
}
setCurrentBlankId(undefined);
}}
/>
)}
<span className="text-sm w-full leading-6"> <span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => ( {prompt.split("\\n").map((line, index) => (
<Fragment key={index}> <Fragment key={index}>
@@ -163,6 +144,26 @@ export default function FillBlanks({
</p> </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 justify-between w-full gap-8 absolute bottom-8 left-0 px-8"> <div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">

View File

@@ -16,12 +16,12 @@ const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mo
export default function InteractiveSpeaking({ export default function InteractiveSpeaking({
id, id,
title, title,
first_title,
second_title,
examID, examID,
text,
type, type,
prompts, prompts,
userSolutions, userSolutions,
updateIndex,
onNext, onNext,
onBack, onBack,
}: InteractiveSpeakingExercise & CommonProps) { }: InteractiveSpeakingExercise & CommonProps) {
@@ -36,31 +36,6 @@ export default function InteractiveSpeaking({
const hasExamEnded = useExamStore((state) => state.hasExamEnded); const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const saveToStorage = async (previousURL?: string) => {
if (mediaBlob && mediaBlob.startsWith("blob")) {
const blobBuffer = await downloadBlob(mediaBlob);
const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"});
const seed = Math.random().toString().replace("0.", "");
const formData = new FormData();
formData.append("audio", audioFile, `${seed}.wav`);
formData.append("root", "speaking_recordings");
const config = {
headers: {
"Content-Type": "audio/wav",
},
};
const response = await axios.post<{path: string}>("/api/storage/insert", formData, config);
if (previousURL && !previousURL.startsWith("blob")) await axios.post("/api/storage/delete", {path: previousURL});
return response.data.path;
}
return undefined;
};
const back = async () => { const back = async () => {
setIsLoading(true); setIsLoading(true);
@@ -76,7 +51,7 @@ export default function InteractiveSpeaking({
onBack({ onBack({
exercise: id, exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer], solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 1, total: 1, missing: 0}, score: {correct: 100, total: 100, missing: 0},
type, type,
}); });
}; };
@@ -91,19 +66,20 @@ export default function InteractiveSpeaking({
return; return;
} }
setIsLoading(false); setIsLoading(false);
setQuestionIndex(0);
onNext({ onNext({
exercise: id, exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer], solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 1, total: 1, missing: 0}, score: {correct: 100, total: 100, missing: 0},
type, type,
}); });
}; };
useEffect(() => { useEffect(() => {
if (userSolutions.length > 0 && answers.length === 0) { if (userSolutions.length > 0 && answers.length === 0) {
console.log(userSolutions);
const solutions = userSolutions as unknown as typeof answers; const solutions = userSolutions as unknown as typeof answers;
setAnswers(solutions); setAnswers(solutions);
@@ -112,14 +88,6 @@ export default function InteractiveSpeaking({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [userSolutions, mediaBlob, answers]); }, [userSolutions, mediaBlob, answers]);
useEffect(() => {
console.log({answers});
}, [answers]);
useEffect(() => {
if (updateIndex) updateIndex(questionIndex);
}, [questionIndex, updateIndex]);
useEffect(() => { useEffect(() => {
if (hasExamEnded) { if (hasExamEnded) {
const answer = { const answer = {
@@ -131,7 +99,7 @@ export default function InteractiveSpeaking({
onNext({ onNext({
exercise: id, exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer], solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 1, total: 1, missing: 0}, score: {correct: 100, total: 100, missing: 0},
type, type,
}); });
} }
@@ -159,13 +127,10 @@ export default function InteractiveSpeaking({
}, [answers, questionIndex]); }, [answers, questionIndex]);
const saveAnswer = async (index: number) => { const saveAnswer = async (index: number) => {
const previousURL = answers.find((x) => x.questionIndex === questionIndex)?.blob;
const audioPath = await saveToStorage(previousURL);
const answer = { const answer = {
questionIndex, questionIndex,
prompt: prompts[questionIndex].text, prompt: prompts[questionIndex].text,
blob: audioPath ? audioPath : mediaBlob!, blob: mediaBlob!,
}; };
setAnswers((prev) => [...prev.filter((x) => x.questionIndex !== index), answer]); setAnswers((prev) => [...prev.filter((x) => x.questionIndex !== index), answer]);
@@ -176,7 +141,7 @@ export default function InteractiveSpeaking({
{ {
exercise: id, exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer], solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 1, total: 1, missing: 0}, score: {correct: 100, total: 100, missing: 0},
module: "speaking", module: "speaking",
exam: examID, exam: examID,
type, type,
@@ -190,7 +155,7 @@ export default function InteractiveSpeaking({
<div className="flex flex-col h-full w-full gap-9"> <div className="flex flex-col h-full w-full gap-9">
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16"> <div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<span className="font-semibold">{title}</span> <span className="font-semibold">{!!first_title && !!second_title ? `${first_title} & ${second_title}` : title}</span>
</div> </div>
{prompts && prompts.length > 0 && ( {prompts && prompts.length > 0 && (
<div className="flex flex-col gap-4 w-full items-center"> <div className="flex flex-col gap-4 w-full items-center">
@@ -209,7 +174,7 @@ export default function InteractiveSpeaking({
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center"> <div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
<p className="text-base font-normal">Record your answer:</p> <p className="text-base font-normal">Record your answer:</p>
<div className="flex gap-8 items-center justify-center py-8"> <div className="flex gap-8 items-center justify-center py-8">
{status === "idle" && !mediaBlob && ( {status === "idle" && (
<> <>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" /> <div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
{status === "idle" && ( {status === "idle" && (
@@ -288,9 +253,9 @@ export default function InteractiveSpeaking({
</div> </div>
</> </>
)} )}
{((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && ( {status === "stopped" && mediaBlobUrl && (
<> <>
<Waveform audio={mediaBlobUrl ? mediaBlobUrl : mediaBlob!} waveColor="#FCDDEC" progressColor="#EF5DA8" /> <Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<BsTrashFill <BsTrashFill
className="text-mti-gray-cool cursor-pointer w-5 h-5" className="text-mti-gray-cool cursor-pointer w-5 h-5"

View File

@@ -1,5 +1,5 @@
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";
@@ -9,13 +9,74 @@ import {CommonProps} from ".";
import Button from "../Low/Button"; import Button from "../Low/Button";
import Xarrow from "react-xarrows"; import Xarrow from "react-xarrows";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {DndContext, DragEndEvent, useDraggable, useDroppable} from "@dnd-kit/core";
function DroppableQuestionArea({question, answer}: {question: MatchSentenceExerciseSentence; answer?: string}) {
const {isOver, setNodeRef} = useDroppable({id: `droppable_sentence_${question.id}`});
return (
<div className="grid grid-cols-3 gap-4" ref={setNodeRef}>
<div className="flex items-center gap-3 cursor-pointer col-span-2">
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
"transition duration-300 ease-in-out",
)}>
{question.id}
</button>
<span>{question.sentence}</span>
</div>
<div
key={`answer_${question.id}_${answer}`}
className={clsx("w-48 h-10 border rounded-xl flex items-center justify-center", isOver && "border-mti-purple-light")}>
{answer && `Paragraph ${answer}`}
</div>
</div>
);
}
function DraggableOptionArea({option}: {option: MatchSentenceExerciseOption}) {
const {attributes, listeners, setNodeRef, transform} = useDraggable({
id: `draggable_option_${option.id}`,
});
const style = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
zIndex: 99,
}
: undefined;
return (
<div className={clsx("flex items-center justify-start gap-6 cursor-pointer")} ref={setNodeRef} style={style} {...listeners} {...attributes}>
<button
id={`option_${option.id}`}
// onClick={() => selectOption(id)}
className={clsx(
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple px-3 py-2 rounded-full z-10",
"transition duration-300 ease-in-out",
option.id,
)}>
Paragraph {option.id}
</button>
</div>
);
}
export default function MatchSentences({id, options, type, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) { export default function MatchSentences({id, options, type, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) {
const [selectedQuestion, setSelectedQuestion] = useState<string>();
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions); const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
const hasExamEnded = useExamStore((state) => state.hasExamEnded); 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 = answers.filter( const correct = answers.filter(
@@ -26,12 +87,6 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
return {total, correct, missing}; return {total, correct, missing};
}; };
const selectOption = (option: string) => {
if (!selectedQuestion) return;
setAnswers((prev) => [...prev.filter((x) => x.question !== selectedQuestion), {question: selectedQuestion, option}]);
setSelectedQuestion(undefined);
};
useEffect(() => { useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type}); if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -39,7 +94,7 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
return ( return (
<> <>
<div className="flex flex-col gap-4 mt-4 h-full mb-20"> <div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
<span className="text-sm w-full leading-6"> <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}>
@@ -48,46 +103,28 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
</Fragment> </Fragment>
))} ))}
</span> </span>
<div className="flex gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
<div className="flex flex-col gap-4"> <DndContext onDragEnd={handleDragEnd}>
{sentences.map(({sentence, id}) => ( <div className="flex flex-col gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
<div key={`question_${id}`} className="flex items-center justify-end gap-2 cursor-pointer"> <div className="flex flex-col gap-4">
<span>{sentence} </span> {sentences.map((question) => (
<button <DroppableQuestionArea
id={id} key={`question_${question.id}`}
onClick={() => setSelectedQuestion((prev) => (prev === id ? undefined : id))} question={question}
className={clsx( answer={answers.find((x) => x.question.toString() === question.id.toString())?.option}
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10", />
"transition duration-300 ease-in-out", ))}
selectedQuestion === id && "!text-white !bg-mti-purple", </div>
id, <div className="flex flex-col gap-4">
)}> <span>Drag one of these paragraphs into the slots above:</span>
{id} <div className="flex gap-4 flex-wrap justify-center items-center max-w-lg">
</button> {options.map((option) => (
<DraggableOptionArea key={`answer_${option.id}`} option={option} />
))}
</div> </div>
))} </div>
</div> </div>
<div className="flex flex-col gap-4"> </DndContext>
{options.map(({sentence, id}) => (
<div key={`answer_${id}`} className={clsx("flex items-center justify-start gap-2 cursor-pointer")}>
<button
id={id}
onClick={() => selectOption(id)}
className={clsx(
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
"transition duration-300 ease-in-out",
id,
)}>
{id}
</button>
<span>{sentence}</span>
</div>
))}
</div>
{answers.map((solution, index) => (
<Xarrow key={index} start={solution.question} end={solution.option} lineColor="#7872BF" showHead={false} />
))}
</div>
</div> </div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8"> <div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">

View File

@@ -3,19 +3,36 @@ import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam"
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import clsx from "clsx"; import clsx from "clsx";
import {useEffect, 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"; import Button from "../Low/Button";
function Question({ function Question({
id,
variant, variant,
prompt, prompt,
options, options,
userSolution, userSolution,
onSelectOption, onSelectOption,
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) { }: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
const renderPrompt = (prompt: string) => {
return reactStringReplace(prompt, /((<u>)[\w\s']+(<\/u>))/g, (match) => {
const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
return word.length > 0 ? <u>{word}</u> : null;
});
};
return ( return (
<div className="flex flex-col gap-10"> <div className="flex flex-col gap-10">
<span className="">{prompt}</span> {isNaN(Number(id)) ? (
<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"> <div className="flex flex-wrap gap-4 justify-between">
{variant === "image" && {variant === "image" &&
options.map((option) => ( options.map((option) => (
@@ -48,16 +65,7 @@ function Question({
); );
} }
export default function MultipleChoice({ export default function MultipleChoice({id, prompt, type, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
id,
prompt,
type,
questions,
userSolutions,
updateIndex,
onNext,
onBack,
}: MultipleChoiceExercise & CommonProps) {
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions); const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
const {questionIndex, setQuestionIndex} = useExamStore((state) => state); const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
@@ -76,10 +84,6 @@ export default function MultipleChoice({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]); }, [hasExamEnded]);
useEffect(() => {
if (updateIndex) updateIndex(questionIndex);
}, [questionIndex, updateIndex]);
const onSelectOption = (option: string) => { const onSelectOption = (option: string) => {
const question = questions[questionIndex]; const question = questions[questionIndex];
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]); setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);
@@ -117,7 +121,7 @@ export default function MultipleChoice({
return ( return (
<> <>
<div className="flex flex-col gap-2 mt-4 h-fit mb-20 bg-mti-gray-smoke rounded-xl px-16 py-8"> <div className="flex flex-col gap-2 mt-4 h-fit w-full mb-20 bg-mti-gray-smoke rounded-xl px-16 py-8">
<span className="text-xl font-semibold">{prompt}</span> <span className="text-xl font-semibold">{prompt}</span>
{questionIndex < questions.length && ( {questionIndex < questions.length && (
<Question <Question

View File

@@ -7,18 +7,20 @@ import Button from "../Low/Button";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {downloadBlob} from "@/utils/evaluation"; import {downloadBlob} from "@/utils/evaluation";
import axios from "axios"; import axios from "axios";
import Modal from "../Modal";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false}); const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), { const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
ssr: false, ssr: false,
}); });
export default function Speaking({id, title, text, video_url, type, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) { export default function Speaking({id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
const [recordingDuration, setRecordingDuration] = useState(0); const [recordingDuration, setRecordingDuration] = useState(0);
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
const [mediaBlob, setMediaBlob] = useState<string>(); const [mediaBlob, setMediaBlob] = useState<string>();
const [audioURL, setAudioURL] = useState<string>(); const [audioURL, setAudioURL] = useState<string>();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isPromptsModalOpen, setIsPromptsModalOpen] = useState(false);
const hasExamEnded = useExamStore((state) => state.hasExamEnded); const hasExamEnded = useExamStore((state) => state.hasExamEnded);
@@ -74,36 +76,45 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
}, [isRecording]); }, [isRecording]);
const next = async () => { const next = async () => {
setIsLoading(true);
const storagePath = await saveToStorage();
setIsLoading(false);
onNext({ onNext({
exercise: id, exercise: id,
solutions: storagePath ? [{id, solution: storagePath}] : [], solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0}, score: {correct: 0, total: 100, missing: 0},
type, type,
}); });
}; };
const back = async () => { const back = async () => {
setIsLoading(true);
const storagePath = await saveToStorage();
setIsLoading(false);
onBack({ onBack({
exercise: id, exercise: id,
solutions: storagePath ? [{id, solution: storagePath}] : [], solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0}, score: {correct: 0, total: 100, missing: 0},
type, type,
}); });
}; };
return ( return (
<div className="flex flex-col h-full w-full gap-9"> <div className="flex flex-col h-full w-full gap-9">
<Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}>
<div className="flex flex-col items-center justify-center gap-4 w-full h-full">
<div className="flex flex-col gap-1 ml-4">
{prompts.map((x, index) => (
<li className="italic" key={index}>
{x}
</li>
))}
</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 w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<span className="font-semibold">{title}</span> <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 30 seconds for your answer to be valid.</span>
)}
</div>
{!video_url && ( {!video_url && (
<span className="font-regular"> <span className="font-regular">
{text.split("\\n").map((line, index) => ( {text.split("\\n").map((line, index) => (
@@ -115,7 +126,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
</span> </span>
)} )}
</div> </div>
<div className="flex gap-6"> <div className="flex flex-col gap-6 items-center">
{video_url && ( {video_url && (
<div className="flex flex-col gap-4 w-full items-center"> <div className="flex flex-col gap-4 w-full items-center">
<video key={id} autoPlay controls className="max-w-3xl rounded-xl"> <video key={id} autoPlay controls className="max-w-3xl rounded-xl">
@@ -123,18 +134,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
</video> </video>
</div> </div>
)} )}
{prompts && prompts.length > 0 && ( {prompts && prompts.length > 0 && <Button onClick={() => setIsPromptsModalOpen(true)}>View Prompts</Button>}
<div className="flex flex-col gap-4">
<span className="font-bold">You should talk about the following things:</span>
<div className="flex flex-col gap-1 ml-4">
{prompts.map((x, index) => (
<li className="italic" key={index}>
{x}
</li>
))}
</div>
</div>
)}
</div> </div>
</div> </div>

View File

@@ -40,7 +40,7 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
return ( return (
<> <>
<div className="flex flex-col gap-4 mt-4 h-full mb-20"> <div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
<span className="text-sm w-full leading-6"> <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}>

View File

@@ -88,7 +88,7 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
return ( return (
<> <>
<div className="flex flex-col gap-4 mt-4 h-full mb-20"> <div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
<span className="text-sm w-full leading-6"> <span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => ( {prompt.split("\\n").map((line, index) => (
<span key={index}> <span key={index}>

View File

@@ -41,7 +41,7 @@ export default function Writing({
if (inputText.length > 0 && saveTimer % 10 === 0) { if (inputText.length > 0 && saveTimer % 10 === 0) {
setUserSolutions([ setUserSolutions([
...storeUserSolutions.filter((x) => x.exercise !== id), ...storeUserSolutions.filter((x) => x.exercise !== id),
{exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type}, {exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type, module: "writing"},
]); ]);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -64,7 +64,8 @@ export default function Writing({
}, []); }, []);
useEffect(() => { useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type}); if (hasExamEnded)
onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type, module: "writing"});
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]); }, [hasExamEnded]);
@@ -147,7 +148,9 @@ export default function Writing({
<Button <Button
color="purple" color="purple"
variant="outline" variant="outline"
onClick={() => onBack({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type})} onClick={() =>
onBack({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type})
}
className="max-w-[200px] self-end w-full"> className="max-w-[200px] self-end w-full">
Back Back
</Button> </Button>
@@ -158,8 +161,9 @@ export default function Writing({
onNext({ onNext({
exercise: id, exercise: id,
solutions: [{id, solution: inputText.replaceAll(/\s{2,}/g, " ")}], solutions: [{id, solution: inputText.replaceAll(/\s{2,}/g, " ")}],
score: {correct: 1, total: 1, missing: 0}, score: {correct: 100, total: 100, missing: 0},
type, type,
module: "writing",
}) })
} }
className="max-w-[200px] self-end w-full"> className="max-w-[200px] self-end w-full">

View File

@@ -23,7 +23,6 @@ const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentenc
export interface CommonProps { export interface CommonProps {
examID?: string; examID?: string;
updateIndex?: (internalIndex: number) => void;
onNext: (userSolutions: UserSolution) => void; onNext: (userSolutions: UserSolution) => void;
onBack: (userSolutions: UserSolution) => void; onBack: (userSolutions: UserSolution) => void;
} }
@@ -33,7 +32,6 @@ export const renderExercise = (
examID: string, examID: string,
onNext: (userSolutions: UserSolution) => void, onNext: (userSolutions: UserSolution) => void,
onBack: (userSolutions: UserSolution) => void, onBack: (userSolutions: UserSolution) => void,
updateIndex?: (internalIndex: number) => void,
) => { ) => {
switch (exercise.type) { switch (exercise.type) {
case "fillBlanks": case "fillBlanks":
@@ -43,16 +41,7 @@ export const renderExercise = (
case "matchSentences": case "matchSentences":
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} examID={examID} />; return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
case "multipleChoice": case "multipleChoice":
return ( return <MultipleChoice key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
<MultipleChoice
key={exercise.id}
{...(exercise as MultipleChoiceExercise)}
updateIndex={updateIndex}
onNext={onNext}
onBack={onBack}
examID={examID}
/>
);
case "writeBlanks": case "writeBlanks":
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />; return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
case "writing": case "writing":
@@ -65,7 +54,6 @@ export const renderExercise = (
key={exercise.id} key={exercise.id}
{...(exercise as InteractiveSpeakingExercise)} {...(exercise as InteractiveSpeakingExercise)}
examID={examID} examID={examID}
updateIndex={updateIndex}
onNext={onNext} onNext={onNext}
onBack={onBack} onBack={onBack}
/> />

View 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;

View File

@@ -0,0 +1,7 @@
import React from "react";
const InteractiveSpeakingEdit = () => {
return null;
};
export default InteractiveSpeakingEdit;

View 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;

View 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;

View File

@@ -0,0 +1,7 @@
import React from 'react';
const SpeakingEdit = () => {
return null;
}
export default SpeakingEdit;

View 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;

View 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;

View File

@@ -0,0 +1,7 @@
import React from 'react';
const WritingEdit = () => {
return null;
}
export default WritingEdit;

View File

@@ -33,7 +33,7 @@ export default function Layout({user, children, className, navDisabled = false,
focusMode={focusMode} focusMode={focusMode}
onFocusLayerMouseEnter={onFocusLayerMouseEnter} onFocusLayerMouseEnter={onFocusLayerMouseEnter}
className="-md:hidden" className="-md:hidden"
userType={user.type} user={user}
/> />
<div <div
className={clsx( className={clsx(

View File

@@ -16,6 +16,7 @@ import ShortUniqueId from "short-unique-id";
import Button from "../Low/Button"; import Button from "../Low/Button";
import Input from "../Low/Input"; import Input from "../Low/Input";
import Select from "../Low/Select"; import Select from "../Low/Select";
import { checkAccess } from "@/utils/permissions";
interface Props { interface Props {
user: User; user: User;
@@ -137,13 +138,13 @@ export default function TicketDisplay({ user, ticket, onClose }: Props) {
options={[ options={[
{ value: "me", label: "Assign to me" }, { value: "me", label: "Assign to me" },
...users ...users
.filter((x) => ["admin", "developer"].includes(x.type)) .filter((x) => checkAccess(x, ["admin", "developer", "agent"]))
.map((u) => ({ .map((u) => ({
value: u.id, value: u.id,
label: `${u.name} - ${u.email}`, label: `${u.name} - ${u.email}`,
})), })),
]} ]}
disabled={user.type === "agent"} disabled={checkAccess(user, ["agent"])}
value={ value={
assignedTo assignedTo
? { ? {

View File

@@ -1,5 +1,6 @@
import {Ticket, TicketType, TicketTypeLabel} from "@/interfaces/ticket"; import {Ticket, TicketType, TicketTypeLabel} from "@/interfaces/ticket";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore";
import axios from "axios"; import axios from "axios";
import {useState} from "react"; import {useState} from "react";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
@@ -20,6 +21,8 @@ export default function TicketSubmission({user, page, onClose}: Props) {
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const examState = useExamStore((state) => state);
const submit = () => { const submit = () => {
if (!type) return toast.error("Please choose a type!", {toastId: "missing-type"}); if (!type) return toast.error("Please choose a type!", {toastId: "missing-type"});
if (subject.trim() === "") if (subject.trim() === "")
@@ -48,6 +51,18 @@ export default function TicketSubmission({user, page, onClose}: Props) {
type, type,
reportedFrom: page, reportedFrom: page,
description, 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 axios

View File

@@ -62,8 +62,8 @@ export default function Button({
onClick={onClick} onClick={onClick}
className={clsx( className={clsx(
"py-4 px-6 rounded-full transition ease-in-out duration-300 disabled:cursor-not-allowed cursor-pointer", "py-4 px-6 rounded-full transition ease-in-out duration-300 disabled:cursor-not-allowed cursor-pointer",
className,
colorClassNames[color][variant], colorClassNames[color][variant],
className,
)} )}
disabled={disabled || isLoading}> disabled={disabled || isLoading}>
{!isLoading && children} {!isLoading && children}

View File

@@ -1,68 +1,70 @@
import clsx from "clsx"; import clsx from "clsx";
import { ComponentProps } from "react"; import {ComponentProps, useEffect, useState} from "react";
import ReactSelect from "react-select"; import ReactSelect, {GroupBase, StylesConfig} from "react-select";
interface Option { interface Option {
[key: string]: any; [key: string]: any;
value: string; value: string | null;
label: string; label: string;
} }
interface Props { interface Props {
defaultValue?: Option; defaultValue?: Option;
value?: Option | null; value?: Option | null;
options: Option[]; options: Option[];
disabled?: boolean; disabled?: boolean;
placeholder?: string; placeholder?: string;
onChange: (value: Option | null) => void; onChange: (value: Option | null) => void;
isClearable?: boolean; isClearable?: boolean;
styles?: StylesConfig<Option, boolean, GroupBase<Option>>;
className?: string;
} }
export default function Select({ export default function Select({value, defaultValue, options, placeholder, disabled, onChange, styles, isClearable, className}: Props) {
value, const [target, setTarget] = useState<HTMLElement>();
defaultValue,
options, useEffect(() => {
placeholder, if (document) setTarget(document.body);
disabled, }, []);
onChange,
isClearable, return (
}: Props) { <ReactSelect
return ( className={
<ReactSelect styles
className={clsx( ? undefined
"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", : clsx(
disabled && "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",
"!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed", disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
)} className,
options={options} )
value={value} }
onChange={onChange} options={options}
placeholder={placeholder} value={value}
menuPortalTarget={document?.body} onChange={onChange as any}
defaultValue={defaultValue} placeholder={placeholder}
styles={{ menuPortalTarget={target}
menuPortal: (base) => ({ ...base, zIndex: 9999 }), defaultValue={defaultValue}
control: (styles) => ({ styles={
...styles, styles || {
paddingLeft: "4px", menuPortal: (base) => ({...base, zIndex: 9999}),
border: "none", control: (styles) => ({
outline: "none", ...styles,
":focus": { paddingLeft: "4px",
outline: "none", border: "none",
}, outline: "none",
}), ":focus": {
option: (styles, state) => ({ outline: "none",
...styles, },
backgroundColor: state.isFocused }),
? "#D5D9F0" option: (styles, state) => ({
: state.isSelected ...styles,
? "#7872BF" backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
: "white", color: state.isFocused ? "black" : styles.color,
color: state.isFocused ? "black" : styles.color, }),
}), }
}} }
isDisabled={disabled} isDisabled={disabled}
isClearable={isClearable} isClearable={isClearable}
/> />
); );
} }

View File

@@ -21,7 +21,11 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
const [timer, setTimer] = useState(minTimer * 60); const [timer, setTimer] = useState(minTimer * 60);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [warningMode, setWarningMode] = useState(false); const [warningMode, setWarningMode] = useState(false);
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded); const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
const {timeSpent} = useExamStore((state) => state);
useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]);
useEffect(() => { useEffect(() => {
if (!disableTimer) { if (!disableTimer) {

View 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>
);
}

View File

@@ -7,15 +7,23 @@ import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Fragment } from "react"; import { Fragment } from "react";
import { BsXLg } from "react-icons/bs"; import { BsXLg } from "react-icons/bs";
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
path: string; path: string;
user: User; user: User;
disableNavigation?: boolean;
} }
export default function MobileMenu({ isOpen, onClose, path, user }: Props) { export default function MobileMenu({
isOpen,
onClose,
path,
user,
disableNavigation,
}: Props) {
const router = useRouter(); const router = useRouter();
const logout = async () => { const logout = async () => {
@@ -55,7 +63,7 @@ export default function MobileMenu({ isOpen, onClose, path, user }: Props) {
as="header" as="header"
className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden" className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden"
> >
<Link href="/"> <Link href={disableNavigation ? "" : "/"}>
<Image <Image
src="/logo_title.png" src="/logo_title.png"
alt="EnCoach logo" alt="EnCoach logo"
@@ -76,35 +84,33 @@ export default function MobileMenu({ isOpen, onClose, path, user }: Props) {
</Dialog.Title> </Dialog.Title>
<div className="flex h-full flex-col gap-6 px-8 text-lg"> <div className="flex h-full flex-col gap-6 px-8 text-lg">
<Link <Link
href="/" href={disableNavigation ? "" : "/"}
className={clsx( className={clsx(
"w-fit transition duration-300 ease-in-out", "w-fit transition duration-300 ease-in-out",
path === "/" && path === "/" &&
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
)} )}
> >
Dashboard Dashboard
</Link> </Link>
{(user.type === "student" || {checkAccess(user, ["student", "teacher", "developer"]) && (
user.type === "teacher" ||
user.type === "developer") && (
<> <>
<Link <Link
href="/exam" href={disableNavigation ? "" : "/exam"}
className={clsx( className={clsx(
"w-fit transition duration-300 ease-in-out", "w-fit transition duration-300 ease-in-out",
path === "/exam" && path === "/exam" &&
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
)} )}
> >
Exams Exams
</Link> </Link>
<Link <Link
href="/exercises" href={disableNavigation ? "" : "/exercises"}
className={clsx( className={clsx(
"w-fit transition duration-300 ease-in-out", "w-fit transition duration-300 ease-in-out",
path === "/exercises" && path === "/exercises" &&
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
)} )}
> >
Exercises Exercises
@@ -112,71 +118,79 @@ export default function MobileMenu({ isOpen, onClose, path, user }: Props) {
</> </>
)} )}
<Link <Link
href="/stats" href={disableNavigation ? "" : "/stats"}
className={clsx( className={clsx(
"w-fit transition duration-300 ease-in-out", "w-fit transition duration-300 ease-in-out",
path === "/stats" && path === "/stats" &&
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
)} )}
> >
Stats Stats
</Link> </Link>
<Link <Link
href="/record" href={disableNavigation ? "" : "/record"}
className={clsx( className={clsx(
"w-fit transition duration-300 ease-in-out", "w-fit transition duration-300 ease-in-out",
path === "/record" && path === "/record" &&
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
)} )}
> >
Record Record
</Link> </Link>
{["admin", "developer", "agent", "corporate"].includes( {checkAccess(user, [
user.type, "admin",
) && ( "developer",
"agent",
"corporate",
"mastercorporate",
]) && (
<Link <Link
href="/payment-record" href={disableNavigation ? "" : "/payment-record"}
className={clsx( className={clsx(
"w-fit transition duration-300 ease-in-out", "w-fit transition duration-300 ease-in-out",
path === "/payment-record" && path === "/payment-record" &&
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
)} )}
> >
Payment Record Payment Record
</Link> </Link>
)} )}
{["admin", "developer", "corporate", "teacher"].includes( {checkAccess(user, [
user.type, "admin",
) && ( "developer",
"corporate",
"teacher",
"mastercorporate",
]) && (
<Link <Link
href="/settings" href={disableNavigation ? "" : "/settings"}
className={clsx( className={clsx(
"w-fit transition duration-300 ease-in-out", "w-fit transition duration-300 ease-in-out",
path === "/settings" && path === "/settings" &&
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
)} )}
> >
Settings Settings
</Link> </Link>
)} )}
{["admin", "developer", "agent"].includes(user.type) && ( {checkAccess(user, ["admin", "developer", "agent"]) && (
<Link <Link
href="/tickets" href={disableNavigation ? "" : "/tickets"}
className={clsx( className={clsx(
"w-fit transition duration-300 ease-in-out", "w-fit transition duration-300 ease-in-out",
path === "/tickets" && path === "/tickets" &&
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
)} )}
> >
Tickets Tickets
</Link> </Link>
)} )}
<Link <Link
href="/profile" href={disableNavigation ? "" : "/profile"}
className={clsx( className={clsx(
"w-fit transition duration-300 ease-in-out", "w-fit transition duration-300 ease-in-out",
path === "/profile" && path === "/profile" &&
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
)} )}
> >
Profile Profile
@@ -184,7 +198,7 @@ export default function MobileMenu({ isOpen, onClose, path, user }: Props) {
<span <span
className={clsx( className={clsx(
"w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out", "w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out"
)} )}
onClick={logout} onClick={logout}
> >

View File

@@ -1,14 +1,16 @@
import {Dialog, Transition} from "@headlessui/react"; import {Dialog, Transition} from "@headlessui/react";
import clsx from "clsx";
import {Fragment, ReactElement} from "react"; import {Fragment, ReactElement} from "react";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
title?: string; title?: string;
className?: string;
children?: ReactElement; children?: ReactElement;
} }
export default function Modal({isOpen, title, onClose, children}: Props) { export default function Modal({isOpen, title, className, onClose, children}: Props) {
return ( return (
<Transition appear show={isOpen} as={Fragment}> <Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-[200]" onClose={onClose}> <Dialog as="div" className="relative z-[200]" onClose={onClose}>
@@ -33,7 +35,11 @@ export default function Modal({isOpen, title, onClose, children}: Props) {
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"> leaveTo="opacity-0 scale-95">
<Dialog.Panel className="w-full max-w-6xl transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all"> <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 && ( {title && (
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900"> <Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
{title} {title}

View File

@@ -1,114 +1,219 @@
import {User} from "@/interfaces/user"; import { User } from "@/interfaces/user";
import Link from "next/link"; import Link from "next/link";
import FocusLayer from "@/components/FocusLayer"; import FocusLayer from "@/components/FocusLayer";
import {preventNavigation} from "@/utils/navigation.disabled"; import { preventNavigation } from "@/utils/navigation.disabled";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import {BsList, BsQuestionCircle, BsQuestionCircleFill} from "react-icons/bs"; import { BsList, BsQuestionCircle, BsQuestionCircleFill } from "react-icons/bs";
import clsx from "clsx"; import clsx from "clsx";
import moment from "moment"; import moment from "moment";
import MobileMenu from "./MobileMenu"; import MobileMenu from "./MobileMenu";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import {Type} from "@/interfaces/user"; import { Type } from "@/interfaces/user";
import {USER_TYPE_LABELS} from "@/resources/user"; import { USER_TYPE_LABELS } from "@/resources/user";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import {isUserFromCorporate} from "@/utils/groups"; import { isUserFromCorporate } from "@/utils/groups";
import Button from "./Low/Button"; import Button from "./Low/Button";
import Modal from "./Modal"; import Modal from "./Modal";
import Input from "./Low/Input"; import Input from "./Low/Input";
import TicketSubmission from "./High/TicketSubmission"; 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 {
user: User; user: User;
navDisabled?: boolean; navDisabled?: boolean;
focusMode?: boolean; focusMode?: boolean;
onFocusLayerMouseEnter?: () => void; onFocusLayerMouseEnter?: () => void;
path: string; path: string;
} }
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
export default function Navbar({user, path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) { export default function Navbar({
const [isMenuOpen, setIsMenuOpen] = useState(false); user,
const [disablePaymentPage, setDisablePaymentPage] = useState(true); path,
const [isTicketOpen, setIsTicketOpen] = useState(false); navDisabled = false,
focusMode = false,
onFocusLayerMouseEnter,
}: Props) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
const [isTicketOpen, setIsTicketOpen] = useState(false);
const disableNavigation = preventNavigation(navDisabled, focusMode); const router = useRouter();
const expirationDateColor = (date: Date) => { const disableNavigation = preventNavigation(navDisabled, focusMode);
const momentDate = moment(date);
const today = moment(new Date());
if (today.add(1, "days").isAfter(momentDate)) return "!bg-mti-red-ultralight border-mti-red-light"; const expirationDateColor = (date: Date) => {
if (today.add(3, "days").isAfter(momentDate)) return "!bg-mti-rose-ultralight border-mti-rose-light"; const momentDate = moment(date);
if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light"; const today = moment(new Date());
};
const showExpirationDate = () => { if (today.add(1, "days").isAfter(momentDate))
if (!user.subscriptionExpirationDate) return false; return "!bg-mti-red-ultralight border-mti-red-light";
if (today.add(3, "days").isAfter(momentDate))
return "!bg-mti-rose-ultralight border-mti-rose-light";
if (today.add(7, "days").isAfter(momentDate))
return "!bg-mti-orange-ultralight border-mti-orange-light";
};
const momentDate = moment(user.subscriptionExpirationDate); const showExpirationDate = () => {
const today = moment(new Date()); if (!user.subscriptionExpirationDate) return false;
return today.add(7, "days").isAfter(momentDate); const momentDate = moment(user.subscriptionExpirationDate);
}; const today = moment(new Date());
useEffect(() => { return today.add(7, "days").isAfter(momentDate);
if (user.type !== "student" && user.type !== "teacher") return setDisablePaymentPage(false); };
isUserFromCorporate(user.id).then((result) => setDisablePaymentPage(result));
}, [user]);
return ( useEffect(() => {
<> if (user.type !== "student" && user.type !== "teacher")
<Modal isOpen={isTicketOpen} onClose={() => setIsTicketOpen(false)} title="Submit a ticket"> return setDisablePaymentPage(false);
<TicketSubmission user={user} page={window.location.href} onClose={() => setIsTicketOpen(false)} /> isUserFromCorporate(user.id).then((result) =>
</Modal> setDisablePaymentPage(result)
);
}, [user]);
{user && <MobileMenu path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />} const badges = [
<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"> module: "reading",
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" /> icon: () => <BsBook className="h-4 w-4 text-white" />,
<h1 className="-md:hidden w-1/6 text-2xl font-bold">EnCoach</h1> achieved: user.levels.reading >= user.desiredLevels.reading,
</Link> },
<div className="flex items-center justify-end gap-4 md:mr-8 md:w-5/6">
{/* OPEN TICKET SYSTEM */}
<button
className={clsx(
"border-mti-purple-light tooltip tooltip-bottom flex h-8 w-8 flex-col items-center justify-center rounded-full border p-1",
"hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white",
)}
data-tip="Submit a help/feedback ticket"
onClick={() => setIsTicketOpen(true)}>
<BsQuestionCircleFill />
</button>
{showExpirationDate() && ( {
<Link module: "listening",
href={disablePaymentPage ? "/payment" : ""} icon: () => <BsHeadphones className="h-4 w-4 text-white" />,
data-tip="Expiry date" achieved: user.levels.listening >= user.desiredLevels.listening,
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", module: "writing",
!user.subscriptionExpirationDate icon: () => <BsPen className="h-4 w-4 text-white" />,
? "bg-mti-green-ultralight border-mti-green-light" achieved: user.levels.writing >= user.desiredLevels.writing,
: expirationDateColor(user.subscriptionExpirationDate), },
"border-mti-gray-platinum bg-white", {
)}> module: "speaking",
{!user.subscriptionExpirationDate && "Unlimited"} icon: () => <BsMegaphone className="h-4 w-4 text-white" />,
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")} achieved: user.levels.speaking >= user.desiredLevels.speaking,
</Link> },
)} {
<Link href={disableNavigation ? "" : "/profile"} className="-md:hidden flex items-center justify-end gap-6"> module: "level",
<img src={user.profilePicture} alt={user.name} className="h-10 w-10 rounded-full object-cover" /> icon: () => <BsClipboard className="h-4 w-4 text-white" />,
<span className="-md:hidden text-right"> achieved: user.levels.level >= user.desiredLevels.level,
{user.type === "corporate" ? `${user.corporateInformation?.companyInformation.name} |` : ""} {user.name} |{" "} },
{USER_TYPE_LABELS[user.type]} ];
</span>
</Link> return (
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}> <>
<BsList className="text-mti-purple-light h-8 w-8" /> <Modal
</div> isOpen={isTicketOpen}
</div> onClose={() => setIsTicketOpen(false)}
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />} title="Submit a ticket"
</header> >
</> <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>
</>
);
} }

View File

@@ -1,77 +0,0 @@
import {DurationUnit} from "@/interfaces/paypal";
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OnCancelledActions, OrderResponseBody} from "@paypal/paypal-js";
import {PayPalButtons, PayPalScriptProvider, usePayPalScriptReducer} from "@paypal/react-paypal-js";
import axios from "axios";
import {useEffect, useState} from "react";
import {toast} from "react-toastify";
interface Props {
clientID: string;
currency: string;
price: number;
duration: number;
duration_unit: DurationUnit;
loadScript?: boolean;
setIsLoading: (isLoading: boolean) => void;
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
}
export default function PayPalPayment({clientID, price, currency, duration, duration_unit, loadScript, setIsLoading, onSuccess}: Props) {
const createOrder = async (data: CreateOrderData, actions: CreateOrderActions): Promise<string> => {
setIsLoading(true);
return axios
.post<OrderResponseBody>("/api/paypal", {currencyCode: currency, price})
.then((response) => response.data)
.then((data) => data.id);
};
const onApprove = async (data: OnApproveData, actions: OnApproveActions) => {
const request = await axios.post<{ok: boolean; reason?: string}>("/api/paypal/approve", {id: data.orderID, duration, duration_unit});
if (request.status !== 200) {
toast.error("Something went wrong, please try again later");
return;
}
toast.success("Your account has been credited more time!");
return onSuccess(duration, duration_unit);
};
const onError = async (data: Record<string, unknown>) => {
setIsLoading(false);
};
const onCancel = async (data: Record<string, unknown>, actions: OnCancelledActions) => {
setIsLoading(false);
};
return loadScript ? (
<PayPalScriptProvider
options={{
clientId: clientID,
currency,
intent: "capture",
commit: true,
vault: true,
}}>
<PayPalButtons
className="w-full"
style={{layout: "vertical"}}
createOrder={createOrder}
onApprove={onApprove}
onCancel={onCancel}
onError={onError}
/>
</PayPalScriptProvider>
) : (
<PayPalButtons
className="w-full"
style={{layout: "vertical"}}
createOrder={createOrder}
onApprove={onApprove}
onCancel={onCancel}
onError={onError}
/>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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;

View 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;

View File

@@ -1,295 +1,229 @@
import clsx from "clsx"; import clsx from "clsx";
import { IconType } from "react-icons"; import {IconType} from "react-icons";
import { MdSpaceDashboard } from "react-icons/md"; import {MdSpaceDashboard} from "react-icons/md";
import { import {
BsFileEarmarkText, BsFileEarmarkText,
BsClockHistory, BsClockHistory,
BsPencil, BsPencil,
BsGraphUp, BsGraphUp,
BsChevronBarRight, BsChevronBarRight,
BsChevronBarLeft, BsChevronBarLeft,
BsShieldFill, BsShieldFill,
BsCloudFill, BsCloudFill,
BsCurrencyDollar, BsCurrencyDollar,
BsClipboardData, BsClipboardData,
BsFileLock,
} from "react-icons/bs"; } from "react-icons/bs";
import { RiLogoutBoxFill } from "react-icons/ri"; import {RiLogoutBoxFill} from "react-icons/ri";
import { SlPencil } from "react-icons/sl"; import {SlPencil} from "react-icons/sl";
import { FaAward } from "react-icons/fa"; import {FaAward} from "react-icons/fa";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import {useRouter} from "next/router";
import axios from "axios"; import axios from "axios";
import FocusLayer from "@/components/FocusLayer"; import FocusLayer from "@/components/FocusLayer";
import { preventNavigation } from "@/utils/navigation.disabled"; import {preventNavigation} from "@/utils/navigation.disabled";
import { useState } from "react"; import {useEffect, useState} from "react";
import usePreferencesStore from "@/stores/preferencesStore"; import usePreferencesStore from "@/stores/preferencesStore";
import { Type } from "@/interfaces/user"; import {User} from "@/interfaces/user";
import useTicketsListener from "@/hooks/useTicketsListener";
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
interface Props { interface Props {
path: string; path: string;
navDisabled?: boolean; navDisabled?: boolean;
focusMode?: boolean; focusMode?: boolean;
onFocusLayerMouseEnter?: () => void; onFocusLayerMouseEnter?: () => void;
className?: string; className?: string;
userType?: Type; user: User;
} }
interface NavProps { interface NavProps {
Icon: IconType; Icon: IconType;
label: string; label: string;
path: string; path: string;
keyPath: string; keyPath: string;
disabled?: boolean; disabled?: boolean;
isMinimized?: boolean; isMinimized?: boolean;
badge?: number;
} }
const Nav = ({ const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false, badge}: NavProps) => {
Icon, return (
label, <Link
path, href={!disabled ? keyPath : ""}
keyPath, className={clsx(
disabled = false, "flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white",
isMinimized = false, "transition-all duration-300 ease-in-out relative",
}: NavProps) => ( disabled ? "hover:bg-mti-gray-dim cursor-not-allowed" : "hover:bg-mti-purple-light cursor-pointer",
<Link path === keyPath && "bg-mti-purple-light text-white",
href={!disabled ? keyPath : ""} isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]",
className={clsx( )}>
"flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white", <Icon size={24} />
"transition-all duration-300 ease-in-out", {!isMinimized && <span className="text-lg font-semibold">{label}</span>}
disabled {!!badge && badge > 0 && (
? "hover:bg-mti-gray-dim cursor-not-allowed" <div
: "hover:bg-mti-purple-light cursor-pointer", className={clsx(
path === keyPath && "bg-mti-purple-light text-white", "bg-mti-purple-light h-5 w-5 text-xs rounded-full flex items-center justify-center text-white",
isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]", "transition ease-in-out duration-300",
)} isMinimized && "absolute right-0 top-0",
> )}>
<Icon size={24} /> {badge}
{!isMinimized && <span className="text-lg font-semibold">{label}</span>} </div>
</Link> )}
); </Link>
);
};
export default function Sidebar({ export default function Sidebar({path, navDisabled = false, focusMode = false, user, onFocusLayerMouseEnter, className}: Props) {
path, const router = useRouter();
navDisabled = false,
focusMode = false,
userType,
onFocusLayerMouseEnter,
className,
}: Props) {
const router = useRouter();
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [ const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
state.isSidebarMinimized,
state.toggleSidebarMinimized,
]);
const logout = async () => { const {totalAssignedTickets} = useTicketsListener(user.id);
axios.post("/api/logout").finally(() => {
setTimeout(() => router.reload(), 500);
});
};
const disableNavigation = preventNavigation(navDisabled, focusMode); const logout = async () => {
axios.post("/api/logout").finally(() => {
setTimeout(() => router.reload(), 500);
});
};
return ( const disableNavigation = preventNavigation(navDisabled, focusMode);
<section
className={clsx(
"relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8",
isMinimized ? "w-fit" : "-xl:w-fit w-1/6",
className,
)}
>
<div className="-xl:hidden flex-col gap-3 xl:flex">
<Nav
disabled={disableNavigation}
Icon={MdSpaceDashboard}
label="Dashboard"
path={path}
keyPath="/"
isMinimized={isMinimized}
/>
{(userType === "student" ||
userType === "teacher" ||
userType === "developer") && (
<>
<Nav
disabled={disableNavigation}
Icon={BsFileEarmarkText}
label="Exams"
path={path}
keyPath="/exam"
isMinimized={isMinimized}
/>
<Nav
disabled={disableNavigation}
Icon={BsPencil}
label="Exercises"
path={path}
keyPath="/exercises"
isMinimized={isMinimized}
/>
</>
)}
<Nav
disabled={disableNavigation}
Icon={BsGraphUp}
label="Stats"
path={path}
keyPath="/stats"
isMinimized={isMinimized}
/>
<Nav
disabled={disableNavigation}
Icon={BsClockHistory}
label="Record"
path={path}
keyPath="/record"
isMinimized={isMinimized}
/>
{["admin", "developer", "agent", "corporate"].includes(
userType || "",
) && (
<Nav
disabled={disableNavigation}
Icon={BsCurrencyDollar}
label="Payment Record"
path={path}
keyPath="/payment-record"
isMinimized={isMinimized}
/>
)}
{["admin", "developer", "corporate", "teacher"].includes(
userType || "",
) && (
<Nav
disabled={disableNavigation}
Icon={BsShieldFill}
label="Settings"
path={path}
keyPath="/settings"
isMinimized={isMinimized}
/>
)}
{["admin", "developer", "agent"].includes(userType || "") && (
<Nav
disabled={disableNavigation}
Icon={BsClipboardData}
label="Tickets"
path={path}
keyPath="/tickets"
isMinimized={isMinimized}
/>
)}
{userType === "developer" && (
<Nav
disabled={disableNavigation}
Icon={BsCloudFill}
label="Generation"
path={path}
keyPath="/generation"
isMinimized={isMinimized}
/>
)}
</div>
<div className="-xl:flex flex-col gap-3 xl:hidden">
<Nav
disabled={disableNavigation}
Icon={MdSpaceDashboard}
label="Dashboard"
path={path}
keyPath="/"
isMinimized={true}
/>
<Nav
disabled={disableNavigation}
Icon={BsFileEarmarkText}
label="Exams"
path={path}
keyPath="/exam"
isMinimized={true}
/>
<Nav
disabled={disableNavigation}
Icon={BsPencil}
label="Exercises"
path={path}
keyPath="/exercises"
isMinimized={true}
/>
<Nav
disabled={disableNavigation}
Icon={BsGraphUp}
label="Stats"
path={path}
keyPath="/stats"
isMinimized={true}
/>
<Nav
disabled={disableNavigation}
Icon={BsClockHistory}
label="Record"
path={path}
keyPath="/record"
isMinimized={true}
/>
{userType !== "student" && (
<Nav
disabled={disableNavigation}
Icon={BsShieldFill}
label="Settings"
path={path}
keyPath="/settings"
isMinimized={true}
/>
)}
{userType === "developer" && (
<Nav
disabled={disableNavigation}
Icon={BsCloudFill}
label="Generation"
path={path}
keyPath="/generation"
isMinimized={true}
/>
)}
</div>
<div className="fixed bottom-12 flex flex-col gap-0"> return (
<div <section
role="button" className={clsx(
tabIndex={1} "relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8",
onClick={toggleMinimize} isMinimized ? "w-fit" : "-xl:w-fit w-1/6",
className={clsx( className,
"hover:text-mti-rose -xl:hidden flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out", )}>
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8", <div className="-xl:hidden flex-col gap-3 xl:flex">
)} <Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
> {checkAccess(user, ["student", "teacher", "developer"], "viewExams") && (
{isMinimized ? ( <Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={isMinimized} />
<BsChevronBarRight size={24} /> )}
) : ( {checkAccess(user, ["student", "teacher", "developer"], "viewExercises") && (
<BsChevronBarLeft size={24} /> <Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={isMinimized} />
)} )}
{!isMinimized && ( {checkAccess(user, getTypesOfUser(["agent"]), "viewStats") && (
<span className="text-lg font-medium">Minimize</span> <Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
)} )}
</div> {checkAccess(user, getTypesOfUser(["agent"]), "viewRecords") && (
<div <Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
role="button" )}
tabIndex={1} {checkAccess(user, ["admin", "developer", "agent", "corporate", "mastercorporate"], "viewPaymentRecords") && (
onClick={focusMode ? () => {} : logout} <Nav
className={clsx( disabled={disableNavigation}
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out", Icon={BsCurrencyDollar}
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8", label="Payment Record"
)} path={path}
> keyPath="/payment-record"
<RiLogoutBoxFill size={24} /> isMinimized={isMinimized}
{!isMinimized && ( />
<span className="-xl:hidden text-lg font-medium">Log Out</span> )}
)} {checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"]) && (
</div> <Nav
</div> disabled={disableNavigation}
{focusMode && ( Icon={BsShieldFill}
<FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} /> label="Settings"
)} path={path}
</section> keyPath="/settings"
); isMinimized={isMinimized}
/>
)}
{checkAccess(user, ["admin", "developer", "agent"], "viewTickets") && (
<Nav
disabled={disableNavigation}
Icon={BsClipboardData}
label="Tickets"
path={path}
keyPath="/tickets"
isMinimized={isMinimized}
badge={totalAssignedTickets}
/>
)}
{checkAccess(user, ["developer", "admin"]) && (
<>
<Nav
disabled={disableNavigation}
Icon={BsCloudFill}
label="Generation"
path={path}
keyPath="/generation"
isMinimized={isMinimized}
/>
<Nav
disabled={disableNavigation}
Icon={BsFileLock}
label="Permissions"
path={path}
keyPath="/permissions"
isMinimized={isMinimized}
/>
</>
)}
</div>
<div className="-xl:flex flex-col gap-3 xl:hidden">
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} />
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} />
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
{checkAccess(user, getTypesOfUser(["agent"]), "viewStats") && (
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
)}
{checkAccess(user, getTypesOfUser(["agent"]), "viewRecords") && (
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
)}
{checkAccess(user, getTypesOfUser(["student"])) && (
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized={true} />
)}
{checkAccess(user, getTypesOfUser(["student"])) && (
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Permissions" path={path} keyPath="/permissions" isMinimized={true} />
)}
{checkAccess(user, ["developer"]) && (
<>
<Nav
disabled={disableNavigation}
Icon={BsCloudFill}
label="Generation"
path={path}
keyPath="/generation"
isMinimized={true}
/>
<Nav
disabled={disableNavigation}
Icon={BsFileLock}
label="Permissions"
path={path}
keyPath="/permissions"
isMinimized={true}
/>
</>
)}
</div>
<div className="fixed bottom-12 flex flex-col gap-0">
<div
role="button"
tabIndex={1}
onClick={toggleMinimize}
className={clsx(
"hover:text-mti-rose -xl:hidden flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
)}>
{isMinimized ? <BsChevronBarRight size={24} /> : <BsChevronBarLeft size={24} />}
{!isMinimized && <span className="text-lg font-medium">Minimize</span>}
</div>
<div
role="button"
tabIndex={1}
onClick={focusMode ? () => {} : logout}
className={clsx(
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
)}>
<RiLogoutBoxFill size={24} />
{!isMinimized && <span className="-xl:hidden text-lg font-medium">Log Out</span>}
</div>
</div>
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
</section>
);
} }

View File

@@ -5,12 +5,30 @@ import {CommonProps} from ".";
import {Fragment} from "react"; import {Fragment} from "react";
import Button from "../Low/Button"; import Button from "../Low/Button";
export default function FillBlanksSolutions({id, type, prompt, solutions, text, userSolutions, onNext, onBack}: FillBlanksExercise & CommonProps) { export default function FillBlanksSolutions({
id,
type,
prompt,
solutions,
words,
text,
userSolutions,
onNext,
onBack,
}: FillBlanksExercise & CommonProps) {
const calculateScore = () => { const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0; const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = userSolutions.filter( const correct = userSolutions.filter((x) => {
(x) => solutions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false, const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution.toLowerCase();
).length; if (!solution) return false;
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 - userSolutions.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length; const missing = total - userSolutions.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return {total, correct, missing}; return {total, correct, missing};
@@ -35,7 +53,14 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
); );
} }
if (userSolution.solution === solution.solution) { const userSolutionWord = words.find((w) =>
typeof w === "string"
? w.toLowerCase() === userSolution.solution.toLowerCase()
: w.letter.toLowerCase() === userSolution.solution.toLowerCase(),
);
const userSolutionText = typeof userSolutionWord === "string" ? userSolutionWord : userSolutionWord?.word;
if (userSolutionText === solution.solution) {
return ( return (
<button <button
className={clsx( className={clsx(
@@ -47,7 +72,7 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
); );
} }
if (userSolution.solution !== solution.solution) { if (userSolutionText !== solution.solution) {
return ( return (
<> <>
<button <button
@@ -55,7 +80,7 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
"rounded-full hover:text-white hover:bg-mti-rose transition duration-300 ease-in-out my-1 mr-1", "rounded-full hover:text-white hover:bg-mti-rose transition duration-300 ease-in-out my-1 mr-1",
userSolution && "px-5 py-2 text-center text-white bg-mti-rose-light", userSolution && "px-5 py-2 text-center text-white bg-mti-rose-light",
)}> )}>
{userSolution.solution} {userSolutionText}
</button> </button>
<button <button
@@ -75,7 +100,7 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
return ( return (
<> <>
<div className="flex flex-col gap-4 mt-4 h-full mb-20"> <div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
<span className="text-sm w-full leading-6"> <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}>

View File

@@ -132,11 +132,16 @@ export default function InteractiveSpeaking({
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && ( {userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-4 w-full">
<div className="flex gap-4 px-1"> <div className="flex gap-4 px-1">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => ( {Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
<div className="bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2" key={key}> const taskResponse = userSolutions[0].evaluation!.task_response[key];
{key}: Level {userSolutions[0].evaluation!.task_response[key]} const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
</div>
))} return (
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2")} key={key}>
{key}: Level {grade}
</div>
);
})}
</div> </div>
{userSolutions[0].evaluation && {userSolutions[0].evaluation &&
Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length === 3 ? ( Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length === 3 ? (
@@ -186,6 +191,17 @@ export default function InteractiveSpeaking({
}> }>
Recommended Answer (Prompt 3) Recommended Answer (Prompt 3)
</Tab> </Tab>
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
)
}>
Global Overview
</Tab>
</Tab.List> </Tab.List>
<Tab.Panels> <Tab.Panels>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4"> <Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
@@ -193,19 +209,36 @@ export default function InteractiveSpeaking({
</Tab.Panel> </Tab.Panel>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4"> <Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap"> <span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
{userSolutions[0].evaluation!.perfect_answer_1!.replaceAll(/\s{2,}/g, "\n\n")} {userSolutions[0].evaluation!.perfect_answer_1!.answer.replaceAll(/\s{2,}/g, "\n\n")}
</span> </span>
</Tab.Panel> </Tab.Panel>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4"> <Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap"> <span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
{userSolutions[0].evaluation!.perfect_answer_2!.replaceAll(/\s{2,}/g, "\n\n")} {userSolutions[0].evaluation!.perfect_answer_2!.answer.replaceAll(/\s{2,}/g, "\n\n")}
</span> </span>
</Tab.Panel> </Tab.Panel>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4"> <Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap"> <span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
{userSolutions[0].evaluation!.perfect_answer_3!.replaceAll(/\s{2,}/g, "\n\n")} {userSolutions[0].evaluation!.perfect_answer_3!.answer.replaceAll(/\s{2,}/g, "\n\n")}
</span> </span>
</Tab.Panel> </Tab.Panel>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<div className="flex flex-col gap-4">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
const taskResponse = userSolutions[0].evaluation!.task_response[key];
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
return (
<div key={key} className="flex flex-col gap-2">
<span className={"font-semibold"}>
{key}: Level {grade}
</span>
{typeof taskResponse !== "number" && <span>{taskResponse.comment}</span>}
</div>
);
})}
</div>
</Tab.Panel>
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
) : ( ) : (

View File

@@ -1,4 +1,4 @@
import {MatchSentencesExercise} from "@/interfaces/exam"; import {MatchSentenceExerciseSentence, MatchSentencesExercise} from "@/interfaces/exam";
import clsx from "clsx"; import clsx from "clsx";
import LineTo from "react-lineto"; import LineTo from "react-lineto";
import {CommonProps} from "."; import {CommonProps} from ".";
@@ -9,6 +9,48 @@ import {Fragment} from "react";
import Button from "../Low/Button"; import Button from "../Low/Button";
import Xarrow from "react-xarrows"; import Xarrow from "react-xarrows";
function QuestionSolutionArea({
question,
userSolution,
}: {
question: MatchSentenceExerciseSentence;
userSolution?: {question: string; option: string};
}) {
return (
<div className="grid grid-cols-3 gap-4">
<div className="flex items-center gap-3 cursor-pointer col-span-2">
<button
className={clsx(
"text-white w-8 h-8 rounded-full z-10",
!userSolution
? "bg-mti-gray-davy"
: userSolution.option.toString() === question.solution.toString()
? "bg-mti-purple"
: "bg-mti-rose",
"transition duration-300 ease-in-out",
)}>
{question.id}
</button>
<span>{question.sentence}</span>
</div>
<div
className={clsx(
"w-56 h-10 border rounded-xl items-center justify-center flex gap-3 px-2",
!userSolution
? "border-mti-gray-davy"
: userSolution.option.toString() === question.solution.toString()
? "border-mti-purple"
: "border-mti-rose",
)}>
<span className="line-through">
{userSolution && userSolution?.option.toString() !== question.solution.toString() && `Paragraph ${userSolution.option}`}
</span>
<span className="font-semibold">Paragraph {question.solution}</span>
</div>
</div>
);
}
export default function MatchSentencesSolutions({ export default function MatchSentencesSolutions({
id, id,
type, type,
@@ -31,7 +73,7 @@ export default function MatchSentencesSolutions({
return ( return (
<> <>
<div className="flex flex-col gap-4 mt-4 h-full mb-20"> <div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
<span className="text-sm w-full leading-6"> <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}>
@@ -40,57 +82,18 @@ export default function MatchSentencesSolutions({
</Fragment> </Fragment>
))} ))}
</span> </span>
<div className="flex gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6"> <div className="flex flex-col gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{sentences.map(({sentence, id, solution}) => ( {sentences.map((question) => (
<div key={`question_${id}`} className="flex items-center justify-end gap-2 cursor-pointer"> <QuestionSolutionArea
<span>{sentence} </span> question={question}
<button userSolution={userSolutions.find((x) => x.question.toString() === question.id.toString())}
id={id} key={`question_${question.id}`}
className={clsx(
"w-8 h-8 rounded-full z-10 text-white",
"transition duration-300 ease-in-out",
!userSolutions.find((x) => x.question.toString() === id.toString()) && "!bg-mti-gray-davy",
userSolutions.find((x) => x.question.toString() === id.toString())?.option === solution && "bg-mti-purple",
userSolutions.find((x) => x.question.toString() === id.toString())?.option !== solution && "bg-mti-rose",
)}>
{id}
</button>
</div>
))}
</div>
<div className="flex flex-col gap-4">
{options.map(({sentence, id}) => (
<div key={`answer_${id}`} className={clsx("flex items-center justify-start gap-2 cursor-pointer")}>
<button
id={id}
className={clsx(
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
"transition duration-300 ease-in-out",
)}>
{id}
</button>
<span>{sentence}</span>
</div>
))}
</div>
{userSolutions &&
sentences.map((sentence, index) => (
<Xarrow
key={index}
start={sentence.id}
end={sentence.solution}
lineColor={
!userSolutions.find((x) => x.question === sentence.id)
? "#CC5454"
: userSolutions.find((x) => x.question === sentence.id)?.option === sentence.solution
? "#7872BF"
: "#CC5454"
}
showHead={false}
/> />
))} ))}
</div>
</div> </div>
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-purple" /> Correct <div className="w-4 h-4 rounded-full bg-mti-purple" /> Correct

View File

@@ -1,17 +1,27 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam"; import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import clsx from "clsx"; import clsx from "clsx";
import {useEffect, 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"; import Button from "../Low/Button";
function Question({ function Question({
id,
variant, variant,
prompt, prompt,
solution, solution,
options, options,
userSolution, userSolution,
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) { }: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
const renderPrompt = (prompt: string) => {
return reactStringReplace(prompt, /((<u>)[\w\s']+(<\/u>))/g, (match) => {
const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
return word.length > 0 ? <u>{word}</u> : null;
});
};
const optionColor = (option: string) => { const optionColor = (option: string) => {
if (option === solution && !userSolution) { if (option === solution && !userSolution) {
return "!border-mti-gray-davy !text-mti-gray-davy"; return "!border-mti-gray-davy !text-mti-gray-davy";
@@ -26,7 +36,15 @@ function Question({
return ( return (
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<span>{prompt}</span> {isNaN(Number(id)) ? (
<span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
) : (
<span className="">
<>
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
</>
</span>
)}
<div className="grid grid-cols-4 gap-4 place-items-center"> <div className="grid grid-cols-4 gap-4 place-items-center">
{variant === "image" && {variant === "image" &&
options.map((option) => ( options.map((option) => (
@@ -54,17 +72,8 @@ function Question({
); );
} }
export default function MultipleChoice({ export default function MultipleChoice({id, type, prompt, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
id, const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
type,
prompt,
questions,
userSolutions,
updateIndex,
onNext,
onBack,
}: MultipleChoiceExercise & CommonProps) {
const [questionIndex, setQuestionIndex] = useState(0);
const calculateScore = () => { const calculateScore = () => {
const total = questions.length; const total = questions.length;
@@ -76,15 +85,11 @@ export default function MultipleChoice({
return {total, correct, missing}; return {total, correct, missing};
}; };
useEffect(() => {
if (updateIndex) updateIndex(questionIndex);
}, [questionIndex, updateIndex]);
const next = () => { const next = () => {
if (questionIndex === questions.length - 1) { if (questionIndex === questions.length - 1) {
onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type}); onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
} else { } else {
setQuestionIndex((prev) => prev + 1); setQuestionIndex(questionIndex + 1);
} }
}; };
@@ -92,7 +97,7 @@ export default function MultipleChoice({
if (questionIndex === 0) { if (questionIndex === 0) {
onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type}); onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type});
} else { } else {
setQuestionIndex((prev) => prev - 1); setQuestionIndex(questionIndex - 1);
} }
}; };

View File

@@ -20,6 +20,9 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
useEffect(() => { useEffect(() => {
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) { if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
const solution = userSolutions[0].solution;
if (solution.startsWith("https://")) return setSolutionURL(solution);
axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => { axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => {
const blob = new Blob([data], {type: "audio/wav"}); const blob = new Blob([data], {type: "audio/wav"});
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -123,11 +126,16 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && ( {userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-4 w-full">
<div className="flex gap-4 px-1"> <div className="flex gap-4 px-1">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => ( {Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
<div className="bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2" key={key}> const taskResponse = userSolutions[0].evaluation!.task_response[key];
{key}: Level {userSolutions[0].evaluation!.task_response[key]} const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
</div>
))} return (
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2")} key={key}>
{key}: Level {grade}
</div>
);
})}
</div> </div>
{userSolutions[0].evaluation && {userSolutions[0].evaluation &&
(userSolutions[0].evaluation.perfect_answer || userSolutions[0].evaluation.perfect_answer_1) ? ( (userSolutions[0].evaluation.perfect_answer || userSolutions[0].evaluation.perfect_answer_1) ? (
@@ -155,6 +163,17 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
}> }>
Recommended Answer Recommended Answer
</Tab> </Tab>
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
)
}>
Global Overview
</Tab>
</Tab.List> </Tab.List>
<Tab.Panels> <Tab.Panels>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4"> <Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
@@ -168,6 +187,23 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
userSolutions[0].evaluation!.perfect_answer_1.replaceAll(/\s{2,}/g, "\n\n")} userSolutions[0].evaluation!.perfect_answer_1.replaceAll(/\s{2,}/g, "\n\n")}
</span> </span>
</Tab.Panel> </Tab.Panel>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<div className="flex flex-col gap-4">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
const taskResponse = userSolutions[0].evaluation!.task_response[key];
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
return (
<div key={key} className="flex flex-col gap-2">
<span className={"font-semibold"}>
{key}: Level {grade}
</span>
{typeof taskResponse !== "number" && <span>{taskResponse.comment}</span>}
</div>
);
})}
</div>
</Tab.Panel>
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
) : ( ) : (

View File

@@ -38,7 +38,7 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
return ( return (
<> <>
<div className="flex flex-col gap-4 mt-4 h-full mb-20"> <div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
<span className="text-sm w-full leading-6"> <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}>

View File

@@ -107,7 +107,7 @@ export default function WriteBlanksSolutions({
return ( return (
<> <>
<div className="flex flex-col gap-4 mt-4 h-full mb-20"> <div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
<span className="text-sm w-full leading-6"> <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}>

View File

@@ -7,11 +7,17 @@ import {Dialog, Tab, Transition} from "@headlessui/react";
import {writingReverseMarking} from "@/utils/score"; import {writingReverseMarking} from "@/utils/score";
import clsx from "clsx"; import clsx from "clsx";
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer"; import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
import useUser from "@/hooks/useUser";
import AIDetection from "../AIDetection";
export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) { export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [showDiff, setShowDiff] = useState(false); const [showDiff, setShowDiff] = useState(false);
const {user} = useUser();
const aiEval = userSolutions && userSolutions.length > 0 ? userSolutions[0].evaluation?.ai_detection : undefined;
return ( return (
<> <>
{attachment && ( {attachment && (
@@ -117,11 +123,16 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && ( {userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-4 w-full">
<div className="flex gap-4 px-1"> <div className="flex gap-4 px-1">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => ( {Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
<div className="bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2" key={key}> const taskResponse = userSolutions[0].evaluation!.task_response[key];
{key}: Level {userSolutions[0].evaluation!.task_response[key]} const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
</div>
))} return (
<div className={clsx("bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2")} key={key}>
{key}: Level {grade}
</div>
);
})}
</div> </div>
{userSolutions[0].evaluation && userSolutions[0].evaluation.perfect_answer ? ( {userSolutions[0].evaluation && userSolutions[0].evaluation.perfect_answer ? (
<Tab.Group> <Tab.Group>
@@ -148,6 +159,30 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
}> }>
Recommended Answer Recommended Answer
</Tab> </Tab>
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
)
}>
Global Overview
</Tab>
{aiEval && user?.type !== "student" && (
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
)
}>
AI Use
</Tab>
)}
</Tab.List> </Tab.List>
<Tab.Panels> <Tab.Panels>
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4"> <Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
@@ -158,6 +193,28 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
{userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n").replaceAll("\\n", "\n")} {userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n").replaceAll("\\n", "\n")}
</span> </span>
</Tab.Panel> </Tab.Panel>
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<div className="flex flex-col gap-4">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
const taskResponse = userSolutions[0].evaluation!.task_response[key];
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
return (
<div key={key} className="flex flex-col gap-2">
<span className={"font-semibold"}>
{key}: Level {grade}
</span>
{typeof taskResponse !== "number" && <span>{taskResponse.comment}</span>}
</div>
);
})}
</div>
</Tab.Panel>
{aiEval && user?.type !== "student" && (
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<AIDetection {...aiEval} />
</Tab.Panel>
)}
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
) : ( ) : (

View File

@@ -22,7 +22,6 @@ import Writing from "./Writing";
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), {ssr: false}); const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), {ssr: false});
export interface CommonProps { export interface CommonProps {
updateIndex?: (internalIndex: number) => void;
onNext: (userSolutions: UserSolution) => void; onNext: (userSolutions: UserSolution) => void;
onBack: (userSolutions: UserSolution) => void; onBack: (userSolutions: UserSolution) => void;
} }
@@ -36,15 +35,7 @@ export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: (
case "matchSentences": case "matchSentences":
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />; return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
case "multipleChoice": case "multipleChoice":
return ( return <MultipleChoice key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
<MultipleChoice
key={exercise.id}
{...(exercise as MultipleChoiceExercise)}
updateIndex={updateIndex}
onNext={onNext}
onBack={onBack}
/>
);
case "writeBlanks": case "writeBlanks":
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />; return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
case "writing": case "writing":

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
export type Error = "E001" | "E002"; export type Error = "E001" | "E002" | "E003";
export interface ErrorMessage { export interface ErrorMessage {
error: Error; error: Error;
message: string; message: string;
@@ -7,4 +7,5 @@ export interface ErrorMessage {
export const errorMessages: {[key in Error]: string} = { export const errorMessages: {[key in Error]: string} = {
E001: "Wrong password!", E001: "Wrong password!",
E002: "Invalid e-mail", E002: "Invalid e-mail",
E003: "E-mail already in use!",
}; };

View File

@@ -0,0 +1,13 @@
{
"type": "service_account",
"project_id": "encoach-staging",
"private_key_id": "5718a649419776df9637589f8696a258a6a70f6c",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC2C6Es2gY8lLvH\ndVilNtRNm9glSaPXMNw2PzZZbSGuG1uGPFaCzlq1lOb2u17YfMG4GriKIMjIQKXF\nqdvxA8CAmAFRuDjUGmpbO/X1ZW7amOs5Bjed2BYmL01dEqzzwwh7rEfNDjeghRPx\n1uKzH8A6TLT5xq+74I5K1CIgiljBpZimsERu2SDawjkdtZfA7qoylA46Nq66LuwQ\nVyv9CK2SZNpBcT3sunCmRsrCzmSTzKdbcqRPdqUKgZOH/Rjp0sw9VuUgwoxdGZV3\n5SJjObo5ceZ1OSiJm7GwLzp7uq16sqycgSYwppNLI5OtzOfSuWbGD4+a044t2Mlq\n9PHXv7H/AgMBAAECggEAAfhKlFwq8MaL6PggRJq9HbaKgQ4fcOmCmy8AQmPNF1UM\nyVKSKGndjxUfPLCWsaaunUnjZlHoKkvndKXxDyttuVaBE9EiWEqNjRLZ3KpuJ9Jm\nH+CtLbmUCnISQb1n1AlvvZAwhLZbLBL/PhYyWiLapybZAdJAaOWLVKGgBD8gVRQW\nJFCqnszX1O2YlpWHutb979R4qoY/XAf94gyMkTpXZwuETvFqZbau2vxRZ8qARix3\nmic881PwiF6Cod8UPCS9yMK+Q+Se6SomwXU9PCmlummn9xmQBAxYy8gIAVs/J9Fg\n5SvhnImAPDd+zIzzw2cHCiruNWIhroMVZDZJgWdY1QKBgQDjTKKeFOur3ijJJL2/\nWg1SE2jLP0GpXzM5YMx6jdOCNDCzugPngRucRXiTkJ2FnUgyMcQyi6hyrbWXN/6z\nXhx5fwLB4tnTcqOMvNfcay5mDk3RW9ZZJxayB54Sf1Nm/4xiDBnGPT+iHQvK+/pT\nwScWznFkmk60E796o76OLn3PEwKBgQDNCC2uPq+uOcCopIO8HH88gqdxTvpbeHUU\nrdJOmr1VtGNuvay/mfpva9+VEtGbZTFzjhfvfCEIjpj3Llh8Flb9EYa6BmscBiyp\ngszEeFuB3zHndlSCZPnGJ7JiRAdPAEgG3Gl/r9th6PDaEMq0MFS5i7GGhPBIRYCG\nUtmY5eVy5QKBgH5Nuls/YsnJFD7ZNLscziQadvPhvZnhNbSfjmBXaP2EBMAKEFtX\nCcGndN4C0RVLFbAWqWAw7LR0xGA4FEcVd5snsZ+Nb98oZ6sv0H9B67F4J1O7xXsa\n1mitBPBgYjbsr9RXxwa6SB7MJx5vMGXUAeWRZ78wY6V7B76dOKkHOo+TAoGBAJf5\nBOsPueZZFm2qK58GPGVcrsI0+StNuPLP+H+dANQC9mTCIMaQWmm2Oq5jmYwmUKZH\nX4R6rH2MPOOSrbGkWWwRTpyaX1ARX49xzVefoqw8BOB8/Bz+vYjcKcPeitBK9Bhp\nzaUAc4s6PzRTl/xBirtRSQ/df8ECC0cFKBbF6PHlAoGAGqnlpo+k8vAtg6ulCuGu\nx2Y/c5UmvXGHk60pccnW3UtENSDnl99OgMfBz8/qLAMWs6DUQ/kvSlHQPmMBHRWZ\nNTr6ceGXyNs4KdYoj1K7AU3c0Lm0wyQ2giQMoOOUQAm98Xr8z5aiihj10hHPmzzL\n9kwpOmZpjNmC/ERD69imWhY=\n-----END PRIVATE KEY-----\n",
"client_email": "firebase-adminsdk-8rs9e@encoach-staging.iam.gserviceaccount.com",
"client_id": "108221424237414412378",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-8rs9e%40encoach-staging.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}

View File

@@ -1,39 +1,91 @@
import {Type} from "@/interfaces/user"; import { Type } from "@/interfaces/user";
export const PERMISSIONS = { export const PERMISSIONS = {
generateCode: { generateCode: {
student: ["corporate", "developer", "admin"], student: ["corporate", "developer", "admin", "mastercorporate"],
teacher: ["corporate", "developer", "admin"], teacher: ["corporate", "developer", "admin", "mastercorporate"],
corporate: ["admin", "developer"], corporate: ["admin", "developer"],
admin: ["developer", "admin"], mastercorporate: ["admin", "developer"],
agent: ["developer", "admin"],
developer: ["developer"], admin: ["developer", "admin"],
}, agent: ["developer", "admin"],
deleteUser: { developer: ["developer"],
student: ["corporate", "developer", "admin"], },
teacher: ["corporate", "developer", "admin"], deleteUser: {
corporate: ["admin", "developer"], student: {
admin: ["developer", "admin"], perm: "deleteStudent",
agent: ["developer", "admin"], list: ["corporate", "developer", "admin", "mastercorporate"],
developer: ["developer"], },
}, teacher: {
updateUser: { perm: "deleteTeacher",
student: ["developer", "admin"], list: ["corporate", "developer", "admin", "mastercorporate"],
teacher: ["developer", "admin"], },
corporate: ["admin", "developer"], corporate: {
admin: ["developer", "admin"], perm: "deleteCorporate",
agent: ["developer", "admin"], list: ["admin", "developer"],
developer: ["developer"], },
}, mastercorporate: {
updateExpiryDate: { perm: undefined,
student: ["developer", "admin"], list: ["admin", "developer"],
teacher: ["developer", "admin"], },
corporate: ["admin", "developer"],
admin: ["developer", "admin"], admin: {
agent: ["developer", "admin"], perm: "deleteAdmin",
developer: ["developer"], list: ["developer", "admin"],
}, },
examManagement: { agent: {
delete: ["developer", "admin"], perm: "deleteCountryManager",
}, list: ["developer", "admin"],
},
developer: {
perm: undefined,
list: ["developer"],
},
},
updateUser: {
student: {
perm: "editStudent",
list: ["developer", "admin"],
},
teacher: {
perm: "editTeacher",
list: ["developer", "admin"],
},
corporate: {
perm: "editCorporate",
list: ["admin", "developer"],
},
mastercorporate: {
perm: undefined,
list: ["admin", "developer"],
},
admin: {
perm: "editAdmin",
list: ["developer", "admin"],
},
agent: {
perm: "editCountryManager",
list: ["developer", "admin"],
},
developer: {
perm: undefined,
list: ["developer"],
},
},
updateExpiryDate: {
student: ["developer", "admin"],
teacher: ["developer", "admin"],
corporate: ["admin", "developer"],
mastercorporate: ["admin", "developer"],
admin: ["developer", "admin"],
agent: ["developer", "admin"],
developer: ["developer"],
},
examManagement: {
delete: ["developer", "admin"],
},
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -2,243 +2,294 @@
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import useStats from "@/hooks/useStats"; import useStats from "@/hooks/useStats";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import { User} from "@/interfaces/user"; import { User } from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList"; import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils"; import { dateSorter } from "@/utils";
import moment from "moment"; import moment from "moment";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import {BsArrowLeft, BsPersonFill, BsBank, BsCurrencyDollar} from "react-icons/bs"; import {
BsArrowLeft,
BsPersonFill,
BsBank,
BsCurrencyDollar,
} from "react-icons/bs";
import UserCard from "@/components/UserCard"; import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import IconCard from "./IconCard"; import IconCard from "./IconCard";
import usePaymentStatusUsers from '@/hooks/usePaymentStatusUsers'; import usePaymentStatusUsers from "@/hooks/usePaymentStatusUsers";
interface Props { interface Props {
user: User; user: User;
} }
export default function AgentDashboard({user}: Props) { export default function AgentDashboard({ user }: Props) {
const [page, setPage] = useState(""); const [page, setPage] = useState("");
const [selectedUser, setSelectedUser] = useState<User>(); const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const {stats} = useStats(); const { stats } = useStats();
const {users, reload} = useUsers(); const { users, reload } = useUsers();
const {groups} = useGroups(user.id); const { groups } = useGroups(user.id);
const { pending, done } = usePaymentStatusUsers(); const { pending, done } = usePaymentStatusUsers();
useEffect(() => { useEffect(() => {
setShowModal(!!selectedUser && page === ""); setShowModal(!!selectedUser && page === "");
}, [selectedUser, page]); }, [selectedUser, page]);
const corporateFilter = (user: User) => user.type === "corporate"; const corporateFilter = (user: User) => user.type === "corporate";
const referredCorporateFilter = (x: User) => const referredCorporateFilter = (x: User) =>
x.type === "corporate" && !!x.corporateInformation && x.corporateInformation.referralAgent === user.id; x.type === "corporate" &&
const inactiveReferredCorporateFilter = (x: User) => !!x.corporateInformation &&
referredCorporateFilter(x) && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)); x.corporateInformation.referralAgent === user.id;
const inactiveReferredCorporateFilter = (x: User) =>
referredCorporateFilter(x) &&
(x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
const UserDisplay = ({ displayUser, allowClick = true }: {displayUser: User, allowClick?: boolean}) => ( const UserDisplay = ({
<div displayUser,
onClick={() => allowClick && setSelectedUser(displayUser)} allowClick = true,
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300"> }: {
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" /> displayUser: User;
<div className="flex flex-col gap-1 items-start"> allowClick?: boolean;
<span> }) => (
{displayUser.type === "corporate" <div
? displayUser.corporateInformation?.companyInformation?.name || displayUser.name onClick={() => allowClick && setSelectedUser(displayUser)}
: displayUser.name} className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300"
</span> >
<span className="text-sm opacity-75">{displayUser.email}</span> <img
</div> src={displayUser.profilePicture}
</div> alt={displayUser.name}
); className="rounded-full w-10 h-10"
/>
<div className="flex flex-col gap-1 items-start">
<span>
{displayUser.type === "corporate"
? displayUser.corporateInformation?.companyInformation?.name ||
displayUser.name
: displayUser.name}
</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
const ReferredCorporateList = () => { const ReferredCorporateList = () => {
return ( return (
<> <UserList
<div className="flex flex-col gap-4"> user={user}
<div filters={[referredCorporateFilter]}
onClick={() => setPage("")} renderHeader={(total) => (
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> <div className="flex flex-col gap-4">
<BsArrowLeft className="text-xl" /> <div
<span>Back</span> onClick={() => setPage("")}
</div> className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
<h2 className="text-2xl font-semibold">Referred Corporate ({users.filter(referredCorporateFilter).length})</h2> >
</div> <BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">
Referred Corporate ({total})
</h2>
</div>
)}
/>
);
};
<UserList user={user} filters={[referredCorporateFilter]} /> const InactiveReferredCorporateList = () => {
</> return (
); <UserList
}; user={user}
filters={[inactiveReferredCorporateFilter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">
Inactive Referred Corporate ({total})
</h2>
</div>
)}
/>
);
};
const InactiveReferredCorporateList = () => { const CorporateList = () => {
return ( const filter = (x: User) => x.type === "corporate";
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Inactive Referred Corporate ({users.filter(inactiveReferredCorporateFilter).length})</h2>
</div>
<UserList user={user} filters={[inactiveReferredCorporateFilter]} /> return (
</> <UserList
); user={user}
}; filters={[filter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Corporate ({total})</h2>
</div>
)}
/>
);
};
const CorporateList = () => { const CorporatePaidStatusList = ({ paid }: { paid: Boolean }) => {
const filter = (x: User) => x.type === "corporate"; const list = paid ? done : pending;
const filter = (x: User) => x.type === "corporate" && list.includes(x.id);
return ( return (
<> <UserList
<div className="flex flex-col gap-4"> user={user}
<div filters={[filter]}
onClick={() => setPage("")} renderHeader={(total) => (
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> <div className="flex flex-col gap-4">
<BsArrowLeft className="text-xl" /> <div
<span>Back</span> onClick={() => setPage("")}
</div> className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
<h2 className="text-2xl font-semibold">Corporate ({users.filter(filter).length})</h2> >
</div> <BsArrowLeft className="text-xl" />
<UserList user={user} filters={[filter]} /> <span>Back</span>
</> </div>
); <h2 className="text-2xl font-semibold">
}; {paid ? "Payment Done" : "Pending Payment"} ({total})
</h2>
</div>
)}
/>
);
};
const CorporatePaidStatusList = ({ paid }: {paid: Boolean}) => { const DefaultDashboard = () => (
const list = paid ? done : pending; <>
const filter = (x: User) => x.type === "corporate" && list.includes(x.id); <section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:gap-4 text-center">
<IconCard
onClick={() => setPage("referredCorporate")}
Icon={BsBank}
label="Referred Corporate"
value={users.filter(referredCorporateFilter).length}
color="purple"
/>
<IconCard
onClick={() => setPage("inactiveReferredCorporate")}
Icon={BsBank}
label="Inactive Referred Corporate"
value={users.filter(inactiveReferredCorporateFilter).length}
color="rose"
/>
<IconCard
onClick={() => setPage("corporate")}
Icon={BsBank}
label="Corporate"
value={users.filter(corporateFilter).length}
color="purple"
/>
<IconCard
onClick={() => setPage("paymentdone")}
Icon={BsCurrencyDollar}
label="Payment Done"
value={done.length}
color="purple"
/>
<IconCard
onClick={() => setPage("paymentpending")}
Icon={BsCurrencyDollar}
label="Pending Payment"
value={pending.length}
color="rose"
/>
</section>
return ( <section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<> <div className="bg-white shadow flex flex-col rounded-xl w-full">
<div className="flex flex-col gap-4"> <span className="p-4">Latest Referred Corporate</span>
<div <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
onClick={() => setPage("")} {users
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> .filter(referredCorporateFilter)
<BsArrowLeft className="text-xl" /> .sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
<span>Back</span> .map((x) => (
</div> <UserDisplay key={x.id} displayUser={x} />
<h2 className="text-2xl font-semibold">{paid ? 'Payment Done' : 'Pending Payment'} ({list.length})</h2> ))}
</div> </div>
<UserList user={user} filters={[filter]} /> </div>
</> <div className="bg-white shadow flex flex-col rounded-xl w-full">
); <span className="p-4">Latest corporate</span>
}; <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(corporateFilter)
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} displayUser={x} allowClick={false} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Referenced corporate expiring in 1 month</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(
(x) =>
referredCorporateFilter(x) &&
moment().isAfter(
moment(x.subscriptionExpirationDate).subtract(30, "days")
) &&
moment().isBefore(moment(x.subscriptionExpirationDate))
)
.map((x) => (
<UserDisplay key={x.id} displayUser={x} />
))}
</div>
</div>
</section>
</>
);
const DefaultDashboard = () => ( return (
<> <>
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:gap-4 text-center"> <Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
<IconCard <>
onClick={() => setPage("referredCorporate")} {selectedUser && (
Icon={BsBank} <div className="w-full flex flex-col gap-8">
label="Referred Corporate" <UserCard
value={users.filter(referredCorporateFilter).length} loggedInUser={user}
color="purple" onClose={(shouldReload) => {
/> setSelectedUser(undefined);
<IconCard if (shouldReload) reload();
onClick={() => setPage("inactiveReferredCorporate")} }}
Icon={BsBank} onViewStudents={
label="Inactive Referred Corporate" selectedUser.type === "corporate" ||
value={users.filter(inactiveReferredCorporateFilter).length} selectedUser.type === "teacher"
color="rose" ? () => setPage("students")
/> : undefined
<IconCard }
onClick={() => setPage("corporate")} onViewTeachers={
Icon={BsBank} selectedUser.type === "corporate"
label="Corporate" ? () => setPage("teachers")
value={users.filter(corporateFilter).length} : undefined
color="purple" }
/> user={selectedUser}
<IconCard />
onClick={() => setPage("paymentdone")} </div>
Icon={BsCurrencyDollar} )}
label="Payment Done" </>
value={done.length} </Modal>
color="purple" {page === "referredCorporate" && <ReferredCorporateList />}
/> {page === "corporate" && <CorporateList />}
<IconCard {page === "inactiveReferredCorporate" && (
onClick={() => setPage("paymentpending")} <InactiveReferredCorporateList />
Icon={BsCurrencyDollar} )}
label="Pending Payment" {page === "paymentdone" && <CorporatePaidStatusList paid={true} />}
value={pending.length} {page === "paymentpending" && <CorporatePaidStatusList paid={false} />}
color="rose" {page === "" && <DefaultDashboard />}
/> </>
</section> );
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest Referred Corporate</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(referredCorporateFilter)
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} displayUser={x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest corporate</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(corporateFilter)
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} displayUser={x} allowClick={false} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Referenced corporate expiring in 1 month</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(
(x) =>
referredCorporateFilter(x) &&
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
moment().isBefore(moment(x.subscriptionExpirationDate)),
)
.map((x) => (
<UserDisplay key={x.id} displayUser={x} />
))}
</div>
</div>
</section>
</>
);
return (
<>
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
<>
{selectedUser && (
<div className="w-full flex flex-col gap-8">
<UserCard
loggedInUser={user}
onClose={(shouldReload) => {
setSelectedUser(undefined);
if (shouldReload) reload();
}}
onViewStudents={
selectedUser.type === "corporate" || selectedUser.type === "teacher" ? () => setPage("students") : undefined
}
onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined}
user={selectedUser}
/>
</div>
)}
</>
</Modal>
{page === "referredCorporate" && <ReferredCorporateList />}
{page === "corporate" && <CorporateList />}
{page === "inactiveReferredCorporate" && <InactiveReferredCorporateList />}
{page === "paymentdone" && <CorporatePaidStatusList paid={true} />}
{page === "paymentpending" && <CorporatePaidStatusList paid={false} />}
{page === "" && <DefaultDashboard />}
</>
);
} }

View File

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

View File

@@ -2,7 +2,7 @@ import Input from "@/components/Low/Input";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import clsx from "clsx"; import clsx from "clsx";
import {useState} from "react"; import {useEffect, useState} from "react";
import {BsBook, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs"; import {BsBook, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
import {generate} from "random-words"; import {generate} from "random-words";
import {capitalize} from "lodash"; import {capitalize} from "lodash";
@@ -16,10 +16,11 @@ import moment from "moment";
import axios from "axios"; import axios from "axios";
import {getExam} from "@/utils/exams"; import {getExam} from "@/utils/exams";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import {uuidv4} from "@firebase/util";
import {Assignment} from "@/interfaces/results"; import {Assignment} from "@/interfaces/results";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import {Variant} from "@/interfaces/exam"; import {InstructorGender, Variant} from "@/interfaces/exam";
import Select from "@/components/Low/Select";
import useExams from "@/hooks/useExams";
interface Props { interface Props {
isCreating: boolean; isCreating: boolean;
@@ -40,8 +41,17 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(), assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(),
); );
const [variant, setVariant] = useState<Variant>("full"); const [variant, setVariant] = useState<Variant>("full");
const [instructorGender, setInstructorGender] = useState<InstructorGender>(assignment?.instructorGender || "varied");
// creates a new exam for each assignee or just one exam for all assignees // creates a new exam for each assignee or just one exam for all assignees
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false); const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
const [useRandomExams, setUseRandomExams] = useState(true);
const [examIDs, setExamIDs] = useState<{id: string; module: Module}[]>([]);
const {exams} = useExams();
useEffect(() => {
setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module)));
}, [selectedModules]);
const toggleModule = (module: Module) => { const toggleModule = (module: Module) => {
const modules = selectedModules.filter((x) => x !== module); const modules = selectedModules.filter((x) => x !== module);
@@ -59,10 +69,12 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
assignees, assignees,
name, name,
startDate, startDate,
examIDs: !useRandomExams ? examIDs : undefined,
endDate, endDate,
selectedModules, selectedModules,
generateMultiple, generateMultiple,
variant, variant,
instructorGender,
}) })
.then(() => { .then(() => {
toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`); toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`);
@@ -226,6 +238,53 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
</div> </div>
</div> </div>
{selectedModules.includes("speaking") && (
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor&apos;s Gender</label>
<Select
value={{value: instructorGender, label: capitalize(instructorGender)}}
onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)}
disabled={!selectedModules.includes("speaking") || !!assignment}
options={[
{value: "male", label: "Male"},
{value: "female", label: "Female"},
{value: "varied", label: "Varied"},
]}
/>
</div>
)}
{selectedModules.length > 0 && (
<div className="flex flex-col gap-3 w-full">
<Checkbox isChecked={useRandomExams} onChange={setUseRandomExams}>
Random Exams
</Checkbox>
{!useRandomExams && (
<div className="grid md:grid-cols-2 w-full gap-4">
{selectedModules.map((module) => (
<div key={module} className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">{capitalize(module)} Exam</label>
<Select
value={{
value: examIDs.find((e) => e.module === module)?.id || null,
label: examIDs.find((e) => e.module === module)?.id || "",
}}
onChange={(value) =>
value
? setExamIDs((prev) => [...prev.filter((x) => x.module !== module), {id: value.value!, module}])
: setExamIDs((prev) => prev.filter((x) => x.module !== module))
}
options={exams
.filter((x) => !x.isDiagnostic && x.module === module)
.map((x) => ({value: x.id, label: x.id}))}
/>
</div>
))}
</div>
)}
</div>
)}
<section className="w-full flex flex-col gap-3"> <section className="w-full flex flex-col gap-3">
<span className="font-semibold">Assignees ({assignees.length} selected)</span> <span className="font-semibold">Assignees ({assignees.length} selected)</span>
<div className="flex gap-4 overflow-x-scroll scrollbar-hide"> <div className="flex gap-4 overflow-x-scroll scrollbar-hide">
@@ -306,7 +365,14 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
</Button> </Button>
)} )}
<Button <Button
disabled={selectedModules.length === 0 || !name || !startDate || !endDate || assignees.length === 0} disabled={
selectedModules.length === 0 ||
!name ||
!startDate ||
!endDate ||
assignees.length === 0 ||
(!!examIDs && examIDs.length < selectedModules.length)
}
className="w-full max-w-[200px]" className="w-full max-w-[200px]"
onClick={createAssignment} onClick={createAssignment}
isLoading={isLoading}> isLoading={isLoading}>

View File

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

View File

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

View File

@@ -0,0 +1,140 @@
import React from "react";
import useUsers from "@/hooks/useUsers";
import useGroups from "@/hooks/useGroups";
import { User } from "@/interfaces/user";
import Select from "@/components/Low/Select";
import ProgressBar from "@/components/Low/ProgressBar";
import {
BsBook,
BsClipboard,
BsHeadphones,
BsMegaphone,
BsPen,
} from "react-icons/bs";
import { MODULE_ARRAY } from "@/utils/moduleUtils";
import { capitalize } from "lodash";
import { getLevelLabel } from "@/utils/score";
const Card = ({ user }: { user: User }) => {
return (
<div className="border-mti-gray-platinum flex flex-col h-fit w-full cursor-pointer flex-col gap-6 rounded-xl border bg-white p-4 transition duration-300 ease-in-out hover:drop-shadow">
<div className="flex flex-col gap-3">
<h3 className="text-xl font-semibold">{user.name}</h3>
</div>
<div className="flex w-full gap-3 flex-wrap">
{MODULE_ARRAY.map((module) => {
const desiredLevel = user.desiredLevels[module] || 9;
const level = user.levels[module] || 0;
return (
<div
className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4 min-w-[250px]"
key={module}
>
<div className="flex items-center gap-2 md:gap-3">
<div className="bg-mti-gray-smoke flex h-8 w-8 items-center justify-center rounded-lg md:h-12 md:w-12 md:rounded-xl">
{module === "reading" && (
<BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" />
)}
{module === "listening" && (
<BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />
)}
{module === "writing" && (
<BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" />
)}
{module === "speaking" && (
<BsMegaphone className="text-ielts-speaking h-4 w-4 md:h-5 md:w-5" />
)}
{module === "level" && (
<BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" />
)}
</div>
<div className="flex w-full flex-col">
<span className="text-sm font-bold md:font-extrabold w-full">
{capitalize(module)}
</span>
<div className="text-mti-gray-dim text-sm font-normal">
{module === "level" && (
<span>
English Level: {getLevelLabel(level).join(" / ")}
</span>
)}
{module !== "level" && (
<div className="flex flex-col">
<span>Level {level} / Level 9</span>
<span>Desired Level: {desiredLevel}</span>
</div>
)}
</div>
</div>
</div>
<div className="md:pl-14">
<ProgressBar
color={module}
label=""
mark={Math.round((desiredLevel * 100) / 9)}
markLabel={`Desired Level: ${desiredLevel}`}
percentage={Math.round((level * 100) / 9)}
className="h-2 w-full"
/>
</div>
</div>
);
})}
</div>
</div>
);
};
const CorporateStudentsLevels = () => {
const { users } = useUsers();
const { groups } = useGroups();
const corporateUsers = users.filter((u) => u.type === "corporate") as User[];
const [corporateId, setCorporateId] = React.useState<string>("");
const corporate =
corporateUsers.find((u) => u.id === corporateId) || corporateUsers[0];
const groupsFromCorporate = corporate
? groups.filter((g) => g.admin === corporate.id)
: [];
const groupsParticipants = groupsFromCorporate
.flatMap((g) => g.participants)
.reduce((accm: User[], p) => {
const user = users.find((u) => u.id === p) as User;
if (user) {
return [...accm, user];
}
return accm;
}, []);
return (
<>
<Select
options={corporateUsers.map((x: User) => ({
value: x.id,
label: `${x.name} - ${x.email}`,
}))}
value={corporate ? { value: corporate.id, label: corporate.name } : null}
onChange={(value) => setCorporateId(value?.value!)}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused
? "#D5D9F0"
: state.isSelected
? "#7872BF"
: "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
{groupsParticipants.map((u) => (
<Card user={u} key={u.id} />
))}
</>
);
};
export default CorporateStudentsLevels;

View File

@@ -4,8 +4,8 @@ import {IconType} from "react-icons";
interface Props { interface Props {
Icon: IconType; Icon: IconType;
label: string; label: string;
value: string | number; value?: string | number;
color: "purple" | "rose" | "red"; color: "purple" | "rose" | "red" | "green";
tooltip?: string; tooltip?: string;
onClick?: () => void; onClick?: () => void;
} }
@@ -15,6 +15,7 @@ export default function IconCard({Icon, label, value, color, tooltip, onClick}:
purple: "text-mti-purple-light", purple: "text-mti-purple-light",
red: "text-mti-red-light", red: "text-mti-red-light",
rose: "text-mti-rose-light", rose: "text-mti-rose-light",
green: "text-mti-green-light",
}; };
return ( return (

View File

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

View File

@@ -1,7 +1,6 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import ProgressBar from "@/components/Low/ProgressBar"; import ProgressBar from "@/components/Low/ProgressBar";
import InviteCard from "@/components/Medium/InviteCard"; import InviteCard from "@/components/Medium/InviteCard";
import PayPalPayment from "@/components/PayPalPayment";
import ProfileSummary from "@/components/ProfileSummary"; import ProfileSummary from "@/components/ProfileSummary";
import useAssignments from "@/hooks/useAssignments"; import useAssignments from "@/hooks/useAssignments";
import useInvites from "@/hooks/useInvites"; import useInvites from "@/hooks/useInvites";
@@ -13,7 +12,8 @@ import {CorporateUser, User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams"; import {getExamById} from "@/utils/exams";
import {getUserCorporate} from "@/utils/groups"; import {getUserCorporate} from "@/utils/groups";
import {MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils"; import {countExamModules, countFullExams, MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
import {getLevelLabel, getLevelScore} from "@/utils/score";
import {averageScore, groupBySession} from "@/utils/stats"; import {averageScore, groupBySession} from "@/utils/stats";
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js"; import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js";
import {PayPalButtons} from "@paypal/react-paypal-js"; import {PayPalButtons} from "@paypal/react-paypal-js";
@@ -34,7 +34,7 @@ interface Props {
export default function StudentDashboard({user}: Props) { export default function StudentDashboard({user}: Props) {
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>(); const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
const {stats} = useStats(user.id); const {stats} = useStats(user.id, !user?.id);
const {users} = useUsers(); const {users} = useUsers();
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id}); const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id});
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user.id}); const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user.id});
@@ -83,19 +83,22 @@ export default function StudentDashboard({user}: Props) {
user={user} user={user}
items={[ items={[
{ {
icon: <BsFileEarmarkText className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />, icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: Object.keys(groupBySession(stats)).length, value: countFullExams(stats),
label: "Exams", label: "Exams",
tooltip: "Number of all conducted completed exams",
}, },
{ {
icon: <BsPencil className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />, icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: stats.length, value: countExamModules(stats),
label: "Exercises", label: "Modules",
tooltip: "Number of all exam modules performed including Level Test",
}, },
{ {
icon: <BsStar className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />, icon: <BsStar className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
value: `${stats.length > 0 ? averageScore(stats) : 0}%`, value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
label: "Average Score", label: "Average Score",
tooltip: "Average success rate for questions responded",
}, },
]} ]}
/> />
@@ -224,35 +227,40 @@ export default function StudentDashboard({user}: Props) {
<section className="flex flex-col gap-3"> <section className="flex flex-col gap-3">
<span className="text-lg font-bold">Score History</span> <span className="text-lg font-bold">Score History</span>
<div className="-md:grid-rows-4 grid gap-6 md:grid-cols-2"> <div className="-md:grid-rows-4 grid gap-6 md:grid-cols-2">
{MODULE_ARRAY.map((module) => ( {MODULE_ARRAY.map((module) => {
<div className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4" key={module}> const desiredLevel = user.desiredLevels[module] || 9;
<div className="flex items-center gap-2 md:gap-3"> const level = user.levels[module] || 0;
<div className="bg-mti-gray-smoke flex h-8 w-8 items-center justify-center rounded-lg md:h-12 md:w-12 md:rounded-xl"> return (
{module === "reading" && <BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" />} <div className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4" key={module}>
{module === "listening" && <BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />} <div className="flex items-center gap-2 md:gap-3">
{module === "writing" && <BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" />} <div className="bg-mti-gray-smoke flex h-8 w-8 items-center justify-center rounded-lg md:h-12 md:w-12 md:rounded-xl">
{module === "speaking" && <BsMegaphone className="text-ielts-speaking h-4 w-4 md:h-5 md:w-5" />} {module === "reading" && <BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" />}
{module === "level" && <BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" />} {module === "listening" && <BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />}
{module === "writing" && <BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" />}
{module === "speaking" && <BsMegaphone className="text-ielts-speaking h-4 w-4 md:h-5 md:w-5" />}
{module === "level" && <BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" />}
</div>
<div className="flex w-full justify-between">
<span className="text-sm font-bold md:font-extrabold">{capitalize(module)}</span>
<span className="text-mti-gray-dim text-sm font-normal">
{module === "level" && `English Level: ${getLevelLabel(level).join(" / ")}`}
{module !== "level" && `Level ${level} / Level 9 (Desired Level: ${desiredLevel})`}
</span>
</div>
</div> </div>
<div className="flex w-full justify-between"> <div className="md:pl-14">
<span className="text-sm font-bold md:font-extrabold">{capitalize(module)}</span> <ProgressBar
<span className="text-mti-gray-dim text-sm font-normal"> color={module}
Level {user.levels[module] || 0} / Level 9 (Desired Level: {user.desiredLevels[module] || 9}) label=""
</span> mark={Math.round((desiredLevel * 100) / 9)}
markLabel={`Desired Level: ${desiredLevel}`}
percentage={Math.round((level * 100) / 9)}
className="h-2 w-full"
/>
</div> </div>
</div> </div>
<div className="md:pl-14"> );
<ProgressBar })}
color={module}
label=""
mark={Math.round((user.desiredLevels[module] * 100) / 9)}
markLabel={`Desired Level: ${user.desiredLevels[module]}`}
percentage={Math.round((user.levels[module] * 100) / 9)}
className="h-2 w-full"
/>
</div>
</div>
))}
</div> </div>
</section> </section>
</> </>

View File

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

View File

@@ -19,7 +19,7 @@
</p> </p>
<br /> <br />
<p>Don't forget to do it before its end date!</p> <p>Don't forget to do it before its end date!</p>
<p>Click <b><a href="https://platform.encoach.com">here</a></b> to open the EnCoach Platform!</p> <p>Click <b><a href="https://{{environment}}.encoach.com">here</a></b> to open the EnCoach Platform!</p>
<br /> <br />
<p>Thanks,</p> <p>Thanks,</p>
<p>Your EnCoach team</p> <p>Your EnCoach team</p>

View File

@@ -11,7 +11,8 @@
<img src="/logo_title.png" class="w-48 h-48 self-center" /> <img src="/logo_title.png" class="w-48 h-48 self-center" />
<div> <div>
<span>Hello future {{type}} of <b>EnCoach</b>,</span><br /> <span>Hello future {{type}} of <b>EnCoach</b>,</span><br />
<span>You have been invited to register at <a href="https://platform.encoach.com/register?code={{code}}">EnCoach</a> <span>You have been invited to register at <a
href="https://{{environment}}.encoach.com/register?code={{code}}">EnCoach</a>
to to
become a become a
{{type}}!</span><br /> {{type}}!</span><br />
@@ -19,7 +20,7 @@
</div> </div>
<br /> <br />
<br /> <br />
<a href="https://platform.encoach.com/register?code={{code}}"></a> <a href="https://{{environment}}.encoach.com/register?code={{code}}"></a>
<span class="self-center p-4 px-12 text-lg text-[#]" style="background-color: #D5D9F0; color: #353338"> <span class="self-center p-4 px-12 text-lg text-[#]" style="background-color: #D5D9F0; color: #353338">
<b>{{code}}</b> <b>{{code}}</b>
</span> </span>

View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
</head>
<div style="background-color: #ffffff; color: #353338;"
class="h-full min-h-screen w-full flex flex-col p-8 gap-16 text-base">
<img src="/logo_title.png" class="w-48 h-48 self-center" />
<div>
<span>Your ticket has been completed!</span>
<br/>
<span>Here is the ticket's information:</span>
<br/>
<br/>
<span><b>ID:</b> {{id}}</span><br/>
<span><b>Subject:</b> {{subject}}</span><br/>
<span><b>Reporter:</b> {{reporter.name}} - {{reporter.email}}</span><br/>
<span><b>Date:</b> {{date}}</span><br/>
<span><b>Type:</b> {{type}}</span><br/>
<span><b>Page:</b> {{reportedFrom}}</span>
<br/>
<br/>
<span><b>Description:</b> {{description}}</span><br/>
</div>
<br />
<br />
<div>
<span>Thanks, <br /> Your EnCoach team</span>
</div>
</div>
</html>

View File

@@ -10,7 +10,8 @@
<p>Hello {{name}},</p> <p>Hello {{name}},</p>
<br /> <br />
<p>Follow this link to verify your email address.</p> <p>Follow this link to verify your email address.</p>
<a href="https://platform.encoach.com/action?mode=signIn&continueUrl={{email}}&oobCode={{code}}">Verify account</a> <a href="https://{{environment}}.encoach.com/action?mode=signIn&continueUrl={{email}}&oobCode={{code}}">Verify
account</a>
<br /> <br />
<br /> <br />
<p>If you didnt ask to verify this address, you can ignore this email.</p> <p>If you didnt ask to verify this address, you can ignore this email.</p>

View File

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

View File

@@ -9,9 +9,23 @@ import clsx from "clsx";
import Link from "next/link"; import Link from "next/link";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {Fragment, useEffect, useState} from "react"; import {Fragment, useEffect, useState} from "react";
import {BsArrowCounterclockwise, BsBook, BsClipboard, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs"; import {
BsArrowCounterclockwise,
BsBook,
BsClipboard,
BsClipboardFill,
BsEyeFill,
BsHeadphones,
BsMegaphone,
BsPen,
BsShareFill,
} from "react-icons/bs";
import {LevelScore} from "@/constants/ielts"; import {LevelScore} from "@/constants/ielts";
import {getLevelScore} from "@/utils/score"; import {getLevelScore} from "@/utils/score";
import {capitalize} from "lodash";
import Modal from "@/components/Modal";
import { UserSolution } from "@/interfaces/exam";
import ai_usage from "@/utils/ai.detection";
interface Score { interface Score {
module: Module; module: Module;
@@ -24,13 +38,21 @@ interface Props {
user: User; user: User;
modules: Module[]; modules: Module[];
scores: Score[]; scores: Score[];
information: {
timeSpent?: number;
inactivity?: number;
};
solutions: UserSolution[];
isLoading: boolean; isLoading: boolean;
onViewResults: () => void; onViewResults: (moduleIndex?: number) => void;
} }
export default function Finish({user, scores, modules, isLoading, onViewResults}: Props) { export default function Finish({user, scores, modules, information, solutions, isLoading, onViewResults}: Props) {
const [selectedModule, setSelectedModule] = useState(modules[0]); const [selectedModule, setSelectedModule] = useState(modules[0]);
const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!); const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!);
const [isExtraInformationOpen, setIsExtraInformationOpen] = useState(false);
const aiUsage = Math.round(ai_usage(solutions) * 100);
const exams = useExamStore((state) => state.exams); const exams = useExamStore((state) => state.exams);
@@ -61,7 +83,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
const getTotalExercises = () => { const getTotalExercises = () => {
const exam = exams.find((x) => x.module === selectedModule)!; const exam = exams.find((x) => x.module === selectedModule)!;
if (exam.module === "reading" || exam.module === "listening") { if (exam.module === "reading" || exam.module === "listening" || exam.module === "level") {
return exam.parts.flatMap((x) => x.exercises).length; return exam.parts.flatMap((x) => x.exercises).length;
} }
@@ -85,6 +107,21 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
return ( return (
<> <>
<Modal title="Extra Information" isOpen={isExtraInformationOpen} onClose={() => setIsExtraInformationOpen(false)}>
<div className="flex flex-col gap-2 mt-4">
{!!information.timeSpent && (
<span>
<b>Time Spent:</b> {Math.floor(information.timeSpent / 60)} minute(s)
</span>
)}
{!!information.inactivity && (
<span>
<b>Inactivity:</b> {Math.floor(information.inactivity / 60)} minute(s)
</span>
)}
</div>
</Modal>
<div className="flex h-fit min-h-full w-full flex-col items-center justify-between gap-8"> <div className="flex h-fit min-h-full w-full flex-col items-center justify-between gap-8">
<ModuleTitle <ModuleTitle
module={selectedModule} module={selectedModule}
@@ -93,7 +130,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
minTimer={exams.find((x) => x.module === selectedModule)!.minTimer} minTimer={exams.find((x) => x.module === selectedModule)!.minTimer}
disableTimer disableTimer
/> />
<div className="flex gap-4 self-start"> <div className="flex gap-4 self-start w-full">
{modules.includes("reading") && ( {modules.includes("reading") && (
<div <div
onClick={() => setSelectedModule("reading")} onClick={() => setSelectedModule("reading")}
@@ -117,6 +154,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
</div> </div>
)} )}
{modules.includes("writing") && ( {modules.includes("writing") && (
<div className="flex w-full justify-between items-center">
<div <div
onClick={() => setSelectedModule("writing")} onClick={() => setSelectedModule("writing")}
className={clsx( className={clsx(
@@ -126,6 +164,18 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
<BsPen className="h-6 w-6" /> <BsPen className="h-6 w-6" />
<span className="font-semibold">Writing</span> <span className="font-semibold">Writing</span>
</div> </div>
{aiUsage >= 50 && user.type !== "student" && (
<div className={clsx(
"flex items-center justify-center border px-3 h-full rounded",
{
'bg-orange-100 border-orange-400 text-orange-700': aiUsage < 80,
'bg-red-100 border-red-400 text-red-700': aiUsage >= 80,
}
)}>
<span className="text-xs">AI Usage</span>
</div>
)}
</div>
)} )}
{modules.includes("speaking") && ( {modules.includes("speaking") && (
<div <div
@@ -182,33 +232,37 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
{showLevel(bandScore)} {showLevel(bandScore)}
</div> </div>
</div> </div>
<div className="flex flex-col gap-5"> {!["writing", "speaking"].includes(selectedModule) ? (
<div className="flex gap-2"> <div className="flex flex-col gap-5 w-28">
<div className="bg-mti-red-light mt-1 h-3 w-3 rounded-full" /> <div className="flex gap-2">
<div className="flex flex-col"> <div className="bg-mti-red-light mt-1 h-3 w-3 rounded-full" />
<span className="text-mti-red-light"> <div className="flex flex-col">
{(((selectedScore.total - selectedScore.missing) / selectedScore.total) * 100).toFixed(0)}% <span className="text-mti-red-light">
</span> {(((selectedScore.total - selectedScore.missing) / selectedScore.total) * 100).toFixed(0)}%
<span className="text-lg">Completion</span> </span>
<span className="text-lg">Completion</span>
</div>
</div>
<div className="flex gap-2">
<div className="bg-mti-purple-light mt-1 h-3 w-3 rounded-full" />
<div className="flex flex-col">
<span className="text-mti-purple-light">{selectedScore.correct.toString().padStart(2, "0")}</span>
<span className="text-lg">Correct</span>
</div>
</div>
<div className="flex gap-2">
<div className="bg-mti-rose-light mt-1 h-3 w-3 rounded-full" />
<div className="flex flex-col">
<span className="text-mti-rose-light">
{(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")}
</span>
<span className="text-lg">Wrong</span>
</div>
</div> </div>
</div> </div>
<div className="flex gap-2"> ) : (
<div className="bg-mti-purple-light mt-1 h-3 w-3 rounded-full" /> <div className="w-28 h-full" />
<div className="flex flex-col"> )}
<span className="text-mti-purple-light">{selectedScore.correct.toString().padStart(2, "0")}</span>
<span className="text-lg">Correct</span>
</div>
</div>
<div className="flex gap-2">
<div className="bg-mti-rose-light mt-1 h-3 w-3 rounded-full" />
<div className="flex flex-col">
<span className="text-mti-rose-light">
{(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")}
</span>
<span className="text-lg">Wrong</span>
</div>
</div>
</div>
</div> </div>
</div> </div>
)} )}
@@ -220,6 +274,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
<div className="flex w-fit cursor-pointer flex-col items-center gap-1"> <div className="flex w-fit cursor-pointer flex-col items-center gap-1">
<button <button
onClick={() => window.location.reload()} onClick={() => window.location.reload()}
disabled={user.type === "admin"}
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out"> className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
<BsArrowCounterclockwise className="h-7 w-7 text-white" /> <BsArrowCounterclockwise className="h-7 w-7 text-white" />
</button> </button>
@@ -227,12 +282,30 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
</div> </div>
<div className="flex w-fit cursor-pointer flex-col items-center gap-1"> <div className="flex w-fit cursor-pointer flex-col items-center gap-1">
<button <button
onClick={onViewResults} onClick={() => onViewResults()}
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out"> className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
<BsEyeFill className="h-7 w-7 text-white" /> <BsEyeFill className="h-7 w-7 text-white" />
</button> </button>
<span>Review Answers</span> <span>Review All</span>
</div> </div>
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
<button
onClick={() => onViewResults(modules.findIndex((x) => x === selectedModule))}
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
<BsEyeFill className="h-7 w-7 text-white" />
</button>
<span>Review {capitalize(selectedModule)}</span>
</div>
{(!!information.inactivity || !!information.timeSpent) && (
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
<button
onClick={() => setIsExtraInformationOpen(true)}
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
<BsClipboardFill className="h-7 w-7 text-white" />
</button>
<span>Extra Information</span>
</div>
)}
</div> </div>
<Link href="/" className="w-full max-w-[200px] self-end"> <Link href="/" className="w-full max-w-[200px] self-end">

View File

@@ -1,8 +1,10 @@
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
import {renderExercise} from "@/components/Exercises"; import {renderExercise} from "@/components/Exercises";
import Button from "@/components/Low/Button";
import ModuleTitle from "@/components/Medium/ModuleTitle"; import ModuleTitle from "@/components/Medium/ModuleTitle";
import {renderSolution} from "@/components/Solutions"; import {renderSolution} from "@/components/Solutions";
import {infoButtonStyle} from "@/constants/buttonStyles"; import {infoButtonStyle} from "@/constants/buttonStyles";
import {LevelExam, UserSolution, WritingExam} from "@/interfaces/exam"; import {LevelExam, LevelPart, UserSolution, WritingExam} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams"; import {defaultUserSolutions} from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils"; import {countExercises} from "@/utils/moduleUtils";
@@ -10,6 +12,7 @@ import {mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react"; import Icon from "@mdi/react";
import clsx from "clsx"; import clsx from "clsx";
import {Fragment, useEffect, useState} from "react"; import {Fragment, useEffect, useState} from "react";
import {BsChevronDown, BsChevronUp} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
interface Props { interface Props {
@@ -18,84 +21,200 @@ interface Props {
onFinish: (userSolutions: UserSolution[]) => void; onFinish: (userSolutions: UserSolution[]) => void;
} }
function TextComponent({part}: {part: LevelPart}) {
return (
<div className="flex flex-col gap-2 w-full">
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
{!!part.context &&
part.context
.split(/\n|(\\n)/g)
.filter((x) => x && x.length > 0 && x !== "\\n")
.map((line, index) => (
<Fragment key={index}>
<p key={index}>{line}</p>
</Fragment>
))}
</div>
);
}
export default function Level({exam, showSolutions = false, onFinish}: Props) { export default function Level({exam, showSolutions = false, onFinish}: Props) {
const [questionIndex, setQuestionIndex] = useState(0); const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); const [showBlankModal, setShowBlankModal] = useState(false);
const [exerciseIndex, setExerciseIndex] = useState(0);
const {userSolutions, setUserSolutions} = useExamStore((state) => state); const {userSolutions, setUserSolutions} = useExamStore((state) => state);
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state); const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
const {partIndex, setPartIndex} = useExamStore((state) => state);
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]);
useEffect(() => { const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
setCurrentQuestionIndex(0);
}, [questionIndex]);
useEffect(() => { useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) { if (hasExamEnded && exerciseIndex === -1) {
setExerciseIndex((prev) => prev + 1); setExerciseIndex(exerciseIndex + 1);
} }
}, [hasExamEnded, exerciseIndex]); }, [hasExamEnded, exerciseIndex, setExerciseIndex]);
const nextExercise = (solution?: UserSolution) => { const confirmFinishModule = (keepGoing?: boolean) => {
if (solution) { if (!keepGoing) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]); setShowBlankModal(false);
}
setQuestionIndex((prev) => prev + currentQuestionIndex);
if (exerciseIndex + 1 < exam.exercises.length) {
setExerciseIndex((prev) => prev + 1);
return; return;
} }
if (exerciseIndex >= exam.exercises.length) return; onFinish(userSolutions);
};
const nextExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]);
}
if (storeQuestionIndex > 0) {
const exercise = getExercise();
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise.id), {id: exercise.id, amount: storeQuestionIndex}]);
}
setStoreQuestionIndex(0);
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
setExerciseIndex(exerciseIndex + 1);
return;
}
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
setPartIndex(partIndex + 1);
setExerciseIndex(!!exam.parts[partIndex + 1].context ? -1 : 0);
return;
}
if (
solution &&
![...userSolutions.filter((x) => x.exercise !== solution?.exercise).map((x) => x.score.missing), solution?.score.missing].every(
(x) => x === 0,
) &&
!showSolutions &&
!hasExamEnded
) {
setShowBlankModal(true);
return;
}
setHasExamEnded(false); setHasExamEnded(false);
if (solution) { if (solution) {
onFinish( onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]);
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "level", exam: exam.id})),
);
} else { } else {
onFinish(userSolutions.map((x) => ({...x, module: "level", exam: exam.id}))); onFinish(userSolutions);
} }
}; };
const previousExercise = (solution?: UserSolution) => { const previousExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) { if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]); setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]);
} }
if (exerciseIndex > 0) { if (storeQuestionIndex > 0) {
setExerciseIndex((prev) => prev - 1); const exercise = getExercise();
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise.id), {id: exercise.id, amount: storeQuestionIndex}]);
} }
setStoreQuestionIndex(0);
setExerciseIndex(exerciseIndex - 1);
}; };
const getExercise = () => { const getExercise = () => {
const exercise = exam.exercises[exerciseIndex]; const exercise = exam.parts[partIndex].exercises[exerciseIndex];
return { return {
...exercise, ...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [], userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
}; };
}; };
const calculateExerciseIndex = () => {
if (partIndex === 0)
return (
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) + storeQuestionIndex + multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
);
const exercisesPerPart = exam.parts.map((x) => x.exercises.length);
const exercisesDone = exercisesPerPart.filter((_, index) => index < partIndex).reduce((acc, curr) => curr + acc, 0);
return (
exercisesDone +
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) +
storeQuestionIndex +
multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
);
};
const renderText = () => (
<div className={clsx("flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl mt-4 relative py-8 px-16")}>
<>
<div className="flex flex-col w-full gap-2">
<h4 className="text-xl font-semibold">
Please read the following excerpt attentively, you will then be asked questions about the text you&apos;ve read.
</h4>
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
</div>
<TextComponent part={exam.parts[partIndex]} />
</>
</div>
);
return ( return (
<> <>
<div className="flex flex-col h-full w-full gap-8 items-center"> <div className="flex flex-col h-full w-full gap-8 items-center">
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
<ModuleTitle <ModuleTitle
minTimer={exam.minTimer} minTimer={exam.minTimer}
exerciseIndex={exerciseIndex + 1 + questionIndex + currentQuestionIndex} exerciseIndex={calculateExerciseIndex()}
module="level" module="level"
totalExercises={countExercises(exam.exercises)} totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
disableTimer={showSolutions} disableTimer={showSolutions}
/> />
{exerciseIndex > -1 && <div
exerciseIndex < exam.exercises.length && className={clsx(
!showSolutions && "mb-20 w-full",
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)} partIndex > -1 && exerciseIndex > -1 && !!exam.parts[partIndex].context && "grid grid-cols-2 gap-4",
{exerciseIndex > -1 && )}>
exerciseIndex < exam.exercises.length && {partIndex > -1 && !!exam.parts[partIndex].context && renderText()}
showSolutions &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)} {exerciseIndex > -1 &&
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions &&
renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
{exerciseIndex > -1 &&
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
showSolutions &&
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
</div>
{exerciseIndex === -1 && partIndex > 0 && (
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => {
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
setPartIndex(partIndex - 1);
}}
className="max-w-[200px] w-full">
Back
</Button>
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)}
{exerciseIndex === -1 && partIndex === 0 && (
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
Start now
</Button>
)}
</div> </div>
</> </>
); );

View File

@@ -1,4 +1,4 @@
import {ListeningExam, UserSolution} from "@/interfaces/exam"; import {ListeningExam, MultipleChoiceExercise, UserSolution} from "@/interfaces/exam";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {renderExercise} from "@/components/Exercises"; import {renderExercise} from "@/components/Exercises";
import {renderSolution} from "@/components/Solutions"; import {renderSolution} from "@/components/Solutions";
@@ -16,18 +16,18 @@ interface Props {
} }
const INSTRUCTIONS_AUDIO_SRC = const INSTRUCTIONS_AUDIO_SRC =
"https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_recordings%2Fgeneric_intro.mp3?alt=media&token=9b9cfdb8-e90d-40d1-854b-51c4378a5c4b"; "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/generic_listening_intro_v2.mp3?alt=media&token=16769f5f-1e9b-4a72-86a9-45a6f0fa9f82";
export default function Listening({exam, showSolutions = false, onFinish}: Props) { export default function Listening({exam, showSolutions = false, onFinish}: Props) {
const [questionIndex, setQuestionIndex] = useState(0);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [timesListened, setTimesListened] = useState(0); const [timesListened, setTimesListened] = useState(0);
const [showBlankModal, setShowBlankModal] = useState(false); const [showBlankModal, setShowBlankModal] = useState(false);
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]);
const {userSolutions, setUserSolutions} = useExamStore((state) => state); const {userSolutions, setUserSolutions} = useExamStore((state) => state);
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state); const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
const {partIndex, setPartIndex} = useExamStore((state) => state); const {partIndex, setPartIndex} = useExamStore((state) => state);
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state); const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]);
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
@@ -35,9 +35,26 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
if (showSolutions) return setExerciseIndex(-1); if (showSolutions) return setExerciseIndex(-1);
}, [setExerciseIndex, showSolutions]); }, [setExerciseIndex, showSolutions]);
// useEffect(() => { useEffect(() => {
// if (exam.variant !== "partial") setPartIndex(-1); if (partIndex === -1 && exam.variant === "partial") {
// }, [exam.variant, setPartIndex]); setPartIndex(0);
}
}, [partIndex, exam, setPartIndex]);
useEffect(() => {
const previousParts = exam.parts.filter((_, index) => index < partIndex);
let previousMultipleChoice = previousParts.flatMap((x) => x.exercises).filter((x) => x.type === "multipleChoice") as MultipleChoiceExercise[];
if (partIndex > -1 && exerciseIndex > -1) {
const previousPartExercises = exam.parts[partIndex].exercises.filter((_, index) => index < exerciseIndex);
const partMultipleChoice = previousPartExercises.filter((x) => x.type === "multipleChoice") as MultipleChoiceExercise[];
previousMultipleChoice = [...previousMultipleChoice, ...partMultipleChoice];
}
setMultipleChoicesDone(previousMultipleChoice.map((x) => ({id: x.id, amount: x.questions.length - 1})));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => { useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) { if (hasExamEnded && exerciseIndex === -1) {
@@ -45,25 +62,25 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
} }
}, [hasExamEnded, exerciseIndex, setExerciseIndex]); }, [hasExamEnded, exerciseIndex, setExerciseIndex]);
useEffect(() => {
setCurrentQuestionIndex(0);
}, [questionIndex]);
const confirmFinishModule = (keepGoing?: boolean) => { const confirmFinishModule = (keepGoing?: boolean) => {
if (!keepGoing) { if (!keepGoing) {
setShowBlankModal(false); setShowBlankModal(false);
return; return;
} }
onFinish(userSolutions.map((x) => ({...x, module: "listening", exam: exam.id}))); onFinish(userSolutions);
}; };
const nextExercise = (solution?: UserSolution) => { const nextExercise = (solution?: UserSolution) => {
scrollToTop(); scrollToTop();
if (solution) { if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]); setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "listening", exam: exam.id}]);
} }
setQuestionIndex((prev) => prev + currentQuestionIndex); if (storeQuestionIndex > 0) {
const exercise = getExercise();
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise.id), {id: exercise.id, amount: storeQuestionIndex}]);
}
setStoreQuestionIndex(0);
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) { if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
setExerciseIndex(exerciseIndex + 1); setExerciseIndex(exerciseIndex + 1);
@@ -72,6 +89,7 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
if (partIndex + 1 < exam.parts.length && !hasExamEnded) { if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
setPartIndex(partIndex + 1); setPartIndex(partIndex + 1);
setTimesListened(0);
setExerciseIndex(showSolutions ? 0 : -1); setExerciseIndex(showSolutions ? 0 : -1);
return; return;
} }
@@ -91,19 +109,18 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
setHasExamEnded(false); setHasExamEnded(false);
if (solution) { if (solution) {
onFinish( onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "listening", exam: exam.id}]);
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "listening", exam: exam.id})),
);
} else { } else {
onFinish(userSolutions.map((x) => ({...x, module: "listening", exam: exam.id}))); onFinish(userSolutions);
} }
}; };
const previousExercise = (solution?: UserSolution) => { const previousExercise = (solution?: UserSolution) => {
scrollToTop(); scrollToTop();
if (solution) { if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]); setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "listening", exam: exam.id}]);
} }
setStoreQuestionIndex(0);
setExerciseIndex(exerciseIndex - 1); setExerciseIndex(exerciseIndex - 1);
}; };
@@ -116,6 +133,31 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
}; };
}; };
useEffect(() => {
if (partIndex > -1 && exerciseIndex > -1) {
const exercise = getExercise();
setMultipleChoicesDone((prev) => prev.filter((x) => x.id !== exercise.id));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [exerciseIndex, partIndex]);
const calculateExerciseIndex = () => {
if (partIndex === -1) return 0;
if (partIndex === 0)
return (
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) + storeQuestionIndex + multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
);
const exercisesPerPart = exam.parts.map((x) => x.exercises.length);
const exercisesDone = exercisesPerPart.filter((_, index) => index < partIndex).reduce((acc, curr) => curr + acc, 0);
return (
exercisesDone +
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) +
storeQuestionIndex +
multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
);
};
const renderAudioInstructionsPlayer = () => ( const renderAudioInstructionsPlayer = () => (
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16"> <div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
<div className="flex flex-col w-full gap-2"> <div className="flex flex-col w-full gap-2">
@@ -155,18 +197,7 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} /> <BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
<div className="flex flex-col h-full w-full gap-8 justify-between"> <div className="flex flex-col h-full w-full gap-8 justify-between">
<ModuleTitle <ModuleTitle
exerciseIndex={ exerciseIndex={calculateExerciseIndex()}
partIndex === -1
? 0
: (exam.parts
.flatMap((x) => x.exercises)
.findIndex(
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
) || 0) +
(exerciseIndex === -1 ? 0 : 1) +
questionIndex +
currentQuestionIndex
}
minTimer={exam.minTimer} minTimer={exam.minTimer}
module="listening" module="listening"
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))} totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
@@ -183,14 +214,14 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
partIndex > -1 && partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length && exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions && !showSolutions &&
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)} renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
{/* Solution renderer */} {/* Solution renderer */}
{exerciseIndex > -1 && {exerciseIndex > -1 &&
partIndex > -1 && partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length && exerciseIndex < exam.parts[partIndex].exercises.length &&
showSolutions && showSolutions &&
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)} renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
</div> </div>
{exerciseIndex === -1 && partIndex > -1 && exam.variant !== "partial" && ( {exerciseIndex === -1 && partIndex > -1 && exam.variant !== "partial" && (

View File

@@ -1,4 +1,4 @@
import {ReadingExam, UserSolution} from "@/interfaces/exam"; import {MultipleChoiceExercise, ReadingExam, ReadingPart, UserSolution} from "@/interfaces/exam";
import {Fragment, useEffect, useState} from "react"; import {Fragment, useEffect, useState} from "react";
import Icon from "@mdi/react"; import Icon from "@mdi/react";
import {mdiArrowRight, mdiNotebook} from "@mdi/js"; import {mdiArrowRight, mdiNotebook} from "@mdi/js";
@@ -10,7 +10,7 @@ import {renderExercise} from "@/components/Exercises";
import {renderSolution} from "@/components/Solutions"; import {renderSolution} from "@/components/Solutions";
import {Panel} from "primereact/panel"; import {Panel} from "primereact/panel";
import {Steps} from "primereact/steps"; import {Steps} from "primereact/steps";
import {BsAlarm, BsBook, BsClock, BsStopwatch} from "react-icons/bs"; import {BsAlarm, BsBook, BsChevronDown, BsChevronUp, BsClock, BsStopwatch} from "react-icons/bs";
import ProgressBar from "@/components/Low/ProgressBar"; import ProgressBar from "@/components/Low/ProgressBar";
import ModuleTitle from "@/components/Medium/ModuleTitle"; import ModuleTitle from "@/components/Medium/ModuleTitle";
import {Divider} from "primereact/divider"; import {Divider} from "primereact/divider";
@@ -26,6 +26,8 @@ interface Props {
onFinish: (userSolutions: UserSolution[]) => void; onFinish: (userSolutions: UserSolution[]) => void;
} }
const numberToLetter = (number: number) => (number + 9).toString(36).toUpperCase();
function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: string; content: string; onClose: () => void}) { function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: string; content: string; onClose: () => void}) {
return ( return (
<Transition appear show={isOpen} as={Fragment}> <Transition appear show={isOpen} as={Fragment}>
@@ -80,17 +82,41 @@ function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: s
); );
} }
function TextComponent({part, exerciseType}: {part: ReadingPart; exerciseType: string}) {
return (
<div className="flex flex-col gap-2 w-full">
<h3 className="text-xl font-semibold">{part.text.title}</h3>
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
{part.text.content
.split(/\n|(\\n)/g)
.filter((x) => x && x.length > 0 && x !== "\\n")
.map((line, index) => (
<Fragment key={index}>
{exerciseType === "matchSentences" && (
<div className="flex gap-3 border border-transparent hover:border-mti-purple-light rounded-lg transition ease-in-out duration-300 p-2 px-3 cursor-pointer">
<span className="font-bold text-mti-purple-dark">{numberToLetter(index + 1)}</span>
<p>{line}</p>
</div>
)}
{exerciseType !== "matchSentences" && <p key={index}>{line}</p>}
</Fragment>
))}
</div>
);
}
export default function Reading({exam, showSolutions = false, onFinish}: Props) { export default function Reading({exam, showSolutions = false, onFinish}: Props) {
const [questionIndex, setQuestionIndex] = useState(0);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [showTextModal, setShowTextModal] = useState(false); const [showTextModal, setShowTextModal] = useState(false);
const [showBlankModal, setShowBlankModal] = useState(false); const [showBlankModal, setShowBlankModal] = useState(false);
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]);
const [isTextMinimized, setIsTextMinimzed] = useState(false);
const [exerciseType, setExerciseType] = useState("");
const {userSolutions, setUserSolutions} = useExamStore((state) => state); const {userSolutions, setUserSolutions} = useExamStore((state) => state);
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state); const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
const {partIndex, setPartIndex} = useExamStore((state) => state); const {partIndex, setPartIndex} = useExamStore((state) => state);
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state); const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
const setStoreQuestionIndex = useExamStore((state) => state.setQuestionIndex); const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]);
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
@@ -98,6 +124,21 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
if (showSolutions) setExerciseIndex(-1); if (showSolutions) setExerciseIndex(-1);
}, [setExerciseIndex, showSolutions]); }, [setExerciseIndex, showSolutions]);
useEffect(() => {
const previousParts = exam.parts.filter((_, index) => index < partIndex);
let previousMultipleChoice = previousParts.flatMap((x) => x.exercises).filter((x) => x.type === "multipleChoice") as MultipleChoiceExercise[];
if (partIndex > -1 && exerciseIndex > -1) {
const previousPartExercises = exam.parts[partIndex].exercises.filter((_, index) => index < exerciseIndex);
const partMultipleChoice = previousPartExercises.filter((x) => x.type === "multipleChoice") as MultipleChoiceExercise[];
previousMultipleChoice = [...previousMultipleChoice, ...partMultipleChoice];
}
setMultipleChoicesDone(previousMultipleChoice.map((x) => ({id: x.id, amount: x.questions.length - 1})));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => { useEffect(() => {
const listener = (e: KeyboardEvent) => { const listener = (e: KeyboardEvent) => {
if (e.key === "F3" || ((e.ctrlKey || e.metaKey) && e.key === "f")) { if (e.key === "F3" || ((e.ctrlKey || e.metaKey) && e.key === "f")) {
@@ -112,10 +153,6 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
}; };
}, []); }, []);
useEffect(() => {
setCurrentQuestionIndex(0);
}, [questionIndex]);
useEffect(() => { useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) { if (hasExamEnded && exerciseIndex === -1) {
setExerciseIndex(exerciseIndex + 1); setExerciseIndex(exerciseIndex + 1);
@@ -128,15 +165,19 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
return; return;
} }
onFinish(userSolutions.map((x) => ({...x, module: "reading", exam: exam.id}))); onFinish(userSolutions);
}; };
const nextExercise = (solution?: UserSolution) => { const nextExercise = (solution?: UserSolution) => {
scrollToTop(); scrollToTop();
if (solution) { if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]); setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "reading", exam: exam.id}]);
}
if (storeQuestionIndex > 0) {
const exercise = getExercise();
setExerciseType(exercise.type);
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise.id), {id: exercise.id, amount: storeQuestionIndex}]);
} }
setQuestionIndex((prev) => prev + currentQuestionIndex);
setStoreQuestionIndex(0); setStoreQuestionIndex(0);
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) { if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
@@ -165,18 +206,16 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
setHasExamEnded(false); setHasExamEnded(false);
if (solution) { if (solution) {
onFinish( onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "reading", exam: exam.id}]);
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "reading", exam: exam.id})),
);
} else { } else {
onFinish(userSolutions.map((x) => ({...x, module: "reading", exam: exam.id}))); onFinish(userSolutions);
} }
}; };
const previousExercise = (solution?: UserSolution) => { const previousExercise = (solution?: UserSolution) => {
scrollToTop(); scrollToTop();
if (solution) { if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]); setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "reading", exam: exam.id}]);
} }
setStoreQuestionIndex(0); setStoreQuestionIndex(0);
@@ -191,23 +230,56 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
}; };
}; };
useEffect(() => {
if (partIndex > -1 && exerciseIndex > -1) {
const exercise = getExercise();
setExerciseType(exercise.type);
setMultipleChoicesDone((prev) => prev.filter((x) => x.id !== exercise.id));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [exerciseIndex, partIndex]);
const calculateExerciseIndex = () => {
if (partIndex === -1) return 0;
if (partIndex === 0)
return (
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) + storeQuestionIndex + multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
);
const exercisesPerPart = exam.parts.map((x) => x.exercises.length);
const exercisesDone = exercisesPerPart.filter((_, index) => index < partIndex).reduce((acc, curr) => curr + acc, 0);
return (
exercisesDone +
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) +
storeQuestionIndex +
multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
);
};
const renderText = () => ( const renderText = () => (
<div className="flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16 mt-4"> <div className={clsx("flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl mt-4 relative", isTextMinimized ? "p-2 px-8" : "py-8 px-16")}>
<div className="flex flex-col w-full gap-2"> <button
<h4 className="text-xl font-semibold"> data-tip={isTextMinimized ? "Maximise" : "Minimize"}
Please read the following excerpt attentively, you will then be asked questions about the text you&apos;ve read. className={clsx("absolute right-8 tooltip", isTextMinimized ? "top-1/2 -translate-y-1/2" : "top-8")}
</h4> onClick={() => setIsTextMinimzed((prev) => !prev)}>
<span className="text-base">You will be allowed to read the text while doing the exercises</span> {isTextMinimized ? (
</div> <BsChevronDown className="text-mti-purple-dark text-lg" />
<div className="flex flex-col gap-2 w-full"> ) : (
<h3 className="text-xl font-semibold">{exam.parts[partIndex].text.title}</h3> <BsChevronUp className="text-mti-purple-dark text-lg" />
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" /> )}
<span className="overflow-auto"> </button>
{exam.parts[partIndex].text.content.split("\\n").map((line, index) => ( {!isTextMinimized && (
<p key={index}>{line}</p> <>
))} <div className="flex flex-col w-full gap-2">
</span> <h4 className="text-xl font-semibold">
</div> Please read the following excerpt attentively, you will then be asked questions about the text you&apos;ve read.
</h4>
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
</div>
<TextComponent part={exam.parts[partIndex]} exerciseType={exerciseType} />
</>
)}
{isTextMinimized && <span className="font-semibold">Reading Passage</span>}
</div> </div>
); );
@@ -218,38 +290,31 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
{partIndex > -1 && <TextModal {...exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />} {partIndex > -1 && <TextModal {...exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />}
<ModuleTitle <ModuleTitle
minTimer={exam.minTimer} minTimer={exam.minTimer}
exerciseIndex={ exerciseIndex={calculateExerciseIndex()}
(exam.parts
.flatMap((x) => x.exercises)
.findIndex(
(x) =>
x.id ===
exam.parts[partIndex > -1 ? partIndex : 0].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]
?.id,
) || 0) +
(exerciseIndex === -1 ? 0 : 1) +
questionIndex +
currentQuestionIndex
}
module="reading" module="reading"
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))} totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
disableTimer={showSolutions} disableTimer={showSolutions}
label={exerciseIndex === -1 ? undefined : convertCamelCaseToReadable(exam.parts[partIndex].exercises[exerciseIndex].type)} label={exerciseIndex === -1 ? undefined : convertCamelCaseToReadable(exam.parts[partIndex].exercises[exerciseIndex].type)}
/> />
<div className={clsx("mb-20 w-full", exerciseIndex > -1 && "grid grid-cols-2 gap-4")}> <div
className={clsx(
"mb-20 w-full",
exerciseIndex > -1 && !isTextMinimized && "grid grid-cols-2 gap-4",
exerciseIndex > -1 && isTextMinimized && "flex flex-col gap-2",
)}>
{partIndex > -1 && renderText()} {partIndex > -1 && renderText()}
{exerciseIndex > -1 && {exerciseIndex > -1 &&
partIndex > -1 && partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length && exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions && !showSolutions &&
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)} renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
{exerciseIndex > -1 && {exerciseIndex > -1 &&
partIndex > -1 && partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length && exerciseIndex < exam.parts[partIndex].exercises.length &&
showSolutions && showSolutions &&
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)} renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
</div> </div>
{exerciseIndex > -1 && partIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && ( {exerciseIndex > -1 && partIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
<Button <Button

View File

@@ -1,305 +1,442 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import {useState} from "react"; import { useState } from "react";
import {Module} from "@/interfaces"; import { Module } from "@/interfaces";
import clsx from "clsx"; import clsx from "clsx";
import {User} from "@/interfaces/user"; import { User } from "@/interfaces/user";
import ProgressBar from "@/components/Low/ProgressBar"; import ProgressBar from "@/components/Low/ProgressBar";
import {BsArrowRepeat, BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs"; import {
import {totalExamsByModule} from "@/utils/stats"; BsArrowRepeat,
BsBook,
BsCheck,
BsCheckCircle,
BsClipboard,
BsHeadphones,
BsMegaphone,
BsPen,
BsXCircle,
} from "react-icons/bs";
import { totalExamsByModule } from "@/utils/stats";
import useStats from "@/hooks/useStats"; import useStats from "@/hooks/useStats";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import {calculateAverageLevel} from "@/utils/score"; import { calculateAverageLevel } from "@/utils/score";
import {sortByModuleName} from "@/utils/moduleUtils"; import { sortByModuleName } from "@/utils/moduleUtils";
import {capitalize} from "lodash"; import { capitalize } from "lodash";
import ProfileSummary from "@/components/ProfileSummary"; import ProfileSummary from "@/components/ProfileSummary";
import {Variant} from "@/interfaces/exam"; import { Variant } from "@/interfaces/exam";
import useSessions, {Session} from "@/hooks/useSessions"; import useSessions, { Session } from "@/hooks/useSessions";
import SessionCard from "@/components/Medium/SessionCard"; import SessionCard from "@/components/Medium/SessionCard";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import moment from "moment"; import moment from "moment";
interface Props { interface Props {
user: User; user: User;
page: "exercises" | "exams"; page: "exercises" | "exams";
onStart: (modules: Module[], avoidRepeated: boolean, variant: Variant) => void; onStart: (
disableSelection?: boolean; modules: Module[],
avoidRepeated: boolean,
variant: Variant,
) => void;
disableSelection?: boolean;
} }
export default function Selection({user, page, onStart, disableSelection = false}: Props) { export default function Selection({
const [selectedModules, setSelectedModules] = useState<Module[]>([]); user,
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true); page,
const [variant, setVariant] = useState<Variant>("full"); onStart,
disableSelection = false,
}: Props) {
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
const [variant, setVariant] = useState<Variant>("full");
const {stats} = useStats(user?.id); const { stats } = useStats(user?.id);
const {sessions, isLoading, reload} = useSessions(user.id); const { sessions, isLoading, reload } = useSessions(user.id);
const state = useExamStore((state) => state); const state = useExamStore((state) => state);
const toggleModule = (module: Module) => { const toggleModule = (module: Module) => {
const modules = selectedModules.filter((x) => x !== module); const modules = selectedModules.filter((x) => x !== module);
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module])); setSelectedModules((prev) =>
}; prev.includes(module) ? modules : [...modules, module],
);
};
const loadSession = async (session: Session) => { const loadSession = async (session: Session) => {
state.setSelectedModules(session.selectedModules); state.setSelectedModules(session.selectedModules);
state.setExam(session.exam); state.setExam(session.exam);
state.setExams(session.exams); state.setExams(session.exams);
state.setSessionId(session.sessionId); state.setSessionId(session.sessionId);
state.setAssignment(session.assignment); state.setAssignment(session.assignment);
state.setExerciseIndex(session.exerciseIndex); state.setExerciseIndex(session.exerciseIndex);
state.setPartIndex(session.partIndex); state.setPartIndex(session.partIndex);
state.setModuleIndex(session.moduleIndex); state.setModuleIndex(session.moduleIndex);
state.setTimeSpent(session.timeSpent); state.setTimeSpent(session.timeSpent);
state.setUserSolutions(session.userSolutions); state.setUserSolutions(session.userSolutions);
state.setShowSolutions(false); state.setShowSolutions(false);
state.setQuestionIndex(session.questionIndex); state.setQuestionIndex(session.questionIndex);
}; };
return ( return (
<> <>
<div className="relative flex h-full w-full flex-col gap-8 md:gap-16"> <div className="relative flex h-full w-full flex-col gap-8 md:gap-16">
{user && ( {user && (
<ProfileSummary <ProfileSummary
user={user} user={user}
items={[ items={[
{ {
icon: <BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />, icon: (
label: "Reading", <BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />
value: totalExamsByModule(stats, "reading"), ),
}, label: "Reading",
{ value: totalExamsByModule(stats, "reading"),
icon: <BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />, tooltip: "The amount of reading exams performed.",
label: "Listening", },
value: totalExamsByModule(stats, "listening"), {
}, icon: (
{ <BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />
icon: <BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />, ),
label: "Writing", label: "Listening",
value: totalExamsByModule(stats, "writing"), value: totalExamsByModule(stats, "listening"),
}, tooltip: "The amount of listening exams performed.",
{ },
icon: <BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />, {
label: "Speaking", icon: (
value: totalExamsByModule(stats, "speaking"), <BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />
}, ),
{ label: "Writing",
icon: <BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />, value: totalExamsByModule(stats, "writing"),
label: "Level", tooltip: "The amount of writing exams performed.",
value: totalExamsByModule(stats, "level"), },
}, {
]} icon: (
/> <BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />
)} ),
label: "Speaking",
value: totalExamsByModule(stats, "speaking"),
tooltip: "The amount of speaking exams performed.",
},
{
icon: (
<BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />
),
label: "Level",
value: totalExamsByModule(stats, "level"),
tooltip: "The amount of level exams performed.",
},
]}
/>
)}
<section className="flex flex-col gap-3"> <section className="flex flex-col gap-3">
<span className="text-lg font-bold">About {capitalize(page)}</span> <span className="text-lg font-bold">About {capitalize(page)}</span>
<span className="text-mti-gray-taupe"> <span className="text-mti-gray-taupe">
{page === "exercises" && ( {page === "exercises" && (
<> <>
In the realm of language acquisition, practice makes perfect, and our exercises are the key to unlocking your full In the realm of language acquisition, practice makes perfect,
potential. Dive into a world of interactive and engaging exercises that cater to diverse learning styles. From grammar and our exercises are the key to unlocking your full potential.
drills that build a strong foundation to vocabulary challenges that broaden your lexicon, our exercises are carefully Dive into a world of interactive and engaging exercises that
designed to make learning English both enjoyable and effective. Whether you&apos;re looking to reinforce specific cater to diverse learning styles. From grammar drills that build
skills or embark on a holistic language journey, our exercises are your companions in the pursuit of excellence. a strong foundation to vocabulary challenges that broaden your
Embrace the joy of learning as you navigate through a variety of activities that cater to every facet of language lexicon, our exercises are carefully designed to make learning
acquisition. Your linguistic adventure starts here! English both enjoyable and effective. Whether you&apos;re
</> looking to reinforce specific skills or embark on a holistic
)} language journey, our exercises are your companions in the
{page === "exams" && ( pursuit of excellence. Embrace the joy of learning as you
<> navigate through a variety of activities that cater to every
Welcome to the heart of success on your English language journey! Our exams are crafted with precision to assess and facet of language acquisition. Your linguistic adventure starts
enhance your language skills. Each test is a passport to your linguistic prowess, designed to challenge and elevate here!
your abilities. Whether you&apos;re a beginner or a seasoned learner, our exams cater to all levels, providing a </>
comprehensive evaluation of your reading, writing, speaking, and listening skills. Prepare to embark on a journey of )}
self-discovery and language mastery as you navigate through our thoughtfully curated exams. Your success is not just a {page === "exams" && (
destination; it&apos;s a testament to your dedication and our commitment to empowering you with the English language. <>
</> Welcome to the heart of success on your English language
)} journey! Our exams are crafted with precision to assess and
</span> enhance your language skills. Each test is a passport to your
</section> linguistic prowess, designed to challenge and elevate your
abilities. Whether you&apos;re a beginner or a seasoned learner,
our exams cater to all levels, providing a comprehensive
evaluation of your reading, writing, speaking, and listening
skills. Prepare to embark on a journey of self-discovery and
language mastery as you navigate through our thoughtfully
curated exams. Your success is not just a destination; it&apos;s
a testament to your dedication and our commitment to empowering
you with the English language.
</>
)}
</span>
</section>
{sessions.length > 0 && ( {sessions.length > 0 && (
<section className="flex flex-col gap-3 md:gap-3"> <section className="flex flex-col gap-3 md:gap-3">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div <div
onClick={reload} onClick={reload}
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out"> className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out"
<span className="text-mti-black text-lg font-bold">Unfinished Sessions</span> >
<BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} /> <span className="text-mti-black text-lg font-bold">
</div> Unfinished Sessions
</div> </span>
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll"> <BsArrowRepeat
{sessions className={clsx("text-xl", isLoading && "animate-spin")}
.sort((a, b) => moment(b.date).diff(moment(a.date))) />
.map((session) => ( </div>
<SessionCard session={session} key={session.sessionId} reload={reload} loadSession={loadSession} /> </div>
))} <span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
</span> {sessions
</section> .sort((a, b) => moment(b.date).diff(moment(a.date)))
)} .map((session) => (
<SessionCard
session={session}
key={session.sessionId}
reload={reload}
loadSession={loadSession}
/>
))}
</span>
</section>
)}
<section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8"> <section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8">
<div <div
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("reading") : undefined} onClick={
className={clsx( !disableSelection && !selectedModules.includes("level")
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", ? () => toggleModule("reading")
selectedModules.includes("reading") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum", : undefined
)}> }
<div className="bg-ielts-reading absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full"> className={clsx(
<BsBook className="h-7 w-7 text-white" /> "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
</div> selectedModules.includes("reading") || disableSelection
<span className="font-semibold">Reading:</span> ? "border-mti-purple-light"
<p className="text-left text-xs"> : "border-mti-gray-platinum",
Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English. )}
</p> >
{!selectedModules.includes("reading") && !selectedModules.includes("level") && !disableSelection && ( <div className="bg-ielts-reading absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" /> <BsBook className="h-7 w-7 text-white" />
)} </div>
{(selectedModules.includes("reading") || disableSelection) && ( <span className="font-semibold">Reading:</span>
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" /> <p className="text-left text-xs">
)} Expand your vocabulary, improve your reading comprehension and
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />} improve your ability to interpret texts in English.
</div> </p>
<div {!selectedModules.includes("reading") &&
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("listening") : undefined} !selectedModules.includes("level") &&
className={clsx( !disableSelection && (
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", <div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
selectedModules.includes("listening") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum", )}
)}> {(selectedModules.includes("reading") || disableSelection) && (
<div className="bg-ielts-listening absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full"> <BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
<BsHeadphones className="h-7 w-7 text-white" /> )}
</div> {selectedModules.includes("level") && (
<span className="font-semibold">Listening:</span> <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
<p className="text-left text-xs"> )}
Improve your ability to follow conversations in English and your ability to understand different accents and intonations. </div>
</p> <div
{!selectedModules.includes("listening") && !selectedModules.includes("level") && !disableSelection && ( onClick={
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" /> !disableSelection && !selectedModules.includes("level")
)} ? () => toggleModule("listening")
{(selectedModules.includes("listening") || disableSelection) && ( : undefined
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" /> }
)} className={clsx(
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />} "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
</div> selectedModules.includes("listening") || disableSelection
<div ? "border-mti-purple-light"
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("writing") : undefined} : "border-mti-gray-platinum",
className={clsx( )}
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", >
selectedModules.includes("writing") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum", <div className="bg-ielts-listening absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
)}> <BsHeadphones className="h-7 w-7 text-white" />
<div className="bg-ielts-writing absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full"> </div>
<BsPen className="h-7 w-7 text-white" /> <span className="font-semibold">Listening:</span>
</div> <p className="text-left text-xs">
<span className="font-semibold">Writing:</span> Improve your ability to follow conversations in English and your
<p className="text-left text-xs"> ability to understand different accents and intonations.
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays. </p>
</p> {!selectedModules.includes("listening") &&
{!selectedModules.includes("writing") && !selectedModules.includes("level") && !disableSelection && ( !selectedModules.includes("level") &&
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" /> !disableSelection && (
)} <div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
{(selectedModules.includes("writing") || disableSelection) && ( )}
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" /> {(selectedModules.includes("listening") || disableSelection) && (
)} <BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />} )}
</div> {selectedModules.includes("level") && (
<div <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("speaking") : undefined} )}
className={clsx( </div>
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", <div
selectedModules.includes("speaking") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum", onClick={
)}> !disableSelection && !selectedModules.includes("level")
<div className="bg-ielts-speaking absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full"> ? () => toggleModule("writing")
<BsMegaphone className="h-7 w-7 text-white" /> : undefined
</div> }
<span className="font-semibold">Speaking:</span> className={clsx(
<p className="text-left text-xs"> "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
You&apos;ll have access to interactive dialogs, pronunciation exercises and speech recordings. selectedModules.includes("writing") || disableSelection
</p> ? "border-mti-purple-light"
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && !disableSelection && ( : "border-mti-gray-platinum",
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" /> )}
)} >
{(selectedModules.includes("speaking") || disableSelection) && ( <div className="bg-ielts-writing absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" /> <BsPen className="h-7 w-7 text-white" />
)} </div>
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />} <span className="font-semibold">Writing:</span>
</div> <p className="text-left text-xs">
{!disableSelection && ( Allow you to practice writing in a variety of formats, from simple
<div paragraphs to complex essays.
onClick={selectedModules.length === 0 || selectedModules.includes("level") ? () => toggleModule("level") : undefined} </p>
className={clsx( {!selectedModules.includes("writing") &&
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", !selectedModules.includes("level") &&
selectedModules.includes("level") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum", !disableSelection && (
)}> <div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
<div className="bg-ielts-level absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full"> )}
<BsClipboard className="h-7 w-7 text-white" /> {(selectedModules.includes("writing") || disableSelection) && (
</div> <BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
<span className="font-semibold">Level:</span> )}
<p className="text-left text-xs">You&apos;ll be able to test your english level with multiple choice questions.</p> {selectedModules.includes("level") && (
{!selectedModules.includes("level") && selectedModules.length === 0 && !disableSelection && ( <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" /> )}
)} </div>
{(selectedModules.includes("level") || disableSelection) && ( <div
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" /> onClick={
)} !disableSelection && !selectedModules.includes("level")
{!selectedModules.includes("level") && selectedModules.length > 0 && ( ? () => toggleModule("speaking")
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" /> : undefined
)} }
</div> className={clsx(
)} "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
</section> selectedModules.includes("speaking") || disableSelection
<div className="-md:flex-col -md:gap-4 -md:justify-center flex w-full items-center md:justify-between"> ? "border-mti-purple-light"
<div className="flex w-full flex-col items-center gap-3"> : "border-mti-gray-platinum",
<div )}
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm" >
onClick={() => setAvoidRepeatedExams((prev) => !prev)}> <div className="bg-ielts-speaking absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<input type="checkbox" className="hidden" /> <BsMegaphone className="h-7 w-7 text-white" />
<div </div>
className={clsx( <span className="font-semibold">Speaking:</span>
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white", <p className="text-left text-xs">
"transition duration-300 ease-in-out", You&apos;ll have access to interactive dialogs, pronunciation
avoidRepeatedExams && "!bg-mti-purple-light ", exercises and speech recordings.
)}> </p>
<BsCheck color="white" className="h-full w-full" /> {!selectedModules.includes("speaking") &&
</div> !selectedModules.includes("level") &&
<span className="tooltip" data-tip="If possible, the platform will choose exams not yet done."> !disableSelection && (
Avoid Repeated Questions <div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
</span> )}
</div> {(selectedModules.includes("speaking") || disableSelection) && (
<div <BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm" )}
onClick={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}> {selectedModules.includes("level") && (
<input type="checkbox" className="hidden" /> <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
<div )}
className={clsx( </div>
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white", {!disableSelection && (
"transition duration-300 ease-in-out", <div
variant === "full" && "!bg-mti-purple-light ", onClick={
)}> selectedModules.length === 0 ||
<BsCheck color="white" className="h-full w-full" /> selectedModules.includes("level")
</div> ? () => toggleModule("level")
<span>Full length exams</span> : undefined
</div> }
</div> className={clsx(
<div className="tooltip w-full" data-tip={`Your screen size is too small to do ${page}`}> "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
<Button color="purple" className="w-full max-w-xs px-12 md:hidden" disabled> selectedModules.includes("level") || disableSelection
Start Exam ? "border-mti-purple-light"
</Button> : "border-mti-gray-platinum",
</div> )}
<Button >
onClick={() => <div className="bg-ielts-level absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
onStart( <BsClipboard className="h-7 w-7 text-white" />
!disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"], </div>
avoidRepeatedExams, <span className="font-semibold">Level:</span>
variant, <p className="text-left text-xs">
) You&apos;ll be able to test your english level with multiple
} choice questions.
color="purple" </p>
className="-md:hidden w-full max-w-xs px-12 md:self-end" {!selectedModules.includes("level") &&
disabled={selectedModules.length === 0 && !disableSelection}> selectedModules.length === 0 &&
Start Exam !disableSelection && (
</Button> <div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
</div> )}
</div> {(selectedModules.includes("level") || disableSelection) && (
</> <BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
); )}
{!selectedModules.includes("level") &&
selectedModules.length > 0 && (
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
)}
</div>
)}
</section>
<div className="-md:flex-col -md:gap-4 -md:justify-center flex w-full items-center md:justify-between">
<div className="flex w-full flex-col items-center gap-3">
<div
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
onClick={() => setAvoidRepeatedExams((prev) => !prev)}
>
<input type="checkbox" className="hidden" />
<div
className={clsx(
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
"transition duration-300 ease-in-out",
avoidRepeatedExams && "!bg-mti-purple-light ",
)}
>
<BsCheck color="white" className="h-full w-full" />
</div>
<span
className="tooltip"
data-tip="If possible, the platform will choose exams not yet done."
>
Avoid Repeated Questions
</span>
</div>
<div
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
// onClick={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
>
<input type="checkbox" className="hidden" disabled />
<div
className={clsx(
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
"transition duration-300 ease-in-out",
variant === "full" && "!bg-mti-purple-light ",
)}
>
<BsCheck color="white" className="h-full w-full" />
</div>
<span>Full length exams</span>
</div>
</div>
<div
className="tooltip w-full"
data-tip={`Your screen size is too small to do ${page}`}
>
<Button
color="purple"
className="w-full max-w-xs px-12 md:hidden"
disabled
>
Start Exam
</Button>
</div>
<Button
onClick={() =>
onStart(
!disableSelection
? selectedModules.sort(sortByModuleName)
: ["reading", "listening", "writing", "speaking"],
avoidRepeatedExams,
variant,
)
}
color="purple"
className="-md:hidden w-full max-w-xs px-12 md:self-end"
disabled={selectedModules.length === 0 && !disableSelection}
>
Start Exam
</Button>
</div>
</div>
</>
);
} }

View File

@@ -2,7 +2,7 @@ import {renderExercise} from "@/components/Exercises";
import ModuleTitle from "@/components/Medium/ModuleTitle"; import ModuleTitle from "@/components/Medium/ModuleTitle";
import {renderSolution} from "@/components/Solutions"; import {renderSolution} from "@/components/Solutions";
import {infoButtonStyle} from "@/constants/buttonStyles"; import {infoButtonStyle} from "@/constants/buttonStyles";
import {UserSolution, SpeakingExam} from "@/interfaces/exam"; import {UserSolution, SpeakingExam, SpeakingExercise, InteractiveSpeakingExercise} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams"; import {defaultUserSolutions} from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils"; import {countExercises} from "@/utils/moduleUtils";
@@ -20,25 +20,26 @@ interface Props {
} }
export default function Speaking({exam, showSolutions = false, onFinish}: Props) { export default function Speaking({exam, showSolutions = false, onFinish}: Props) {
const [questionIndex, setQuestionIndex] = useState(0); const [speakingPromptsDone, setSpeakingPromptsDone] = useState<{id: string; amount: number}[]>([]);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const {userSolutions, setUserSolutions} = useExamStore((state) => state); const {userSolutions, setUserSolutions} = useExamStore((state) => state);
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state); const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state); const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
useEffect(() => {
setCurrentQuestionIndex(0);
}, [questionIndex]);
const nextExercise = (solution?: UserSolution) => { const nextExercise = (solution?: UserSolution) => {
scrollToTop(); scrollToTop();
if (solution) { if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]); setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "speaking", exam: exam.id}]);
} }
setQuestionIndex((prev) => prev + currentQuestionIndex);
if (questionIndex > 0) {
const exercise = getExercise();
setSpeakingPromptsDone((prev) => [...prev.filter((x) => x.id !== exercise.id), {id: exercise.id, amount: questionIndex}]);
}
setQuestionIndex(0);
if (exerciseIndex + 1 < exam.exercises.length) { if (exerciseIndex + 1 < exam.exercises.length) {
setExerciseIndex(exerciseIndex + 1); setExerciseIndex(exerciseIndex + 1);
@@ -50,18 +51,16 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
setHasExamEnded(false); setHasExamEnded(false);
if (solution) { if (solution) {
onFinish( onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "speaking", exam: exam.id}]);
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "speaking", exam: exam.id})),
);
} else { } else {
onFinish(userSolutions.map((x) => ({...x, module: "speaking", exam: exam.id}))); onFinish(userSolutions);
} }
}; };
const previousExercise = (solution?: UserSolution) => { const previousExercise = (solution?: UserSolution) => {
scrollToTop(); scrollToTop();
if (solution) { if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]); setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "speaking", exam: exam.id}]);
} }
if (exerciseIndex > 0) { if (exerciseIndex > 0) {
@@ -73,8 +72,9 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
const exercise = exam.exercises[exerciseIndex]; const exercise = exam.exercises[exerciseIndex];
return { return {
...exercise, ...exercise,
variant: exerciseIndex < 2 && exercise.type === "interactiveSpeaking" ? "initial" : undefined,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [], userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
}; } as SpeakingExercise | InteractiveSpeakingExercise;
}; };
return ( return (
@@ -83,7 +83,7 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
<ModuleTitle <ModuleTitle
label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)} label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
minTimer={exam.minTimer} minTimer={exam.minTimer}
exerciseIndex={exerciseIndex + 1 + questionIndex + currentQuestionIndex} exerciseIndex={exerciseIndex + 1 + questionIndex + speakingPromptsDone.reduce((acc, curr) => acc + curr.amount, 0)}
module="speaking" module="speaking"
totalExercises={countExercises(exam.exercises)} totalExercises={countExercises(exam.exercises)}
disableTimer={showSolutions} disableTimer={showSolutions}
@@ -91,11 +91,11 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
{exerciseIndex > -1 && {exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length && exerciseIndex < exam.exercises.length &&
!showSolutions && !showSolutions &&
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)} renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
{exerciseIndex > -1 && {exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length && exerciseIndex < exam.exercises.length &&
showSolutions && showSolutions &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)} renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
</div> </div>
</> </>
); );

View File

@@ -28,7 +28,7 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
const nextExercise = (solution?: UserSolution) => { const nextExercise = (solution?: UserSolution) => {
scrollToTop(); scrollToTop();
if (solution) { if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]); setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "writing", exam: exam.id}]);
} }
if (exerciseIndex + 1 < exam.exercises.length) { if (exerciseIndex + 1 < exam.exercises.length) {
@@ -41,18 +41,16 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
setHasExamEnded(false); setHasExamEnded(false);
if (solution) { if (solution) {
onFinish( onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "writing", exam: exam.id}]);
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "writing", exam: exam.id})),
);
} else { } else {
onFinish(userSolutions.map((x) => ({...x, module: "writing", exam: exam.id}))); onFinish(userSolutions);
} }
}; };
const previousExercise = (solution?: UserSolution) => { const previousExercise = (solution?: UserSolution) => {
scrollToTop(); scrollToTop();
if (solution) { if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]); setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "writing", exam: exam.id}]);
} }
if (exerciseIndex > 0) { if (exerciseIndex > 0) {

View File

@@ -25,7 +25,13 @@ const thresholds = [
level: "High A2/Low B1", level: "High A2/Low B1",
label: "Pre-Intermediate", label: "Pre-Intermediate",
minValue: 8, minValue: 8,
maxValue: 12, maxValue: 11,
},
{
level: "High B1/Low B2",
label: "Intermediate",
minValue: 12,
maxValue: 15,
}, },
{ {
level: "High B2/Low C1", level: "High B2/Low C1",

View File

@@ -4,14 +4,18 @@ import React from "react";
import {View, Text, Image} from "@react-pdf/renderer"; import {View, Text, Image} from "@react-pdf/renderer";
import {styles} from "../styles"; import {styles} from "../styles";
import {ModuleScore} from "@/interfaces/module.scores"; import {ModuleScore} from "@/interfaces/module.scores";
import {calculateBandScore} from "@/utils/score";
import {Module} from "@/interfaces";
export const RadialResult = ({module, score, total, png}: ModuleScore) => ( export const RadialResult = ({module, score, total, png}: ModuleScore) => (
<View style={[styles.textFont, styles.radialContainer]}> <View style={[styles.textFont, styles.radialContainer]}>
<Text style={[styles.textColor, styles.textBold, {fontSize: 10}]}>{module}</Text> <Text style={[styles.textColor, styles.textBold, {fontSize: 10}]}>{module}</Text>
<Image src={png} style={styles.image64}></Image> <Image src={png} style={styles.image64}></Image>
<View style={[styles.textColor, styles.radialResultContainer]}> <View style={[styles.textColor, styles.radialResultContainer]}>
<Text style={styles.textBold}>{score.toFixed(2)}</Text> <Text style={styles.textBold}>
<Text style={{fontSize: 8}}>out of {total}</Text> {module === "level" ? Math.floor(score) : calculateBandScore(score, total, module.toLowerCase() as Module | "overall", "general")}
</Text>
<Text style={{fontSize: 8}}>out of {module === "level" ? total : "9.0"}</Text>
</View> </View>
</View> </View>
); );

View File

@@ -215,7 +215,7 @@ const GroupTestReport = ({
</View> </View>
<View style={[{ paddingBottom: 30 }, styles.separator]}></View> <View style={[{ paddingBottom: 30 }, styles.separator]}></View>
<View style={{ flexGrow: 1 }}></View> <View style={{ flexGrow: 1 }}></View>
<TestReportFooter /> <TestReportFooter userId={id} />
</Page> </Page>
<Page style={styles.body}> <Page style={styles.body}>
<View <View
@@ -297,7 +297,7 @@ const GroupTestReport = ({
</View> </View>
<View style={{ flexGrow: 1 }}></View> <View style={{ flexGrow: 1 }}></View>
<TestReportFooter /> <TestReportFooter userId={id} />
</Page> </Page>
</Document> </Document>
); );

View File

@@ -0,0 +1,24 @@
import { Text, View, StyleSheet } from "@react-pdf/renderer";
const styles = StyleSheet.create({
row: {
display: "flex",
flexDirection: "row",
},
bullet: {
height: "100%",
},
});
const ListItem = ({ text, textStyle }: { text: string, textStyle: any[] }) => {
return (
<View style={styles.row}>
<View style={styles.bullet}>
<Text style={textStyle}>{"\u2022" + " "}</Text>
</View>
<Text style={textStyle}>{text}</Text>
</View>
);
};
export default ListItem;

View File

@@ -69,7 +69,6 @@ const TestReportFooter = ({ userId }: Props) => (
<Text style={styles.textUnderline}>info@encoach.com</Text> <Text style={styles.textUnderline}>info@encoach.com</Text>
<Text>https://encoach.com</Text> <Text>https://encoach.com</Text>
<View style={styles.spacedRow}> <View style={styles.spacedRow}>
<Text>Group ID: TRI64BNBOIU5043</Text>
<Text <Text
// style={styles.pageNumber} // style={styles.pageNumber}
render={({ pageNumber, totalPages }) => render={({ pageNumber, totalPages }) =>

View File

@@ -6,11 +6,17 @@ import { styles } from "./styles";
import { StyleSheet } from "@react-pdf/renderer"; import { StyleSheet } from "@react-pdf/renderer";
import TestReportFooter from "./test.report.footer"; import TestReportFooter from "./test.report.footer";
import ListItem from "./list.item";
const customStyles = StyleSheet.create({ const customStyles = StyleSheet.create({
testDetails: { testDetails: {
display: "flex", display: "flex",
gap: 4, gap: 4,
}, },
testDetailsContainer: {
display: "flex",
gap: 16,
},
}); });
interface Props { interface Props {
@@ -82,7 +88,6 @@ const TestReport = ({
</Text> </Text>
<View style={styles.textMargin}> <View style={styles.textMargin}>
<Text style={defaultTextStyle}>Name: {name}</Text> <Text style={defaultTextStyle}>Name: {name}</Text>
<Text style={defaultTextStyle}>ID: {id}</Text>
<Text style={defaultTextStyle}>Email: {email}</Text> <Text style={defaultTextStyle}>Email: {email}</Text>
<Text style={defaultTextStyle}>Gender: {gender}</Text> <Text style={defaultTextStyle}>Gender: {gender}</Text>
<Text style={defaultTextStyle}>Passport ID: {passportId}</Text> <Text style={defaultTextStyle}>Passport ID: {passportId}</Text>
@@ -124,7 +129,7 @@ const TestReport = ({
</View> </View>
</View> </View>
<View style={[{ paddingTop: 30 }, styles.separator]}></View> <View style={[{ paddingTop: 30 }, styles.separator]}></View>
<TestReportFooter userId={id}/> <TestReportFooter userId={id} />
</Page> </Page>
<Page style={styles.body}> <Page style={styles.body}>
<View> <View>
@@ -149,15 +154,46 @@ const TestReport = ({
.filter( .filter(
({ suggestions, evaluation }) => suggestions || evaluation ({ suggestions, evaluation }) => suggestions || evaluation
) )
.map(({ module, suggestions, evaluation }) => ( .map(
<View key={module} style={customStyles.testDetails}> ({
<Text style={[...defaultSkillsTitleStyle, styles.textBold]}> module,
{module} suggestions,
</Text> evaluation,
<Text style={defaultSkillsTextStyle}>{evaluation}</Text> bullet_points = [],
<Text style={defaultSkillsTextStyle}>{suggestions}</Text> }) => (
</View> <View key={module} style={customStyles.testDetailsContainer}>
))} <View style={customStyles.testDetails}>
<Text
style={[...defaultSkillsTitleStyle, styles.textBold]}
>
{module}
</Text>
<Text style={defaultSkillsTextStyle}>{evaluation}</Text>
<Text style={defaultSkillsTextStyle}>{suggestions}</Text>
</View>
<View style={customStyles.testDetails}>
{bullet_points.length > 0 && (
<>
<Text
style={defaultSkillsTitleStyle}
>
How to Improve:
</Text>
<View>
{bullet_points.map((text: string) => (
<ListItem
key={text}
text={text}
textStyle={defaultSkillsTextStyle}
/>
))}
</View>
</>
)}
</View>
</View>
)
)}
</View> </View>
<View style={styles.alignRightRow}> <View style={styles.alignRightRow}>
<Image src={qrcode} style={styles.qrcode} /> <Image src={qrcode} style={styles.qrcode} />
@@ -165,7 +201,7 @@ const TestReport = ({
</View> </View>
<View style={[{ paddingBottom: 30 }, styles.separator]}></View> <View style={[{ paddingBottom: 30 }, styles.separator]}></View>
<View style={{ flexGrow: 1 }}></View> <View style={{ flexGrow: 1 }}></View>
<TestReportFooter userId={id}/> <TestReportFooter userId={id} />
</Page> </Page>
</Document> </Document>
); );

View File

@@ -1,8 +1,9 @@
import {initializeApp} from "firebase/app"; import {initializeApp} from "firebase/app";
import * as admin from "firebase-admin/app"; import * as admin from "firebase-admin/app";
import { getStorage } from "firebase/storage"; import {getStorage} from "firebase/storage";
const serviceAccount = require("@/constants/serviceAccountKey.json"); const stagingServiceAccount = require("@/constants/staging.json");
const platformServiceAccount = require("@/constants/platform.json");
const firebaseConfig = { const firebaseConfig = {
apiKey: process.env.FIREBASE_PUBLIC_API_KEY || "", apiKey: process.env.FIREBASE_PUBLIC_API_KEY || "",
@@ -16,7 +17,7 @@ const firebaseConfig = {
export const app = initializeApp(firebaseConfig, Math.random().toString()); export const app = initializeApp(firebaseConfig, Math.random().toString());
export const adminApp = admin.initializeApp( export const adminApp = admin.initializeApp(
{ {
credential: admin.cert(serviceAccount), credential: admin.cert(process.env.ENVIRONMENT === "platform" ? platformServiceAccount : stagingServiceAccount),
}, },
Math.random().toString(), Math.random().toString(),
); );

View File

@@ -0,0 +1,26 @@
import React from "react";
import Link from "next/link";
import Checkbox from "@/components/Low/Checkbox";
const useAcceptedTerms = () => {
const [acceptedTerms, setAcceptedTerms] = React.useState(false);
const renderCheckbox = () => (
<Checkbox isChecked={acceptedTerms} onChange={setAcceptedTerms}>
I agree to the
<Link href={`https://encoach.com/terms`} className="text-mti-purple-light">
{" "}
Terms and Conditions
</Link>{" "}
and
<Link href={`https://encoach.com/privacy-policy`} className="text-mti-purple-light">
{" "}
Privacy Policy
</Link>
</Checkbox>
);
return {acceptedTerms, renderCheckbox};
};
export default useAcceptedTerms;

View File

@@ -0,0 +1,42 @@
import React from "react";
import axios from "axios";
import {toast} from "react-toastify";
import {BsArchive} from "react-icons/bs";
export const useAssignmentArchive = (assignmentId: string, reload?: Function) => {
const [loading, setLoading] = React.useState(false);
const archive = () => {
// archive assignment
setLoading(true);
axios
.post(`/api/assignments/${assignmentId}/archive`)
.then((res) => {
toast.success("Assignment archived!");
if (reload) reload();
setLoading(false);
})
.catch((err) => {
toast.error("Failed to archive the assignment!");
setLoading(false);
});
};
const renderIcon = (downloadClasses: string, loadingClasses: string) => {
if (loading) {
return <span className={`${loadingClasses} loading loading-infinity w-6`} />;
}
return (
<div
className="tooltip flex items-center justify-center w-fit h-fit"
data-tip="Archive assignment"
onClick={(e) => {
e.stopPropagation();
archive();
}}>
<BsArchive className={`${downloadClasses} text-2xl cursor-pointer tooltip`} />
</div>
);
};
return renderIcon;
};

View File

@@ -0,0 +1,42 @@
import React from "react";
import axios from "axios";
import {toast} from "react-toastify";
import {BsArchive, BsFileEarmarkCheck, BsFileEarmarkCheckFill} from "react-icons/bs";
export const useAssignmentUnarchive = (assignmentId: string, reload?: Function) => {
const [loading, setLoading] = React.useState(false);
const archive = () => {
// archive assignment
setLoading(true);
axios
.post(`/api/assignments/${assignmentId}/unarchive`)
.then((res) => {
toast.success("Assignment unarchived!");
if (reload) reload();
setLoading(false);
})
.catch((err) => {
toast.error("Failed to unarchive the assignment!");
setLoading(false);
});
};
const renderIcon = (downloadClasses: string, loadingClasses: string) => {
if (loading) {
return <span className={`${loadingClasses} loading loading-infinity w-6`} />;
}
return (
<div
className="tooltip flex items-center justify-center w-fit h-fit"
data-tip="Unarchive assignment"
onClick={(e) => {
e.stopPropagation();
archive();
}}>
<BsFileEarmarkCheck className={`${downloadClasses} text-2xl cursor-pointer tooltip`} />
</div>
);
};
return renderIcon;
};

View File

@@ -0,0 +1,22 @@
import { Discount } from "@/interfaces/paypal";
import { Code, Group, User } from "@/interfaces/user";
import axios from "axios";
import { useEffect, useState } from "react";
export default function useDiscounts(creator?: string) {
const [discounts, setDiscounts] = useState<Discount[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
setIsLoading(true);
axios
.get<Discount[]>("/api/discounts")
.then((response) => setDiscounts(response.data))
.finally(() => setIsLoading(false));
};
useEffect(getData, [creator]);
return { discounts, isLoading, isError, reload: getData };
}

View File

@@ -2,16 +2,23 @@ import {Group, User} from "@/interfaces/user";
import axios from "axios"; import axios from "axios";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
export default function useGroups(admin?: string) { export default function useGroups(admin?: string, userType?: string) {
const [groups, setGroups] = useState<Group[]>([]); const [groups, setGroups] = useState<Group[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false); const [isError, setIsError] = useState(false);
const isMasterType = userType?.startsWith('master');
const getData = () => { const getData = () => {
setIsLoading(true); setIsLoading(true);
const url = admin ? `/api/groups?admin=${admin}` : "/api/groups";
axios axios
.get<Group[]>("/api/groups") .get<Group[]>(url)
.then((response) => { .then((response) => {
if(isMasterType) {
return setGroups(response.data);
}
const filter = (g: Group) => g.admin === admin || g.participants.includes(admin || ""); const filter = (g: Group) => g.admin === admin || g.participants.includes(admin || "");
const filteredGroups = admin ? response.data.filter(filter) : response.data; const filteredGroups = admin ? response.data.filter(filter) : response.data;
@@ -20,7 +27,7 @@ export default function useGroups(admin?: string) {
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
useEffect(getData, [admin]); useEffect(getData, [admin, isMasterType]);
return {groups, isLoading, isError, reload: getData}; return {groups, isLoading, isError, reload: getData};
} }

View File

@@ -1,4 +1,4 @@
import {useState, useMemo} from 'react'; import {useState, useMemo} from "react";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
/*fields example = [ /*fields example = [
@@ -6,43 +6,33 @@ import Input from "@/components/Low/Input";
['companyInformation', 'companyInformation', 'name'] ['companyInformation', 'companyInformation', 'name']
]*/ ]*/
const getFieldValue = (fields: string[], data: any): string => { const getFieldValue = (fields: string[], data: any): string => {
if(fields.length === 0) return data; if (fields.length === 0) return data;
const [key, ...otherFields] = fields; const [key, ...otherFields] = fields;
if(data[key]) return getFieldValue(otherFields, data[key]); if (data[key]) return getFieldValue(otherFields, data[key]);
return data; return data;
} };
export const useListSearch = (fields: string[][], rows: any[]) => { export function useListSearch<T>(fields: string[][], rows: T[]) {
const [text, setText] = useState(''); const [text, setText] = useState("");
const renderSearch = () => ( const renderSearch = () => <Input label="Search" type="text" name="search" onChange={setText} placeholder="Enter search text" value={text} />;
<Input
label="Search" const updatedRows = useMemo(() => {
type="text" const searchText = text.toLowerCase();
name="search" return rows.filter((row) => {
onChange={setText} return fields.some((fieldsKeys) => {
placeholder="Enter search text" const value = getFieldValue(fieldsKeys, row);
value={text} if (typeof value === "string") {
/> return value.toLowerCase().includes(searchText);
) }
});
const updatedRows = useMemo(() => { });
const searchText = text.toLowerCase(); }, [fields, rows, text]);
return rows.filter((row) => {
return fields.some((fieldsKeys) => { return {
const value = getFieldValue(fieldsKeys, row); rows: updatedRows,
if(typeof value === 'string') { renderSearch,
return value.toLowerCase().includes(searchText); };
}
})
})
}, [fields, rows, text])
return {
rows: updatedRows,
renderSearch,
}
} }

View File

@@ -0,0 +1,23 @@
import {PaypalPayment} from "@/interfaces/paypal";
import axios from "axios";
import {useEffect, useState} from "react";
export default function usePaypalPayments() {
const [payments, setPayments] = useState<PaypalPayment[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
setIsLoading(true);
axios
.get<PaypalPayment[]>("/api/payments/paypal")
.then((response) => {
return setPayments(response.data);
})
.finally(() => setIsLoading(false));
};
useEffect(getData, []);
return {payments, isLoading, isError, reload: getData};
}

View File

@@ -0,0 +1,20 @@
import { useEffect, useState } from "react";
import axios from "axios";
export const usePaypalTracking = () => {
const [trackingId, setTrackingId] = useState<string>();
useEffect(() => {
axios
.put<{ ok: boolean; trackingId: string }>("/api/paypal/raas")
.then((response) => {
if (response.data.ok) {
setTrackingId(response.data.trackingId);
}
})
.catch((error) => {
console.error(error);
});
}, []);
return trackingId;
};

View File

@@ -2,18 +2,22 @@ import {Stat, User} from "@/interfaces/user";
import axios from "axios"; import axios from "axios";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
export default function useStats(id?: string) { export default function useStats(id?: string, shouldNotQuery?: boolean) {
const [stats, setStats] = useState<Stat[]>([]); const [stats, setStats] = useState<Stat[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false); const [isError, setIsError] = useState(false);
useEffect(() => { const getData = () => {
if (shouldNotQuery) return;
setIsLoading(true); setIsLoading(true);
axios axios
.get<Stat[]>(!id ? "/api/stats" : `/api/stats/user/${id}`) .get<Stat[]>(!id ? "/api/stats" : `/api/stats/user/${id}`)
.then((response) => setStats(response.data)) .then((response) => setStats(response.data.filter((x) => (id ? x.user === id : true))))
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}, [id]); };
return {stats, isLoading, isError}; useEffect(getData, [id, shouldNotQuery]);
return {stats, reload: getData, isLoading, isError};
} }

View File

@@ -1,22 +1,22 @@
import { Ticket } from "@/interfaces/ticket"; import { TicketWithCorporate } from "@/interfaces/ticket";
import { Code, Group, User } from "@/interfaces/user"; import { Code, Group, User } from "@/interfaces/user";
import axios from "axios"; import axios from "axios";
import { useEffect, useState } from "react"; import { useEffect, useState, useCallback } from "react";
export default function useTickets() { export default function useTickets() {
const [tickets, setTickets] = useState<Ticket[]>([]); const [tickets, setTickets] = useState<TicketWithCorporate[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false); const [isError, setIsError] = useState(false);
const getData = () => { const getData = useCallback(() => {
setIsLoading(true); setIsLoading(true);
axios axios
.get<Ticket[]>(`/api/tickets`) .get<TicketWithCorporate[]>(`/api/tickets`)
.then((response) => setTickets(response.data)) .then((response) => setTickets(response.data))
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; }, []);
useEffect(getData, []); useEffect(getData, [getData]);
return { tickets, isLoading, isError, reload: getData }; return { tickets, isLoading, isError, reload: getData };
} }

View File

@@ -0,0 +1,29 @@
import React from "react";
import useTickets from "./useTickets";
const useTicketsListener = (userId?: string) => {
const { tickets, reload } = useTickets();
React.useEffect(() => {
const intervalId = setInterval(() => {
reload();
}, 60 * 1000);
return () => clearInterval(intervalId);
}, [reload]);
if (userId) {
const assignedTickets = tickets.filter(
(ticket) => ticket.assignedTo === userId && ticket.status === "submitted"
);
return {
assignedTickets,
totalAssignedTickets: assignedTickets.length,
};
}
return {};
};
export default useTicketsListener;

View File

@@ -2,6 +2,8 @@ import {Module} from ".";
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam; export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
export type Variant = "full" | "partial"; export type Variant = "full" | "partial";
export type InstructorGender = "male" | "female" | "varied";
export type Difficulty = "easy" | "medium" | "hard";
export interface ReadingExam { export interface ReadingExam {
parts: ReadingPart[]; parts: ReadingPart[];
@@ -11,6 +13,7 @@ export interface ReadingExam {
type: "academic" | "general"; type: "academic" | "general";
isDiagnostic: boolean; isDiagnostic: boolean;
variant?: Variant; variant?: Variant;
difficulty?: Difficulty;
} }
export interface ReadingPart { export interface ReadingPart {
@@ -24,10 +27,16 @@ export interface ReadingPart {
export interface LevelExam { export interface LevelExam {
module: "level"; module: "level";
id: string; id: string;
exercises: Exercise[]; parts: LevelPart[];
minTimer: number; minTimer: number;
isDiagnostic: boolean; isDiagnostic: boolean;
variant?: Variant; variant?: Variant;
difficulty?: Difficulty;
}
export interface LevelPart {
context?: string;
exercises: Exercise[];
} }
export interface ListeningExam { export interface ListeningExam {
@@ -37,6 +46,7 @@ export interface ListeningExam {
minTimer: number; minTimer: number;
isDiagnostic: boolean; isDiagnostic: boolean;
variant?: Variant; variant?: Variant;
difficulty?: Difficulty;
} }
export interface ListeningPart { export interface ListeningPart {
@@ -59,15 +69,17 @@ export interface UserSolution {
missing: number; missing: number;
}; };
exercise: string; exercise: string;
isDisabled?: boolean;
} }
export interface WritingExam { export interface WritingExam {
module: "writing"; module: "writing";
id: string; id: string;
exercises: Exercise[]; exercises: WritingExercise[];
minTimer: number; minTimer: number;
isDiagnostic: boolean; isDiagnostic: boolean;
variant?: Variant; variant?: Variant;
difficulty?: Difficulty;
} }
interface WordCounter { interface WordCounter {
@@ -78,10 +90,12 @@ interface WordCounter {
export interface SpeakingExam { export interface SpeakingExam {
id: string; id: string;
module: "speaking"; module: "speaking";
exercises: Exercise[]; exercises: (SpeakingExercise | InteractiveSpeakingExercise)[];
minTimer: number; minTimer: number;
isDiagnostic: boolean; isDiagnostic: boolean;
variant?: Variant; variant?: Variant;
instructorGender: InstructorGender;
difficulty?: Difficulty;
} }
export type Exercise = export type Exercise =
@@ -97,18 +111,18 @@ export type Exercise =
export interface Evaluation { export interface Evaluation {
comment: string; comment: string;
overall: number; overall: number;
task_response: {[key: string]: number}; task_response: {[key: string]: number | {grade: number; comment: string}};
misspelled_pairs?: {correction: string | null; misspelled: string}[]; misspelled_pairs?: {correction: string | null; misspelled: string}[];
} }
interface InteractiveSpeakingEvaluation extends Evaluation { interface InteractiveSpeakingEvaluation extends Evaluation {
perfect_answer_1?: string; perfect_answer_1?: {answer: string};
transcript_1?: string; transcript_1?: string;
fixed_text_1?: string; fixed_text_1?: string;
perfect_answer_2?: string; perfect_answer_2?: {answer: string};
transcript_2?: string; transcript_2?: string;
fixed_text_2?: string; fixed_text_2?: string;
perfect_answer_3?: string; perfect_answer_3?: {answer: string};
transcript_3?: string; transcript_3?: string;
fixed_text_3?: string; fixed_text_3?: string;
} }
@@ -138,8 +152,27 @@ export interface WritingExercise {
userSolutions: { userSolutions: {
id: string; id: string;
solution: string; solution: string;
evaluation?: CommonEvaluation; evaluation?: WritingEvaluation;
}[]; }[];
topic?: string;
}
export interface AIDetectionAttributes {
predicted_class: "ai" | "mixed" | "human";
confidence_category: "high" | "medium" | "low";
class_probabilities: {
ai: number;
human: number;
mixed: number;
};
sentences: {
sentence: string;
highlight_sentence_for_ai: boolean;
}[];
}
export interface WritingEvaluation extends CommonEvaluation {
ai_detection?: AIDetectionAttributes;
} }
export interface SpeakingExercise { export interface SpeakingExercise {
@@ -148,18 +181,22 @@ export interface SpeakingExercise {
title: string; title: string;
text: string; text: string;
prompts: string[]; prompts: string[];
suffix?: string;
video_url: string; video_url: string;
userSolutions: { userSolutions: {
id: string; id: string;
solution: string; solution: string;
evaluation?: SpeakingEvaluation; evaluation?: SpeakingEvaluation;
}[]; }[];
topic?: string;
} }
export interface InteractiveSpeakingExercise { export interface InteractiveSpeakingExercise {
id: string; id: string;
type: "interactiveSpeaking"; type: "interactiveSpeaking";
title: string; title: string;
first_title?: string;
second_title?: string;
text: string; text: string;
prompts: {text: string; video_url: string}[]; prompts: {text: string; video_url: string}[];
userSolutions: { userSolutions: {
@@ -167,13 +204,17 @@ export interface InteractiveSpeakingExercise {
solution: {questionIndex: number; question: string; answer: string}[]; solution: {questionIndex: number; question: string; answer: string}[];
evaluation?: InteractiveSpeakingEvaluation; evaluation?: InteractiveSpeakingEvaluation;
}[]; }[];
topic?: string;
first_topic?: string;
second_topic?: string;
variant?: "initial" | "final";
} }
export interface FillBlanksExercise { export interface FillBlanksExercise {
prompt: string; // *EXAMPLE: "Complete the summary below. Click a blank to select the corresponding word for it." prompt: string; // *EXAMPLE: "Complete the summary below. Click a blank to select the corresponding word for it."
type: "fillBlanks"; type: "fillBlanks";
id: string; id: string;
words: string[]; // *EXAMPLE: ["preserve", "unaware"] words: (string | {letter: string; word: string})[]; // *EXAMPLE: ["preserve", "unaware"]
text: string; // *EXAMPLE: "They tried to {{1}} burning" text: string; // *EXAMPLE: "They tried to {{1}} burning"
allowRepetition: boolean; allowRepetition: boolean;
solutions: { solutions: {
@@ -221,17 +262,21 @@ export interface MatchSentencesExercise {
id: string; id: string;
prompt: string; prompt: string;
userSolutions: {question: string; option: string}[]; userSolutions: {question: string; option: string}[];
sentences: { sentences: MatchSentenceExerciseSentence[];
id: string;
sentence: string;
solution: string;
color: string;
}[];
allowRepetition: boolean; allowRepetition: boolean;
options: { options: MatchSentenceExerciseOption[];
id: string; }
sentence: string;
}[]; export interface MatchSentenceExerciseSentence {
id: string;
sentence: string;
solution: string;
color: string;
}
export interface MatchSentenceExerciseOption {
id: string;
sentence: string;
} }
export interface MultipleChoiceExercise { export interface MultipleChoiceExercise {

View File

@@ -8,6 +8,7 @@ export interface ModuleScore {
png?: string; png?: string;
evaluation?: string; evaluation?: string;
suggestions?: string; suggestions?: string;
bullet_points?: string[];
} }
export interface StudentData { export interface StudentData {

118
src/interfaces/paymob.ts Normal file
View File

@@ -0,0 +1,118 @@
export interface PaymentIntention {
amount: number;
currency: string;
payment_methods: number[];
items: any[];
billing_data: BillingData;
customer: Customer;
extras: IntentionExtras;
}
interface BillingData {
apartment: string;
first_name: string;
last_name: string;
street: string;
building: string;
phone_number: string;
country: string;
email: string;
floor: string;
state: string;
}
interface Customer {
first_name: string;
last_name: string;
email: string;
extras: IntentionExtras;
}
type IntentionExtras = {[key: string]: string | number};
export interface IntentionResult {
payment_keys: PaymentKeysItem[];
id: string;
intention_detail: IntentionDetail;
client_secret: string;
payment_methods: PaymentMethodsItem[];
special_reference: null;
extras: Extras;
confirmed: boolean;
status: string;
created: string;
card_detail: null;
object: string;
}
interface PaymentKeysItem {
integration: number;
key: string;
gateway_type: string;
iframe_id: null;
}
interface IntentionDetail {
amount: number;
items: ItemsItem[];
currency: string;
}
interface ItemsItem {
name: string;
amount: number;
description: string;
quantity: number;
}
interface PaymentMethodsItem {
integration_id: number;
alias: null;
name: null;
method_type: string;
currency: string;
live: boolean;
use_cvc_with_moto: boolean;
}
interface Extras {
creation_extras: IntentionExtras;
confirmation_extras: null;
}
export interface TransactionResult {
paymob_request_id: null;
intention: IntentionResult;
hmac: string;
transaction: Transaction;
}
interface Transaction {
amount_cents: number;
created_at: string;
currency: string;
error_occured: boolean;
has_parent_transaction: boolean;
id: number;
integration_id: number;
is_3d_secure: boolean;
is_auth: boolean;
is_capture: boolean;
is_refunded: boolean;
is_standalone_payment: boolean;
is_voided: boolean;
order: Order;
owner: number;
pending: boolean;
source_data: Source_data;
success: boolean;
receipt: string;
}
interface Order {
id: number;
}
interface Source_data {
pan: string;
sub_type: string;
type: string;
}

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