Compare commits

...

266 Commits

Author SHA1 Message Date
Tiago Ribeiro
58c18133ec Finished up the modal to create a payment and added the page to the sidebar 2023-12-05 23:41:55 +00:00
Tiago Ribeiro
03520b650b Merge branch 'develop' into faeture/payment-history 2023-12-05 16:36:16 +00:00
Tiago Ribeiro
556884058b Fixed a bug where the user was not being saved when the expiry date is disabled 2023-12-05 16:35:40 +00:00
Tiago Ribeiro
73b0d5d41d Continued creating the payment page 2023-12-05 16:27:18 +00:00
Tiago Ribeiro
7c589327f7 Merge branch 'develop' into faeture/payment-history 2023-12-04 16:01:30 +00:00
Tiago Ribeiro
5c8867555d Added the option to view both the teachers and students of a corporate as well as the corporate of a student 2023-12-03 00:13:50 +00:00
Tiago Ribeiro
36be5267a2 Set the Part 4 as undefined as well 2023-11-30 16:52:45 +00:00
Tiago Ribeiro
4ebfd49cb9 Merge branch 'develop' into faeture/payment-history 2023-11-30 15:52:16 +00:00
Tiago Ribeiro
96fe83de14 Added the Speaking generation to the project, still WIP 2023-11-30 15:50:24 +00:00
Tiago Ribeiro
1746db3752 Disabled Diagnostics test for all users except students 2023-11-30 10:36:15 +00:00
Tiago Ribeiro
58b4883236 Updated the types of exercises for the Listening Generation 2023-11-29 20:52:08 +00:00
Tiago Ribeiro
a3864eb7d3 Added sound effects to the exam generation 2023-11-29 20:26:48 +00:00
Tiago Ribeiro
1f0e5f4a08 Added the ability to generate Listening exams as well 2023-11-29 17:19:47 +00:00
Tiago Ribeiro
c90234cefc Changed from employment to position for Corporate accounts 2023-11-28 08:21:00 +00:00
Tiago Ribeiro
f354a4f4fe Solved an oopsie 2023-11-27 23:09:37 +00:00
Tiago Ribeiro
7e0c071eee Changed to Number of users 2023-11-27 23:07:40 +00:00
Tiago Ribeiro
9bed726062 Created a list of payments 2023-11-27 22:27:51 +00:00
Tiago Ribeiro
3878d4761e Made it so the listing of a corporate account shows the name of the corporate instead of the person 2023-11-27 13:07:33 +00:00
Tiago Ribeiro
81f5af5629 Added more information for the Agent User 2023-11-27 13:02:19 +00:00
Tiago Ribeiro
5f76e430af Extracted the user types 2023-11-27 11:35:04 +00:00
Tiago Ribeiro
facac33a89 More housekeeping 2023-11-27 11:22:41 +00:00
Tiago Ribeiro
f36c63f1b2 Added a trim 2023-11-27 11:06:05 +00:00
Tiago Ribeiro
b1f07b877c Added the type to the profile page 2023-11-27 10:39:43 +00:00
Tiago Ribeiro
70611305a7 Changed the defaultAvatar 2023-11-27 10:32:54 +00:00
Tiago Ribeiro
fdedc2c5d3 Changed the way the settings is viewable 2023-11-27 10:32:02 +00:00
Tiago Ribeiro
75875b49e6 Removed access to upload/add users from the teachers 2023-11-27 10:23:42 +00:00
Tiago Ribeiro
37e52886b5 Merge branch 'main' into develop 2023-11-27 08:48:52 +00:00
Tiago Ribeiro
a5dfe69220 Removed an unused firebase config variable 2023-11-27 08:48:15 +00:00
Tiago Ribeiro
1c36c7f1e1 Improved a slight detail 2023-11-26 23:21:45 +00:00
Tiago Ribeiro
9de39485de Improved the way the PayPal integration works 2023-11-26 23:16:26 +00:00
Tiago Ribeiro
0fe2e0d393 Merge branch 'develop' into feature/paypal-integration 2023-11-26 23:00:17 +00:00
Tiago Ribeiro
dbb5e131fc Removed the previous stuff 2023-11-26 22:34:59 +00:00
Tiago Ribeiro
ebda1e1717 And another test 2023-11-26 22:34:35 +00:00
Tiago Ribeiro
8cbec131fe Adding it to the build as well 2023-11-26 22:13:02 +00:00
Tiago Ribeiro
472d4a3331 Let's try this one out now 2023-11-26 21:49:39 +00:00
Tiago Ribeiro
c2f83d996a Testing something out 2023-11-26 21:15:09 +00:00
Tiago Ribeiro
43bd6b24c5 Reverted a change 2023-11-26 15:47:37 +00:00
Tiago Ribeiro
ca89261e10 Made it so the admin and agent should also be able to edit the amount each corporate should pay 2023-11-26 15:15:58 +00:00
Tiago Ribeiro
a9bbbe8b52 Turned the code in the register optional 2023-11-26 13:59:14 +00:00
Tiago Ribeiro
fa544bf4e8 Enabled payment for Corporate along with increasing every single one of their students/teachers expiry date as well 2023-11-26 11:01:27 +00:00
Tiago Ribeiro
7e91a989b3 Added packages for students to be able to purchase 2023-11-26 10:08:57 +00:00
Tiago Ribeiro
c312260721 Started working with PayPal 2023-11-24 16:02:55 +00:00
Tiago Ribeiro
23f2bace5d Added the ability to generate Level exams 2023-11-24 00:57:25 +00:00
Tiago Ribeiro
7e2f1fcf9d Merge branch 'develop' into feature/exam-generation 2023-11-21 19:45:30 +00:00
Tiago Ribeiro
6e420a8a82 Created a dashboard for the Agent 2023-11-21 18:01:45 +00:00
Tiago Ribeiro
cd81547022 Created a dashboard of the Agent 2023-11-21 13:59:36 +00:00
Tiago Ribeiro
a2baedb80c Improvement the creation of Agents 2023-11-21 13:37:29 +00:00
Tiago Ribeiro
8072cefbe6 Added the ability to create an agent using the CodeGenerator 2023-11-21 13:24:07 +00:00
Tiago Ribeiro
6bf666d01c It is now possible to generate and save both Reading and Writing exams 2023-11-21 12:17:57 +00:00
Tiago Ribeiro
7672e29063 Merge branch 'develop' into feature/exam-generation 2023-11-21 11:22:02 +00:00
Tiago Ribeiro
51e7c535df Updated the Exercise count for the Interactive Speaking as well 2023-11-21 09:35:54 +00:00
Tiago Ribeiro
d0f89cfe01 Fixed issues related to the exercise/question index in the ModuleTitle 2023-11-21 09:22:32 +00:00
Tiago Ribeiro
8de60aeb32 Merge branch 'develop' into feature/exam-generation 2023-11-21 00:31:51 +00:00
Tiago Ribeiro
0e28473c31 Changed the mobile menu to the correct one 2023-11-21 00:29:58 +00:00
Tiago Ribeiro
52d4b831ae Renamed the Owner to Admin 2023-11-20 23:46:43 +00:00
Tiago Ribeiro
cdc8cfe46e Updated more of the exam generation 2023-11-20 23:30:47 +00:00
Tiago Ribeiro
4c7e8f56d8 Made it so it is currently possible to generate reading passages 2023-11-20 21:03:24 +00:00
Tiago Ribeiro
4753b85ab5 Started creating the page to generate exams 2023-11-20 16:19:05 +00:00
Tiago Ribeiro
13c8459d4b Updated the assignments to work with the level exams 2023-11-18 00:19:26 +00:00
Tiago Ribeiro
19b3bbe139 Added it to the exam list 2023-11-17 15:45:59 +00:00
Tiago Ribeiro
44a89c6645 Added a new module called Level for level testing 2023-11-17 15:32:45 +00:00
Tiago Ribeiro
4a51bd7dfa Turned off the ExamGenerator for everyone except me 2023-11-16 13:56:37 +00:00
Tiago Ribeiro
dc759a368e Added more lists related to the expired accounts 2023-11-16 13:55:43 +00:00
Tiago Ribeiro
c28f7bb024 Added the ability to change the status and type of a user 2023-11-15 19:54:16 +00:00
Tiago Ribeiro
d412c1616f Updated the expiry date to show as red 2023-11-15 11:17:44 +00:00
Tiago Ribeiro
c2a807efc7 Improved the email verification a tiny bit 2023-11-14 15:57:30 +00:00
Tiago Ribeiro
6056735c72 Added more fields to the corporate and showcased them in the UserCard 2023-11-13 19:27:11 +00:00
Tiago Ribeiro
261ba74105 Removed the exercises and exams tab from the sidebar for owners and corporate 2023-11-13 14:43:11 +00:00
Tiago Ribeiro
4328a1d72d Made it so a corporate user is not able to generate more code than they are allowed to 2023-11-10 15:41:26 +00:00
Tiago Ribeiro
82643b51d3 Updated the user card to have the corporate information 2023-11-10 15:27:03 +00:00
Tiago Ribeiro
a38e5e2f0a Removed the console.log 2023-11-09 14:56:40 +00:00
Tiago Ribeiro
19624e97bd Improved the way a teacher views the assignments 2023-11-09 12:34:56 +00:00
Tiago Ribeiro
536c1dfab3 Enabled a way for students to do assigned tasks 2023-11-09 11:44:58 +00:00
Tiago Ribeiro
c2acb39859 Improved the responsiveness of the assignment creator 2023-11-07 22:44:26 +00:00
Tiago Ribeiro
dd2ddc0e5b Finished up a wizard to create Assignments 2023-11-07 22:30:46 +00:00
Tiago Ribeiro
40f095191a Created a simple "assignment" dashboard for teachers 2023-11-05 14:53:54 +00:00
Tiago Ribeiro
37baa11987 Merge branch 'main' into feature/corporate-model-implementation 2023-10-30 15:35:27 +00:00
Tiago Ribeiro
3ecc2b7982 Removed the port from the mailer 2023-10-30 15:34:45 +00:00
Tiago Ribeiro
ba3588e97d Updated the groups section for the teachers and admins 2023-10-30 15:27:48 +00:00
Tiago Ribeiro
bd6892dcf1 Created a dashboard for teachers 2023-10-30 15:01:58 +00:00
Tiago Ribeiro
dc13a4a7b7 Updated the Dashboard for Corporate accounts 2023-10-30 14:29:41 +00:00
Tiago Ribeiro
37c889a39a Merge branch 'main' into feature/corporate-model-implementation 2023-10-29 14:39:58 +00:00
Tiago Ribeiro
c5ece3a5f8 Updated the SMTP port 2023-10-29 14:39:16 +00:00
Tiago Ribeiro
a20b980adb Added the ability for Corporate accounts to register without codes 2023-10-29 14:38:46 +00:00
Tiago Ribeiro
6e31a05f21 Updated the register to allow the difference between a individual and corporate 2023-10-27 15:50:02 +01:00
Tiago Ribeiro
0aefbb85ec Renamed the admin type to corporate 2023-10-27 00:43:05 +01:00
Tiago Ribeiro
15f8d25bc9 Improved the overall code itself 2023-10-27 00:35:56 +01:00
Tiago Ribeiro
c0269fca45 Added the ability to view how many students and teachers an admin has 2023-10-27 00:28:29 +01:00
Tiago Ribeiro
bdb0ffde95 - Added more panels and lists;
- Added the ability to view more information on the user;
- Added the ability to update the user's expiry date
2023-10-26 22:41:24 +01:00
Tiago Ribeiro
8515eaf4ee Merge branch 'develop' into feature/updated-user-type-dashboard 2023-10-24 16:59:40 +01:00
Tiago Ribeiro
3bb27a692f Removed the console.log calls 2023-10-24 16:58:44 +01:00
Tiago Ribeiro
3528eb227e Merge branch 'develop' into feature/updated-user-type-dashboard 2023-10-24 14:47:46 +01:00
Tiago Ribeiro
729204a095 May have solved a bug made in the writing and speaking evaluation 2023-10-24 14:47:01 +01:00
Tiago Ribeiro
cf5a9c9780 Resolved a small bug 2023-10-23 23:27:35 +01:00
Tiago Ribeiro
8b872020c6 Continued with the same updates 2023-10-23 23:17:47 +01:00
Tiago Ribeiro
e16a9873be Merge branch 'develop' into feature/updated-user-type-dashboard 2023-10-22 09:42:45 +01:00
Tiago Ribeiro
faced0b20c Replaced the previous e-mail verification process for our own 2023-10-22 09:38:29 +01:00
Tiago Ribeiro
7e576738ce Started creating a user type specific dashboard 2023-10-22 09:15:11 +01:00
Tiago Ribeiro
e10aebf4c0 Improved a bit of the evaluation system 2023-10-22 09:13:25 +01:00
Tiago Ribeiro
9f9e36f0cd Added a Husky pre-commit to always build before a commit 2023-10-21 16:51:55 +01:00
Tiago Ribeiro
913ed54cf9 Keep calm and commit 2023-10-21 16:51:25 +01:00
Tiago Ribeiro
960c5b8c6f Added the possibility to have multiple dashboards 2023-10-21 16:49:41 +01:00
Tiago Ribeiro
57f2135848 Improved the responsiveness of the application for tablet as well 2023-10-19 10:19:33 +01:00
Tiago Ribeiro
171f328278 Added the ability to sort by every column 2023-10-19 09:40:52 +01:00
Tiago Ribeiro
ffe534edd9 Solved a problem with the build process 2023-10-17 23:25:16 +01:00
Tiago Ribeiro
fb15668288 Updated the mobile responsiveness for Diagnostic and Demographic 2023-10-17 23:22:28 +01:00
Tiago Ribeiro
b00d155aa1 Updated the responsiveness of the profile page for mobile 2023-10-17 22:51:12 +01:00
Tiago Ribeiro
9b852bd6be - Did a bit of refactor related to the exam/exercises page;
- Improved the responsiveness of the page for mobile;
2023-10-17 08:52:41 +01:00
Tiago Ribeiro
550cdba5a7 Started improving the responsiveness of the application 2023-10-16 23:52:41 +01:00
Tiago Ribeiro
aaa7d6deb3 Changed to platform 2023-10-16 18:56:42 +01:00
Tiago Ribeiro
e2567e128e Changed from encoach.com to app.encoach.com 2023-10-16 18:55:55 +01:00
Tiago Ribeiro
5c11087cec Oops 2023-10-16 00:34:27 +01:00
Tiago Ribeiro
635c92791c Added the timeSpent to the stats 2023-10-16 00:18:45 +01:00
Tiago Ribeiro
e8b44ee10e Updated the CountrySelect 2023-10-15 23:40:25 +01:00
Tiago Ribeiro
932a2e4081 Updated the look of the stats page 2023-10-15 23:23:46 +01:00
Tiago Ribeiro
11777b1bea Solved a bug on the stats page 2023-10-15 23:05:02 +01:00
Tiago Ribeiro
69425d0b93 Updated the ExamList to only appear to developers 2023-10-15 23:03:54 +01:00
Tiago Ribeiro
1895ffb5c3 Updated the text of the about exams/exercises 2023-10-15 22:57:23 +01:00
Tiago Ribeiro
f51dc450b9 Updated the counter of exercises 2023-10-15 22:54:40 +01:00
Tiago Ribeiro
18e12db7c5 Made it possible to add time to a specific account as well 2023-10-14 09:49:10 +01:00
Tiago Ribeiro
ebb6bb2a1a Updated the Stripe webhook to work better 2023-10-13 15:22:01 +01:00
Tiago Ribeiro
348a020e7f Solved issues related to the build 2023-10-13 13:53:18 +01:00
Tiago Ribeiro
ca96b37303 Created a route for the Stripe webhook 2023-10-13 13:33:58 +01:00
Tiago Ribeiro
1a255b5a4d Updated the User list to also show their demographic information 2023-10-12 23:13:37 +01:00
Tiago Ribeiro
320aedefb1 Improved the screen for when a user's subscription has expired, or their account was disabled 2023-10-12 20:51:09 +01:00
Tiago Ribeiro
da135d3e6f Added the ability to view the expiry date on the profile page (as well as some warnings on the dashboard) 2023-10-12 10:17:22 +01:00
Tiago Ribeiro
4c95d85cf9 Made sure the user can’t navigate to another page when they shouldn’t via the URL 2023-10-12 10:00:44 +01:00
Tiago Ribeiro
1d27da71ec Updated the user list to only show users belonging to an admin's groups 2023-10-11 12:02:20 +01:00
Tiago Ribeiro
a84edcd237 - Added the option to select an expiry date when an owner or dev creates a code
- Made it so the student's expiry date is the same as the admin when created by one
2023-10-11 11:20:28 +01:00
Tiago Ribeiro
25ce3bdf8f Removed the MTI's name from the headers 2023-10-11 08:06:49 +01:00
Tiago Ribeiro
634a396434 New feature on the account creation:
It automatically stores who created the code and adds the registered user to a group administrated by that creator
2023-10-10 23:00:36 +01:00
Tiago Ribeiro
1aa4f0ddfd Updated a bit more of the way the codes work 2023-10-10 21:17:46 +01:00
Tiago Ribeiro
0c9a49a9c3 Solved an oopsie 2023-10-09 22:38:09 +01:00
Tiago Ribeiro
cb3790dc1d Removed the DatePicker from the stats 2023-10-09 11:06:26 +01:00
Tiago Ribeiro
d4c4546c88 Updated some bugs 2023-10-09 08:53:41 +01:00
Tiago Ribeiro
ebe4c41f76 Updated a bit more of the evaluation 2023-10-07 10:18:51 +01:00
Tiago Ribeiro
5e1b9ce2c7 Updated the Speaking exam to work with always having video 2023-10-07 10:17:09 +01:00
Tiago Ribeiro
2d095316a7 Fixed a bug in the Listening exam 2023-10-05 18:07:39 +01:00
Tiago Ribeiro
73d3922f18 Merge branch 'main' into update-listening-format 2023-10-04 14:05:04 +01:00
Tiago Ribeiro
925250d2c5 Added the ability to enable/disable a user as well as deleting an exam 2023-10-04 13:39:31 +01:00
Tiago Ribeiro
29914d3e89 Updated the register to only allow to create users if they have a code available 2023-10-03 23:53:54 +01:00
Tiago Ribeiro
1ccb9555b6 Made it so, when using the batch code generator, it sends e-mails to everyone 2023-10-03 20:24:34 +01:00
Tiago Ribeiro
07e73b0d88 Added the possibility to upload a file containing users to a group 2023-10-03 16:48:52 +01:00
Tiago Ribeiro
9239068cde Updated the stats page a bit more 2023-10-02 14:16:34 +01:00
Tiago Ribeiro
0f0b7748d7 Updated the stats and record page 2023-10-02 10:23:33 +01:00
Tiago Ribeiro
551faadd28 Merge branch 'main' into update-listening-format 2023-10-01 22:09:59 +01:00
Tiago Ribeiro
b58957e38e Added the ability to edit a group 2023-10-01 21:15:15 +01:00
Tiago Ribeiro
782976c14f Updated the Listening format to work 2023-09-29 13:56:11 +01:00
Tiago Ribeiro
2a68d37de8 Updated the way this is calculated 2023-09-28 14:51:24 +01:00
Tiago Ribeiro
a47ee28ca5 Updated the backend to work 2023-09-28 14:45:42 +01:00
Tiago Ribeiro
169ae2c959 Updated the reading to a new format 2023-09-28 14:43:43 +01:00
Tiago Ribeiro
a568950aa9 Solved a problem with the build 2023-09-28 12:00:36 +01:00
Tiago Ribeiro
f9cd477114 Removed unused reports 2023-09-28 11:46:41 +01:00
Tiago Ribeiro
75fb6ab197 Added the ability to create groups 2023-09-28 11:40:01 +01:00
Tiago Ribeiro
7af607d476 Prevented the stats page from crashing when there are no stats 2023-09-28 00:16:43 +01:00
Tiago Ribeiro
41040f92c3 Updated the layout leave to a reload 2023-09-27 11:05:23 +01:00
Tiago Ribeiro
9dbf43cf22 Added the admin panel to other users apart from developer 2023-09-27 10:03:28 +01:00
Tiago Ribeiro
4873832437 Solved a bug on repeated questions 2023-09-27 09:58:53 +01:00
Tiago Ribeiro
a362dc5a11 Made it so when a user is deleted, all of their stats are also deleted 2023-09-26 18:13:27 +01:00
Tiago Ribeiro
f3fea7fc66 Updated the sizing of the select 2023-09-26 16:35:13 +01:00
Tiago Ribeiro
64e7fcbcc7 Added the ability to delete a user 2023-09-26 13:51:41 +01:00
Tiago Ribeiro
733138f2be Added more options to the User List 2023-09-26 13:23:53 +01:00
Tiago Ribeiro
b0a11a5f8d Reduced the side of the sidebar a little bit 2023-09-26 12:15:17 +01:00
Tiago Ribeiro
8fb1d8e886 Continued updating the e-mail verification and I think I managed to get it working 2023-09-26 11:28:10 +01:00
Tiago Ribeiro
3491efb494 Created the possibility to delete a user 2023-09-26 07:26:01 +01:00
Tiago Ribeiro
5564b4c181 Added console.log 2023-09-25 23:39:38 +01:00
Tiago Ribeiro
7d4d228f0d Removed a forgotten alert 2023-09-25 14:57:39 +01:00
Tiago Ribeiro
8b7e7cf0ad Improved an error we had before 2023-09-25 14:57:14 +01:00
Tiago Ribeiro
cb5434d166 Added a "trim" to the verification 2023-09-25 11:37:50 +01:00
Tiago Ribeiro
8b51e50f15 Solving a Listening bug 2023-09-25 10:46:04 +01:00
Tiago Ribeiro
6dda49a917 Added the ability to view all exams 2023-09-23 13:34:14 +01:00
Tiago Ribeiro
7a957e4d78 Extracted the Admin Panel items 2023-09-22 14:13:57 +01:00
Tiago Ribeiro
a9ceecdc84 Added the ability to generate a user code on the admin panel 2023-09-22 13:39:26 +01:00
Tiago Ribeiro
d46d0ab42f Added the ability for a user to select to avoid repeated exams 2023-09-21 23:43:48 +01:00
Tiago Ribeiro
1bac6eb110 Updated the nomenclature of the ModuleTitle 2023-09-21 23:25:17 +01:00
Tiago Ribeiro
1e4316a57e Updated the verification 2023-09-21 23:17:50 +01:00
Tiago Ribeiro
9a45f53062 Made it so, in the FillBlanks, it automatically goes to the next one 2023-09-20 09:57:28 +01:00
Tiago Ribeiro
0ca2649040 Improved the Country selector 2023-09-19 21:58:03 +01:00
Tiago Ribeiro
395afbb4ee Testing things out 2023-09-19 20:37:40 +01:00
Tiago Ribeiro
7cdff84d5e Created the InteractiveSpeaking exercise 2023-09-19 00:57:36 +01:00
Tiago Ribeiro
f5de8f5e10 Made it so the side bar is minimized after refresh if it was before 2023-09-18 19:55:37 +01:00
Tiago Ribeiro
4d364bd597 Updated the speaking evaluation to use the new endpoint 2023-09-18 13:17:33 +01:00
Tiago Ribeiro
3e010572f6 Made it so the component reloads 2023-09-18 08:19:08 +01:00
Tiago Ribeiro
68fb5e5bc7 Updated a problem with the rendering of the Solutions 2023-09-18 08:16:56 +01:00
Tiago Ribeiro
efb341355d Prepared the code to later handle the evaluation of the Interactive Speaking exercise 2023-09-17 08:56:00 +01:00
Tiago Ribeiro
161d5236b4 Oops 2023-09-17 08:48:24 +01:00
Tiago Ribeiro
91495d6a34 Created an Admin panel for developers 2023-09-16 15:00:48 +01:00
Tiago Ribeiro
05ca96e476 Updated the demographic input to work more as expected 2023-09-16 10:27:17 +01:00
Tiago Ribeiro
dc8682e1c3 Updated the problems with the marking - related to the DB not having the correct type 2023-09-13 23:46:50 +01:00
Tiago Ribeiro
3a51185942 The country was not working 2023-09-13 11:31:21 +01:00
Tiago Ribeiro
39a2813bde Stopped the isLoading after updating the profile 2023-09-13 11:29:38 +01:00
Tiago Ribeiro
27c6eff590 Added demographic information to the user 2023-09-13 11:28:13 +01:00
Tiago Ribeiro
8dcfb8a670 Removed the ability to pause the listening 2023-09-13 07:40:20 +01:00
Tiago Ribeiro
8cbf56b81a Updated the console.logs 2023-09-12 09:25:03 +01:00
Tiago Ribeiro
27ff2bd158 Removed console.logs used for testing 2023-09-11 09:06:20 +01:00
Tiago Ribeiro
dadaa831ba Improved the duration display on Listening 2023-09-11 09:06:01 +01:00
Tiago Ribeiro
27956d311c Solved the WriteBlanks problem 2023-09-11 08:50:38 +01:00
Tiago Ribeiro
cca94db9bf Added a word counter to the Writing exercise 2023-09-10 10:33:07 +01:00
Tiago Ribeiro
42a0821677 Removed unused console.log 2023-09-10 10:05:29 +01:00
Tiago Ribeiro
9de62cc55f Update the mode of the action page 2023-09-08 09:33:53 +01:00
Tiago Ribeiro
af70d36b7c Increased the size of the Timer 2023-09-07 21:26:02 +01:00
Tiago Ribeiro
b1461d1c04 Removed unused console.log 2023-09-07 13:06:39 +01:00
Tiago Ribeiro
f91cd0ca63 Made it so users have to verify their e-mail starting to use the application 2023-09-07 12:45:31 +01:00
Tiago Ribeiro
5211e92c65 Added a few more stats to the stats page 2023-09-05 16:31:32 +01:00
Tiago Ribeiro
af994cfadb Made it so the when it is showing the solutions, it automatically starts 2023-09-05 14:21:55 +01:00
Tiago Ribeiro
caddc57ef8 Took care of an oopsie 2023-09-03 20:50:24 +01:00
Tiago Ribeiro
578f42d9b1 Improved the overall sizing of the Reading exam 2023-09-03 20:47:24 +01:00
Tiago Ribeiro
1a0ec780b9 Disabled the right click, paste and spell check on the Writing 2023-09-03 20:34:09 +01:00
Tiago Ribeiro
10b2f09c7f Solved a few bugs on the WriteBlanks module 2023-09-03 15:06:56 +01:00
Tiago Ribeiro
5263cc260d Added a capability to minimize the sidebar 2023-09-03 14:41:10 +01:00
Tiago Ribeiro
0013d86ef8 Added margin bottom to the Finish 2023-09-03 13:20:29 +01:00
Tiago Ribeiro
41d6303403 Implemented the reset password mechanism 2023-08-31 21:48:02 +01:00
Tiago Ribeiro
f2323b35b8 Turned the reading into split screen 2023-08-31 20:53:08 +01:00
Tiago Ribeiro
b3b804fc11 - Prevent the CTRL+F on Reading;
- Made the Listening audio appear on exercises;
2023-08-31 20:39:38 +01:00
Tiago Ribeiro
7dd96bf259 Solved the bug where the user solutions were not loading 2023-08-31 12:19:09 +01:00
Tiago Ribeiro
c2fb398707 Removed an alert that was not supposed to be there 2023-08-31 11:16:31 +01:00
Tiago Ribeiro
f4b0d6822d Bug fixing:
- Prevented the viewing of the exam solutions to generate another record;
- Made it so the solution is the same after viewing the results;
2023-08-31 10:47:43 +01:00
Tiago Ribeiro
511c30d635 Reverted previous commit 2023-08-31 09:56:06 +01:00
Tiago Ribeiro
084d19600a Updated the Dockerfile 2023-08-31 09:12:58 +01:00
Tiago Ribeiro
b75a0be52c Added a log for when there is an error 2023-08-31 09:05:15 +01:00
Tiago Ribeiro
c23c07ae38 Removed the uploadDir 2023-08-31 08:35:21 +01:00
Tiago Ribeiro
17ff85b62b Reverted the previous commit 2023-08-30 19:51:06 +01:00
Tiago Ribeiro
84a42d0e14 Removed the uploadDir from the speaking evaluation API 2023-08-30 12:16:37 +01:00
Tiago Ribeiro
437c405c74 Removed the abandon popup when not in exam mode 2023-08-28 22:38:38 +01:00
Tiago Ribeiro
b4d856d32f Improved a bit more of the responsiveness and solved a bug 2023-08-28 16:25:01 +01:00
Tiago Ribeiro
3c711e0279 Improved the responsiveness of the login and register pages on mobile 2023-08-28 15:47:04 +01:00
Tiago Ribeiro
c879d5d8de Made it a tiny bit more responsive 2023-08-24 15:21:35 +01:00
Tiago Ribeiro
82d2c548ef Updated the icon on the register 2023-08-24 10:18:01 +01:00
Tiago Ribeiro
cdb4f329cf Updated the page icon 2023-08-24 10:15:58 +01:00
Tiago Ribeiro
c91264e455 Removed the build problem 2023-08-22 23:25:17 +01:00
Tiago Ribeiro
14a719b8b5 - Solved the bug on Diagnostic where the exams weren't loading;
- Removed the Layout appearance and made it so the abandon popup appears on click and not on enter
2023-08-22 23:13:26 +01:00
Joao Ramos
78c5b7027e Added abandon exam/exercise handler 2023-08-16 19:32:39 +01:00
Joao Ramos
cd71cf4833 Added abandon popup 2023-08-16 00:38:54 +01:00
Joao Ramos
93a5bcf40f Added initial focus trap during exercises/exams 2023-08-16 00:08:20 +01:00
Tiago Ribeiro
dd0acbea61 Added more onClicks 2023-08-13 21:50:12 +01:00
Tiago Ribeiro
ef736bc63e Resolved the Questions Blank bug 2023-08-12 00:03:35 +01:00
Tiago Ribeiro
d9ca0e84a6 Some light bug solving 2023-08-11 23:54:09 +01:00
Tiago Ribeiro
db54d58bab - Added a new type of exercise
- Updated all solutions to solve a huge bug where after reviewing, it would reset the score
2023-08-11 14:23:09 +01:00
Tiago Ribeiro
5099721b9b Finished up the Diagnostic page 2023-08-08 00:06:01 +01:00
Tiago Ribeiro
2c2fbffd8c Well, removed unused thingy 2023-08-07 23:48:48 +01:00
Tiago Ribeiro
3fee4292f1 Updated the Writing to work with another format 2023-08-07 23:39:29 +01:00
Tiago Ribeiro
7e9e28f134 Updated the styling of the Diagnostic page 2023-08-07 22:52:10 +01:00
Tiago Ribeiro
d879f4afab Made it so the timer is more dynamic 2023-07-27 20:25:57 +01:00
Tiago Ribeiro
d38ca76182 Seems to have solved some other issues 2023-07-27 15:41:34 +01:00
Tiago Ribeiro
77692d270e Made it so when the timer ends, the module ends 2023-07-27 13:59:00 +01:00
Tiago Ribeiro
f5c3abb310 Fully implemented the register flow 2023-07-25 19:53:48 +01:00
Tiago Ribeiro
02260d496c Solved some more bugs and styling 2023-07-25 00:09:25 +01:00
Tiago Ribeiro
581adbb56e - Updated the colors of the application;
- Added the ability for a user to partially update their profile
2023-07-22 10:11:10 +01:00
Tiago Ribeiro
6ade34d243 Updated the platform colors to the new ones 2023-07-22 07:18:28 +01:00
Tiago Ribeiro
16ea0b497e Ooopsy 2023-07-21 13:42:07 +01:00
Tiago Ribeiro
ea41875e36 Updated the Formidable to work with serverless (supposedly) 2023-07-21 13:37:41 +01:00
Tiago Ribeiro
eae0a4ae4e Updated the clock of the Speaking timer 2023-07-21 12:41:44 +01:00
Tiago Ribeiro
fea788bdc4 Updated the next.config.js 2023-07-21 10:43:15 +01:00
Tiago Ribeiro
86c69e5993 Removed the --link flag 2023-07-21 10:30:14 +01:00
Tiago Ribeiro
f01794fed8 Updated the Dockerfile 2023-07-21 10:29:06 +01:00
Tiago Ribeiro
cc4b38fbbd Added Docker support to the application 2023-07-21 10:17:38 +01:00
Tiago Ribeiro
121ac8ba4d Finallyyyyyy finished the whole Speaking flow along with the solution page 2023-07-14 14:15:07 +01:00
Tiago Ribeiro
2c10a203a5 Finalized the Speaking module exercise 2023-07-14 12:08:25 +01:00
Tiago Ribeiro
6a2fab4f88 Commented a bit of code that is not yet ready 2023-07-11 00:30:05 +01:00
Tiago Ribeiro
9637cb6477 Made it so the Speaking is sent to the backend and saved to Firebase 2023-07-11 00:29:32 +01:00
Tiago Ribeiro
ce90de1b74 Updated the code so the user levels update depending on their performance 2023-07-04 21:03:36 +01:00
Tiago Ribeiro
49e24865a3 Created a profile editing page 2023-07-04 13:21:36 +01:00
Tiago Ribeiro
dceff807e9 Added the ability to read the text on Reading exercises 2023-06-30 21:18:13 +01:00
Tiago Ribeiro
3c4dba69db Added a confirmation dialog for when the user leaves questions unanswered 2023-06-29 15:28:50 +01:00
Tiago Ribeiro
3fac92b54d Added the exercises page which will work as the current exam page, while the exam page will mandatorily be the full exam 2023-06-29 00:18:39 +01:00
Tiago Ribeiro
139f527fdd Added the ability to filter by month, week and day on the record 2023-06-29 00:08:48 +01:00
185 changed files with 17426 additions and 3438 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git

2
.gitignore vendored
View File

@@ -37,3 +37,5 @@ next-env.d.ts
.env
.yarn/*
.history*
__ENV.js

4
.husky/pre-commit Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
yarn build

57
Dockerfile Normal file
View File

@@ -0,0 +1,57 @@
#syntax=docker/dockerfile:1.4
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* ./
RUN yarn --frozen-lockfile
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN yarn build
# If using npm comment out above and use below instead
# RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
ENV NEXT_TELEMETRY_DISABLED 1
RUN \
addgroup --system --gid 1001 nodejs; \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=1001:1001 /app/.next/standalone ./
COPY --from=builder --chown=1001:1001 /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME localhost
CMD ["node", "server.js"]

View File

@@ -1,6 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
}
output: "standalone",
};
module.exports = nextConfig
module.exports = nextConfig;

810
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,54 +6,86 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"prepare": "husky install"
},
"dependencies": {
"@beam-australia/react-env": "^3.1.1",
"@headlessui/react": "^1.7.13",
"@mdi/js": "^7.1.96",
"@mdi/react": "^1.6.1",
"@next/font": "13.1.6",
"@paypal/paypal-js": "^7.1.0",
"@paypal/react-paypal-js": "^8.1.3",
"@tanstack/react-table": "^8.10.1",
"@types/node": "18.13.0",
"@types/react": "18.0.27",
"@types/react-dom": "18.0.10",
"axios": "^1.3.5",
"bcrypt": "^5.1.1",
"chart.js": "^4.2.1",
"clsx": "^1.2.1",
"countries-list": "^3.0.1",
"country-codes-list": "^1.6.11",
"currency-symbol-map": "^5.1.0",
"daisyui": "^3.1.5",
"eslint": "8.33.0",
"eslint-config-next": "13.1.6",
"express-handlebars": "^7.1.2",
"firebase": "9.19.1",
"firebase-admin": "^11.10.1",
"formidable": "^3.5.0",
"formidable-serverless": "^1.1.1",
"framer-motion": "^9.0.2",
"howler": "^2.2.4",
"iron-session": "^6.3.1",
"lodash": "^4.17.21",
"moment": "^2.29.4",
"next": "13.1.6",
"nodemailer": "^6.9.5",
"nodemailer-express-handlebars": "^6.1.0",
"primeicons": "^6.0.1",
"primereact": "^9.2.3",
"random-words": "^2.0.0",
"react": "18.2.0",
"react-chartjs-2": "^5.2.0",
"react-currency-input-field": "^3.6.12",
"react-datepicker": "^4.18.0",
"react-dom": "18.2.0",
"react-firebase-hooks": "^5.1.1",
"react-icons": "^4.8.0",
"react-lineto": "^3.3.0",
"react-media-recorder": "1.6.5",
"react-phone-number-input": "^3.3.6",
"react-player": "^2.12.0",
"react-select": "^5.7.5",
"react-string-replace": "^1.1.0",
"react-toastify": "^9.1.2",
"react-xarrows": "^2.0.2",
"short-unique-id": "^5.0.2",
"stripe": "^13.10.0",
"swr": "^2.1.3",
"tailwind-scrollbar-hide": "^1.1.7",
"typescript": "4.9.5",
"use-file-picker": "^2.1.0",
"uuid": "^9.0.0",
"wavesurfer.js": "^6.6.4",
"zustand": "^4.3.6"
},
"devDependencies": {
"@types/formidable": "^3.4.0",
"@types/howler": "^2.2.11",
"@types/lodash": "^4.14.191",
"@types/nodemailer": "^6.4.11",
"@types/nodemailer-express-handlebars": "^4.0.3",
"@types/react-datepicker": "^4.15.1",
"@types/uuid": "^9.0.1",
"@types/wavesurfer.js": "^6.0.6",
"@wixc3/react-board": "^2.2.0",
"autoprefixer": "^10.4.13",
"husky": "^8.0.3",
"postcss": "^8.4.21",
"tailwindcss": "^3.2.4"
"tailwindcss": "^3.2.4",
"types/": "paypal/react-paypal-js"
}
}

BIN
public/audio/check.mp3 Normal file

Binary file not shown.

BIN
public/audio/sent.mp3 Normal file

Binary file not shown.

BIN
public/defaultAvatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

BIN
public/logo_title.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -0,0 +1,55 @@
import {Dialog, Transition} from "@headlessui/react";
import {Fragment} from "react";
import Button from "./Low/Button";
interface Props {
isOpen: boolean;
abandonPopupTitle: string;
abandonPopupDescription: string;
abandonConfirmButtonText: string;
onAbandon: () => void;
onCancel: () => void;
}
export default function AbandonPopup({isOpen, abandonPopupTitle, abandonPopupDescription, abandonConfirmButtonText, onAbandon, onCancel}: Props) {
return (
<Transition show={isOpen} as={Fragment}>
<Dialog onClose={onCancel} className="relative z-50">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-black/30" />
</Transition.Child>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95">
<div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="w-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
<Dialog.Title className="font-bold text-xl">{abandonPopupTitle}</Dialog.Title>
<span>{abandonPopupDescription}</span>
<div className="w-full flex justify-between mt-8">
<Button color="purple" onClick={onCancel} variant="outline" className="max-w-[200px] self-end w-full">
Cancel
</Button>
<Button color="purple" onClick={onAbandon} className="max-w-[200px] self-end w-full">
{abandonConfirmButtonText}
</Button>
</div>
</Dialog.Panel>
</div>
</Transition.Child>
</Dialog>
</Transition>
);
}

View File

@@ -0,0 +1,56 @@
import {Dialog, Transition} from "@headlessui/react";
import {Fragment} from "react";
import Button from "./Low/Button";
interface Props {
isOpen: boolean;
onClose: (next?: boolean) => void;
}
export default function BlankQuestionsModal({isOpen, onClose}: Props) {
return (
<Transition show={isOpen} as={Fragment}>
<Dialog onClose={() => onClose(false)} className="relative z-50">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-black/30" />
</Transition.Child>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95">
<div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="w-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
<Dialog.Title className="font-bold text-xl">Questions Unanswered</Dialog.Title>
<span>
Please note that you are finishing the current module and once you proceed to the next module, you will no longer be
able to change the answers in the current one, including your unanswered questions. <br />
<br />
Are you sure you want to continue without completing those questions?
</span>
<div className="w-full flex justify-between mt-8">
<Button color="purple" onClick={() => onClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
Go Back
</Button>
<Button color="purple" onClick={() => onClose(true)} className="max-w-[200px] self-end w-full">
Continue
</Button>
</div>
</Dialog.Panel>
</div>
</Transition.Child>
</Dialog>
</Transition>
);
}

View File

@@ -0,0 +1,63 @@
import clsx from "clsx";
import {IconType} from "react-icons";
import {MdSpaceDashboard} from "react-icons/md";
import {BsFileEarmarkText, BsClockHistory, BsPencil, BsGraphUp} from "react-icons/bs";
import {RiLogoutBoxFill} from "react-icons/ri";
import {SlPencil} from "react-icons/sl";
import {FaAward} from "react-icons/fa";
import Link from "next/link";
import {useRouter} from "next/router";
import axios from "axios";
import FocusLayer from "@/components/FocusLayer";
import {preventNavigation} from "@/utils/navigation.disabled";
interface Props {
path: string;
navDisabled?: boolean;
focusMode?: boolean;
onFocusLayerMouseEnter?: () => void;
className?: string;
}
interface NavProps {
Icon: IconType;
label: string;
path: string;
keyPath: string;
disabled?: boolean;
}
const Nav = ({Icon, label, path, keyPath, disabled = false}: NavProps) => (
<Link
href={!disabled ? keyPath : ""}
className={clsx(
"p-4 rounded-full flex gap-4 items-center cursor-pointer text-gray-500 hover:bg-mti-purple-light hover:text-white transition duration-300 ease-in-out",
path === keyPath && "bg-mti-purple-light text-white",
)}>
<Icon size={20} />
</Link>
);
export default function BottomBar({path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter, className}: Props) {
const router = useRouter();
const logout = async () => {
axios.post("/api/logout").finally(() => {
setTimeout(() => router.reload(), 500);
});
};
const disableNavigation = preventNavigation(navDisabled, focusMode);
return (
<section className={clsx("w-full bg-white py-2 drop-shadow-2xl shadow-2xl rounded-t-2xl", className)}>
<div className="flex justify-around gap-3">
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" />
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" />
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" />
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" />
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" />
</div>
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
</section>
);
}

View File

@@ -0,0 +1,172 @@
import {EmploymentStatus, EMPLOYMENT_STATUS, Gender, User} from "@/interfaces/user";
import {FormEvent, useState} from "react";
import countryCodes from "country-codes-list";
import {RadioGroup} from "@headlessui/react";
import Input from "./Low/Input";
import clsx from "clsx";
import Button from "./Low/Button";
import {BsArrowRepeat} from "react-icons/bs";
import axios from "axios";
import {toast} from "react-toastify";
import {KeyedMutator} from "swr";
import CountrySelect from "./Low/CountrySelect";
interface Props {
user: User;
mutateUser: KeyedMutator<User>;
}
export default function DemographicInformationInput({user, mutateUser}: Props) {
const [country, setCountry] = useState<string>();
const [phone, setPhone] = useState<string>();
const [gender, setGender] = useState<Gender>();
const [employment, setEmployment] = useState<EmploymentStatus>();
const [position, setPosition] = useState<string>();
const [isLoading, setIsLoading] = useState(false);
const [companyName, setCompanyName] = useState<string>();
const [commercialRegistration, setCommercialRegistration] = useState<string>();
const save = (e?: FormEvent) => {
if (e) e.preventDefault();
setIsLoading(true);
axios
.patch("/api/users/update", {
demographicInformation: {
country,
phone: `+${countryCodes.findOne("countryCode" as any, country!).countryCallingCode}${phone}`,
gender,
employment: user.type === "corporate" ? undefined : employment,
position: user.type === "corporate" ? position : undefined,
},
agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined,
})
.then((response) => mutateUser((response.data as {user: User}).user))
.catch(() => {
toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"});
})
.finally(() => setIsLoading(false));
};
return (
<div className="flex flex-col items-center justify-center gap-12 w-full">
<h2 className="font-semibold text-center text-xl max-w-[800px]">
Welcome to EnCoach, the ultimate platform dedicated to helping you master the IELTS ! We are thrilled that you have chosen us as your
learning companion on this journey towards achieving your desired IELTS score.
<br />
<br />
To make the most of your learning experience, we kindly request you to complete your profile. By providing some essential information
about yourself.
</h2>
<form className="flex flex-col items-center justify-items-center gap-6 w-full h-full -md:px-4 lg:w-1/2 mb-32" onSubmit={save}>
{user.type === "agent" && (
<div className="w-full flex gap-8">
<Input type="text" onChange={setCompanyName} name="companyName" label="Company Name" required />
<Input
type="text"
onChange={setCommercialRegistration}
name="commercialRegistration"
label="Commercial Registration"
required
/>
</div>
)}
<div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
<CountrySelect value={country} onChange={setCountry} />
</div>
<Input type="tel" name="phone" label="Phone number" onChange={(e) => setPhone(e)} placeholder="Enter phone number" required />
<div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Gender *</label>
<RadioGroup value={gender} onChange={setGender} className="flex flex-row justify-between">
<RadioGroup.Option value="male">
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
Male
</span>
)}
</RadioGroup.Option>
<RadioGroup.Option value="female">
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
Female
</span>
)}
</RadioGroup.Option>
<RadioGroup.Option value="other">
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
Other
</span>
)}
</RadioGroup.Option>
</RadioGroup>
</div>
{user.type === "corporate" && (
<Input name="position" onChange={setPosition} type="text" label="Position" placeholder="CEO, Head of Marketing..." required />
)}
{user.type !== "corporate" && (
<div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Employment Status *</label>
<RadioGroup value={employment} onChange={setEmployment} className="grid grid-cols-2 items-center gap-4 place-items-center">
{EMPLOYMENT_STATUS.map(({status, label}) => (
<RadioGroup.Option value={status} key={status}>
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-44 md:w-48 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked
? "bg-white border-mti-gray-platinum"
: "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
{label}
</span>
)}
</RadioGroup.Option>
))}
</RadioGroup>
</div>
)}
</form>
<div className="self-end flex justify-end w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
className="lg:mt-8 max-w-[400px] w-full self-end"
color="purple"
onClick={save}
disabled={
isLoading ||
!country ||
!phone ||
!gender ||
(user.type === "corporate" ? !position : !employment) ||
(user.type === "agent" ? !companyName || !commercialRegistration : false)
}>
{!isLoading && "Save information"}
{isLoading && (
<div className="flex items-center justify-center">
<BsArrowRepeat className="text-white animate-spin" size={25} />
</div>
)}
</Button>
</div>
</div>
);
}

View File

@@ -3,30 +3,27 @@ import {BAND_SCORES} from "@/constants/ielts";
import {Module} from "@/interfaces";
import {User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams";
import {getExam, getExamById} from "@/utils/exams";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {writingMarking} from "@/utils/score";
import {Menu} from "@headlessui/react";
import axios from "axios";
import clsx from "clsx";
import {capitalize} from "lodash";
import {useRouter} from "next/router";
import {useState} from "react";
import {useEffect, useState} from "react";
import {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen, BsQuestionSquare} from "react-icons/bs";
import {toast} from "react-toastify";
import Button from "./Low/Button";
interface Props {
user: User;
onFinish: () => void;
}
const DIAGNOSTIC_EXAMS = [
["reading", "CurQtQoxWmHaJHeN0JW2"],
["listening", "Y6cMao8kUcVnPQOo6teV"],
["writing", "hbueuDaEZXV37EW7I12A"],
["speaking", "QVFm4pdcziJQZN2iUTDo"],
];
export default function Diagnostic({onFinish}: Props) {
const [focus, setFocus] = useState<"academic" | "general">();
const [isInsert, setIsInsert] = useState(false);
const [levels, setLevels] = useState({reading: 0, listening: 0, writing: 0, speaking: 0});
const [levels, setLevels] = useState({reading: -1, listening: -1, writing: -1, speaking: -1});
const [desiredLevels, setDesiredLevels] = useState({reading: 9, listening: 9, writing: 9, speaking: 9});
const router = useRouter();
@@ -34,86 +31,200 @@ export default function Diagnostic({onFinish}: Props) {
const setExams = useExamStore((state) => state.setExams);
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const isNextDisabled = () => {
if (!focus) return true;
return Object.values(levels).includes(-1);
};
const selectExam = () => {
const examPromises = DIAGNOSTIC_EXAMS.map((exam) => getExamById(exam[0] as Module, exam[1]));
const examPromises = MODULE_ARRAY.map((module) => getExam(module, true));
Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) {
setExams(exams.map((x) => x!));
setSelectedModules(exams.map((x) => x!.module));
router.push("/exam");
router.push("/exercises");
}
});
};
const updateUser = (callback: () => void) => {
axios
.patch("/api/users/update", {focus, levels, desiredLevels, isFirstLogin: false})
.patch("/api/users/update", {
focus,
levels: Object.values(levels).includes(-1) ? {reading: 0, listening: 0, writing: 0, speaking: 0} : levels,
desiredLevels,
isFirstLogin: false,
})
.then(callback)
.catch(() => {
toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"});
});
};
if (!focus) {
return (
<div className="bg-white p-16 rounded-2xl flex flex-col items-center justify-center gap-8 h-96 relative shadow-md">
<h2 className="absolute top-8 font-semibold text-xl">What is your focus?</h2>
<div className="flex flex-col gap-4 justify-self-stretch">
<button onClick={() => setFocus("academic")} className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}>
<div className="flex flex-col items-center justify-center gap-12 w-full">
<div className="flex flex-col items-center justify-center gap-8 w-full">
<h2 className="font-semibold text-xl">What is your current focus?</h2>
<div className="flex flex-col gap-16 w-full">
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-16">
<button
onClick={() => setFocus("academic")}
className={clsx(
"w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-center items-center gap-12 bg-white",
"hover:bg-mti-purple-light hover:text-white",
focus === "academic" && "!bg-mti-purple-light !text-white",
"transition duration-300 ease-in-out",
)}>
Academic
</button>
<button onClick={() => setFocus("general")} className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}>
<button
onClick={() => setFocus("general")}
className={clsx(
"w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-center items-center gap-12 bg-white",
"hover:bg-mti-purple-light hover:text-white",
focus === "general" && "!bg-mti-purple-light !text-white",
"transition duration-300 ease-in-out",
)}>
General
</button>
</div>
</div>
);
}
if (isInsert) {
return (
<div className="bg-white p-16 rounded-2xl flex flex-col items-center justify-center gap-8 shadow-md">
<h2 className="font-semibold text-xl">What is your level?</h2>
<div className="flex w-full flex-col gap-4 justify-self-stretch">
{Object.keys(levels).map((module) => (
<div key={module} className="flex items-center gap-4 justify-between">
<span className="font-medium text-lg">{capitalize(module)}</span>
<input
type="number"
className={clsx(
"input input-bordered bg-white w-24",
!BAND_SCORES[module as Module].includes(levels[module as keyof typeof levels]) && "input-error",
)}
value={levels[module as keyof typeof levels]}
min={0}
max={9}
step={0.5}
onChange={(e) => setLevels((prev) => ({...prev, [module]: parseFloat(e.target.value)}))}
/>
</div>
<div className="flex flex-col items-center justify-center gap-8 w-full">
<h2 className="font-semibold text-xl">What is your current IELTS level?</h2>
<div className="flex flex-col gap-32 w-full mb-20">
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-16 mb-24">
<div className="w-full flex flex-col gap-3.5 relative">
<span className="text-sm text-mti-gray-dim">
<span className="font-bold">Reading</span> level
</span>
<Menu>
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
<BsBook className="text-ielts-reading" size={34} />
<span className="text-mti-gray-cool text-sm">
{levels.reading === -1 ? "Select your reading level" : `Level ${levels.reading}`}
</span>
<BsChevronDown className="text-mti-gray-cool" size={12} />
</Menu.Button>
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
{Object.values(writingMarking).map((x) => (
<Menu.Item key={x}>
<span
onClick={() => setLevels((prev) => ({...prev, reading: x}))}
className="w-full py-4 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
Level {x}
</span>
</Menu.Item>
))}
</Menu.Items>
</Menu>
</div>
<div className="w-full flex flex-col gap-3.5 relative">
<span className="text-sm text-mti-gray-dim">
<span className="font-bold">Listening</span> level
</span>
<Menu>
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
<BsHeadphones className="text-ielts-listening" size={34} />
<span className="text-mti-gray-cool text-sm">
{levels.listening === -1 ? "Select your listening level" : `Level ${levels.listening}`}
</span>
<BsChevronDown className="text-mti-gray-cool" size={12} />
</Menu.Button>
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
{Object.values(writingMarking).map((x) => (
<Menu.Item key={x}>
<span
onClick={() => setLevels((prev) => ({...prev, listening: x}))}
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
Level {x}
</span>
</Menu.Item>
))}
</Menu.Items>
</Menu>
</div>
<div className="w-full flex flex-col gap-3.5 relative">
<span className="text-sm text-mti-gray-dim">
<span className="font-bold">Writing</span> level
</span>
<Menu>
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
<BsPen className="text-ielts-writing" size={34} />
<span className="text-mti-gray-cool text-sm">
{levels.writing === -1 ? "Select your writing level" : `Level ${levels.writing}`}
</span>
<BsChevronDown className="text-mti-gray-cool" size={12} />
</Menu.Button>
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
{Object.values(writingMarking).map((x) => (
<Menu.Item key={x}>
<span
onClick={() => setLevels((prev) => ({...prev, writing: x}))}
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
Level {x}
</span>
</Menu.Item>
))}
</Menu.Items>
</Menu>
</div>
<div className="w-full flex flex-col gap-3.5 relative">
<span className="text-sm text-mti-gray-dim">
<span className="font-bold">Speaking</span> level
</span>
<Menu>
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
<BsMegaphone className="text-ielts-speaking" size={34} />
<span className="text-mti-gray-cool text-sm">
{levels.speaking === -1 ? "Select your speaking level" : `Level ${levels.speaking}`}
</span>
<BsChevronDown className="text-mti-gray-cool" size={12} />
</Menu.Button>
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
{Object.values(writingMarking).map((x) => (
<Menu.Item key={x}>
<span
onClick={() => setLevels((prev) => ({...prev, speaking: x}))}
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
Level {x}
</span>
</Menu.Item>
))}
</Menu.Items>
</Menu>
</div>
</div>
<div className="md:self-end flex -md:flex-col justify-between w-full gap-8 absolute bottom-8 left-0 px-4 md:px-8">
<div className="w-full tooltip" data-tip="Your screen size is too small to perform a diagnostic test">
<Button
color="purple"
variant="outline"
className="group flex items-center justify-center gap-6 relative md:max-w-[400px] w-full md:hidden"
disabled>
<BsQuestionSquare className="text-mti-purple-light transition duration-300 ease-in-out" size={20} />
<span>Perform diagnostic test instead</span>
</Button>
</div>
<Button
onClick={() => updateUser(selectExam)}
color="purple"
variant="outline"
className="group flex items-center justify-center gap-6 relative md:max-w-[400px] w-full -md:hidden"
disabled={!focus}>
<BsQuestionSquare
className="text-mti-purple-light group-hover:text-white transition duration-300 ease-in-out"
size={20}
onClick={() => updateUser(selectExam)}
/>
<span onClick={() => updateUser(selectExam)}>Perform diagnostic test instead</span>
</Button>
<Button color="purple" className="md:max-w-[400px] w-full" onClick={() => updateUser(onFinish)} disabled={isNextDisabled()}>
Next Step
</Button>
</div>
<button
onClick={() => updateUser(onFinish)}
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
disabled={!Object.keys(levels).every((module) => BAND_SCORES[module as Module].includes(levels[module as keyof typeof levels]))}>
Next
</button>
</div>
);
}
return (
<div className="bg-white p-16 rounded-2xl flex flex-col items-center justify-center gap-8 h-96 relative shadow-md">
<h2 className="absolute top-8 font-semibold text-xl">What is your current IELTS level?</h2>
<div className="flex flex-col gap-4">
<button onClick={() => setIsInsert(true)} className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}>
Insert my IELTS level
</button>
<button onClick={() => updateUser(selectExam)} className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}>
Perform a Diagnosis Test
</button>
</div>
</div>
);

View File

@@ -1,4 +1,5 @@
import {FillBlanksExercise} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import clsx from "clsx";
import {Fragment, useEffect, useState} from "react";
import reactStringReplace from "react-string-replace";
@@ -31,18 +32,18 @@ function WordsDrawer({words, isOpen, blankId, previouslySelectedWord, onCancel,
isOpen ? "visible opacity-100" : "invisible opacity-0",
)}>
<div className="w-full flex gap-2">
<div className="rounded-full w-6 h-6 flex items-center justify-center text-white bg-mti-green-light">{blankId}</div>
<div className="rounded-full w-6 h-6 flex items-center justify-center text-white bg-mti-purple-light">{blankId}</div>
<span> Choose the correct word:</span>
</div>
<div className="grid grid-cols-6 gap-6">
<div className="grid grid-cols-6 gap-6" key="word-array">
{words.map(({word, isDisabled}) => (
<button
key={word}
key={`${word}_${blankId}`}
onClick={() => setSelectedWord((prev) => (prev === word ? undefined : word))}
className={clsx(
"rounded-full py-3 text-center transition duration-300 ease-in-out",
selectedWord === word ? "text-white bg-mti-green-light" : "bg-mti-green-ultralight",
!isDisabled && "hover:text-white hover:bg-mti-green",
selectedWord === word ? "text-white bg-mti-purple-light" : "bg-mti-purple-ultralight",
!isDisabled && "hover:text-white hover:bg-mti-purple",
"disabled:cursor-not-allowed disabled:text-mti-gray-dim",
)}
disabled={isDisabled}>
@@ -51,10 +52,10 @@ function WordsDrawer({words, isOpen, blankId, previouslySelectedWord, onCancel,
))}
</div>
<div className="flex justify-between w-full">
<Button color="green" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
<Button color="purple" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
Back
</Button>
<Button color="green" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
<Button color="purple" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
Confirm
</Button>
</div>
@@ -79,14 +80,24 @@ export default function FillBlanks({
const [currentBlankId, setCurrentBlankId] = useState<string>();
const [isDrawerShowing, setIsDrawerShowing] = useState(false);
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(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = answers.filter((x) => solutions.find((y) => x.id === y.id)?.solution === x.solution.toLowerCase() || false).length;
const missing = total - answers.filter((x) => solutions.find((y) => x.id === y.id)).length;
const correct = answers.filter(
(x) => solutions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false,
).length;
const missing = total - answers.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return {total, correct, missing};
};
@@ -101,10 +112,10 @@ export default function FillBlanks({
return (
<button
className={clsx(
"rounded-full hover:text-white hover:bg-mti-green transition duration-300 ease-in-out my-1",
!userSolution && "w-6 h-6 text-center text-mti-green-light bg-mti-green-ultralight",
currentBlankId === id && "text-white !bg-mti-green-light ",
userSolution && "px-5 py-2 text-center text-white bg-mti-green-light",
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
!userSolution && "w-6 h-6 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-white bg-mti-purple-light",
)}
onClick={() => setCurrentBlankId(id)}>
{userSolution ? userSolution.solution : id}
@@ -120,6 +131,7 @@ export default function FillBlanks({
<div className="flex flex-col gap-4 mt-4 h-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}
@@ -127,6 +139,10 @@ export default function FillBlanks({
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);
}}
/>
@@ -151,7 +167,7 @@ export default function FillBlanks({
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="green"
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] w-full">
@@ -159,7 +175,7 @@ export default function FillBlanks({
</Button>
<Button
color="green"
color="purple"
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next

View File

@@ -0,0 +1,253 @@
import {InteractiveSpeakingExercise} from "@/interfaces/exam";
import {CommonProps} from ".";
import {useEffect, useState} from "react";
import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill} from "react-icons/bs";
import dynamic from "next/dynamic";
import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
ssr: false,
});
export default function InteractiveSpeaking({
id,
title,
text,
type,
prompts,
updateIndex,
onNext,
onBack,
}: InteractiveSpeakingExercise & CommonProps) {
const [recordingDuration, setRecordingDuration] = useState(0);
const [isRecording, setIsRecording] = useState(false);
const [mediaBlob, setMediaBlob] = useState<string>();
const [promptIndex, setPromptIndex] = useState(0);
const [answers, setAnswers] = useState<{prompt: string; blob: string}[]>([]);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
if (updateIndex) updateIndex(promptIndex);
}, [promptIndex, updateIndex]);
useEffect(() => {
if (hasExamEnded) {
onNext({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
useEffect(() => {
let recordingInterval: NodeJS.Timer | undefined = undefined;
if (isRecording) {
recordingInterval = setInterval(() => setRecordingDuration((prev) => prev + 1), 1000);
} else if (recordingInterval) {
clearInterval(recordingInterval);
}
return () => {
if (recordingInterval) clearInterval(recordingInterval);
};
}, [isRecording]);
useEffect(() => {
if (promptIndex === answers.length - 1) {
setMediaBlob(answers[promptIndex].blob);
}
}, [answers, promptIndex]);
const saveAnswer = () => {
const answer = {
prompt: prompts[promptIndex].text,
blob: mediaBlob!,
};
setAnswers((prev) => [...prev, answer]);
setMediaBlob(undefined);
};
return (
<div className="flex flex-col h-full w-full gap-9">
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
<div className="flex flex-col gap-3">
<span className="font-semibold">{title}</span>
</div>
{prompts && prompts.length > 0 && (
<div className="flex flex-col gap-4 w-full items-center">
<video key={promptIndex} autoPlay controls className="max-w-3xl rounded-xl">
<source src={prompts[promptIndex].video_url} />
</video>
</div>
)}
</div>
<ReactMediaRecorder
audio
key={promptIndex}
onStop={(blob) => setMediaBlob(blob)}
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => (
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
<p className="text-base font-normal">Record your answer:</p>
<div className="flex gap-8 items-center justify-center py-8">
{status === "idle" && (
<>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
{status === "idle" && (
<BsMicFill
onClick={() => {
setRecordingDuration(0);
startRecording();
setIsRecording(true);
}}
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
/>
)}
</>
)}
{status === "recording" && (
<>
<div className="flex gap-4 items-center">
<span className="text-xs w-9">
{Math.floor(recordingDuration / 60)
.toString(10)
.padStart(2, "0")}
:
{Math.floor(recordingDuration % 60)
.toString(10)
.padStart(2, "0")}
</span>
</div>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
<div className="flex gap-4 items-center">
<BsPauseCircle
onClick={() => {
setIsRecording(false);
pauseRecording();
}}
className="text-red-500 w-8 h-8 cursor-pointer"
/>
<BsCheckCircleFill
onClick={() => {
setIsRecording(false);
stopRecording();
}}
className="text-mti-purple-light w-8 h-8 cursor-pointer"
/>
</div>
</>
)}
{status === "paused" && (
<>
<div className="flex gap-4 items-center">
<span className="text-xs w-9">
{Math.floor(recordingDuration / 60)
.toString(10)
.padStart(2, "0")}
:
{Math.floor(recordingDuration % 60)
.toString(10)
.padStart(2, "0")}
</span>
</div>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
<div className="flex gap-4 items-center">
<BsPlayCircle
onClick={() => {
setIsRecording(true);
resumeRecording();
}}
className="text-mti-purple-light w-8 h-8 cursor-pointer"
/>
<BsCheckCircleFill
onClick={() => {
setIsRecording(false);
stopRecording();
}}
className="text-mti-purple-light w-8 h-8 cursor-pointer"
/>
</div>
</>
)}
{status === "stopped" && mediaBlobUrl && (
<>
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
<div className="flex gap-4 items-center">
<BsTrashFill
className="text-mti-gray-cool cursor-pointer w-5 h-5"
onClick={() => {
setRecordingDuration(0);
clearBlobUrl();
setMediaBlob(undefined);
}}
/>
<BsMicFill
onClick={() => {
clearBlobUrl();
setRecordingDuration(0);
startRecording();
setIsRecording(true);
setMediaBlob(undefined);
}}
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
/>
</div>
</>
)}
</div>
</div>
)}
/>
<div className="self-end flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() =>
onBack({
exercise: id,
solutions: answers,
score: {correct: 1, total: 1, missing: 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Back
</Button>
<Button
color="purple"
disabled={!mediaBlob}
onClick={() => {
saveAnswer();
if (promptIndex + 1 < prompts.length) {
setPromptIndex((prev) => prev + 1);
return;
}
onNext({
exercise: id,
solutions: [
...answers,
{
prompt: prompts[promptIndex].text,
blob: mediaBlob!,
},
],
score: {correct: 1, total: 1, missing: 0},
type,
});
}}
className="max-w-[200px] self-end w-full">
{promptIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
</Button>
</div>
</div>
);
}

View File

@@ -3,20 +3,25 @@ import {MatchSentencesExercise} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {Fragment, useState} from "react";
import {Fragment, useEffect, useState} from "react";
import LineTo from "react-lineto";
import {CommonProps} from ".";
import Button from "../Low/Button";
import Xarrow from "react-xarrows";
import useExamStore from "@/stores/examStore";
export default function MatchSentences({id, options, type, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) {
const [selectedQuestion, setSelectedQuestion] = useState<string>();
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const calculateScore = () => {
const total = sentences.length;
const correct = answers.filter((x) => sentences.find((y) => y.id === x.question)?.solution === x.option || false).length;
const missing = total - answers.filter((x) => sentences.find((y) => y.id === x.question)).length;
const correct = answers.filter(
(x) => sentences.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false,
).length;
const missing = total - answers.filter((x) => sentences.find((y) => y.id.toString() === x.question.toString())).length;
return {total, correct, missing};
};
@@ -27,9 +32,10 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
setSelectedQuestion(undefined);
};
const getSentenceColor = (id: string) => {
return sentences.find((x) => x.id === id)?.color || "";
};
useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
return (
<>
@@ -44,16 +50,16 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
</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">
{sentences.map(({sentence, id, color}) => (
{sentences.map(({sentence, id}) => (
<div key={`question_${id}`} className="flex items-center justify-end gap-2 cursor-pointer">
<span>{sentence} </span>
<button
id={id}
onClick={() => setSelectedQuestion((prev) => (prev === id ? undefined : id))}
className={clsx(
"bg-mti-green-ultralight text-mti-green hover:text-white hover:bg-mti-green w-8 h-8 rounded-full z-10",
"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-green",
selectedQuestion === id && "!text-white !bg-mti-purple",
id,
)}>
{id}
@@ -68,7 +74,7 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
id={id}
onClick={() => selectOption(id)}
className={clsx(
"bg-mti-green-ultralight text-mti-green hover:text-white hover:bg-mti-green w-8 h-8 rounded-full z-10",
"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,
)}>
@@ -79,14 +85,14 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
))}
</div>
{answers.map((solution, index) => (
<Xarrow key={index} start={solution.question} end={solution.option} lineColor="#307912" showHead={false} />
<Xarrow key={index} start={solution.question} end={solution.option} lineColor="#7872BF" showHead={false} />
))}
</div>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="green"
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] w-full">
@@ -94,7 +100,7 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
</Button>
<Button
color="green"
color="purple"
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next

View File

@@ -1,7 +1,8 @@
/* eslint-disable @next/next/no-img-element */
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import clsx from "clsx";
import {useState} from "react";
import {useEffect, useState} from "react";
import {CommonProps} from ".";
import Button from "../Low/Button";
@@ -15,30 +16,30 @@ function Question({
return (
<div className="flex flex-col gap-10">
<span className="">{prompt}</span>
<div className="flex justify-between">
<div className="flex flex-wrap gap-4 justify-between">
{variant === "image" &&
options.map((option) => (
<div
key={option.id}
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
key={option.id.toString()}
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
className={clsx(
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative",
userSolution === option.id && "border-mti-green-light",
userSolution === option.id.toString() && "border-mti-purple-light",
)}>
<span className={clsx("text-sm", userSolution !== option.id && "opacity-50")}>{option.id}</span>
<img src={option.src!} alt={`Option ${option.id}`} />
<span className={clsx("text-sm", userSolution !== option.id.toString() && "opacity-50")}>{option.id.toString()}</span>
<img src={option.src!} alt={`Option ${option.id.toString()}`} />
</div>
))}
{variant === "text" &&
options.map((option) => (
<div
key={option.id}
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
key={option.id.toString()}
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
className={clsx(
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm",
userSolution === option.id && "border-mti-green-light",
userSolution === option.id.toString() && "border-mti-purple-light",
)}>
<span className="font-semibold">{option.id}.</span>
<span className="font-semibold">{option.id.toString()}.</span>
<span>{option.text}</span>
</div>
))}
@@ -47,10 +48,30 @@ function Question({
);
}
export default function MultipleChoice({id, prompt, type, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
export default function MultipleChoice({
id,
prompt,
type,
questions,
userSolutions,
updateIndex,
onNext,
onBack,
}: MultipleChoiceExercise & CommonProps) {
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
const [questionIndex, setQuestionIndex] = useState(0);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
useEffect(() => {
if (updateIndex) updateIndex(questionIndex);
}, [questionIndex, updateIndex]);
const onSelectOption = (option: string) => {
const question = questions[questionIndex];
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);
@@ -58,8 +79,10 @@ export default function MultipleChoice({id, prompt, type, questions, userSolutio
const calculateScore = () => {
const total = questions.length;
const correct = answers.filter((x) => questions.find((y) => y.id === x.question)?.solution === x.option || false).length;
const missing = total - answers.filter((x) => questions.find((y) => y.id === x.question)).length;
const correct = answers.filter(
(x) => questions.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false,
).length;
const missing = total - answers.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length;
return {total, correct, missing};
};
@@ -82,7 +105,7 @@ export default function MultipleChoice({id, prompt, type, questions, userSolutio
return (
<>
<div className="flex flex-col gap-2 mt-4 h-full mb-20 bg-mti-gray-smoke rounded-xl px-16 py-8">
<div className="flex flex-col gap-2 mt-4 h-fit mb-20 bg-mti-gray-smoke rounded-xl px-16 py-8">
<span className="text-xl font-semibold">{prompt}</span>
{questionIndex < questions.length && (
<Question
@@ -94,11 +117,11 @@ export default function MultipleChoice({id, prompt, type, questions, userSolutio
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button color="green" variant="outline" onClick={back} className="max-w-[200px] w-full">
<Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full">
Back
</Button>
<Button color="green" onClick={next} className="max-w-[200px] self-end w-full">
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
Next
</Button>
</div>

View File

@@ -1,25 +1,35 @@
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import {SpeakingExercise, WritingExercise} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {SpeakingExercise} from "@/interfaces/exam";
import {CommonProps} from ".";
import {Fragment, useEffect, useState} from "react";
import {toast} from "react-toastify";
import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill} from "react-icons/bs";
import dynamic from "next/dynamic";
import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
ssr: false,
});
export default function Speaking({id, title, text, type, prompts, onNext, onBack}: SpeakingExercise & CommonProps) {
export default function Speaking({id, title, text, video_url, type, prompts, onNext, onBack}: SpeakingExercise & CommonProps) {
const [recordingDuration, setRecordingDuration] = useState(0);
const [isRecording, setIsRecording] = useState(false);
const [mediaBlob, setMediaBlob] = useState<string>();
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
if (hasExamEnded) {
onNext({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
useEffect(() => {
let recordingInterval: NodeJS.Timer | undefined = undefined;
if (isRecording) {
@@ -35,9 +45,10 @@ export default function Speaking({id, title, text, type, prompts, onNext, onBack
return (
<div className="flex flex-col h-full w-full gap-9">
<div className="flex flex-col w-full gap-14 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
<div className="flex flex-col gap-3">
<span className="font-semibold">{title}</span>
{!video_url && (
<span className="font-regular">
{text.split("\\n").map((line, index) => (
<Fragment key={index}>
@@ -46,7 +57,17 @@ export default function Speaking({id, title, text, type, prompts, onNext, onBack
</Fragment>
))}
</span>
)}
</div>
<div className="flex gap-6">
{video_url && (
<div className="flex flex-col gap-4 w-full items-center">
<video key={id} autoPlay controls className="max-w-3xl rounded-xl">
<source src={video_url} />
</video>
</div>
)}
{prompts && prompts.length > 0 && (
<div className="flex flex-col gap-4">
<span className="font-bold">You should talk about the following things:</span>
<div className="flex flex-col gap-1 ml-4">
@@ -57,6 +78,8 @@ export default function Speaking({id, title, text, type, prompts, onNext, onBack
))}
</div>
</div>
)}
</div>
</div>
<ReactMediaRecorder
@@ -85,11 +108,11 @@ export default function Speaking({id, title, text, type, prompts, onNext, onBack
<>
<div className="flex gap-4 items-center">
<span className="text-xs w-9">
{Math.round(recordingDuration / 60)
{Math.floor(recordingDuration / 60)
.toString(10)
.padStart(2, "0")}
:
{Math.round(recordingDuration % 60)
{Math.floor(recordingDuration % 60)
.toString(10)
.padStart(2, "0")}
</span>
@@ -108,7 +131,7 @@ export default function Speaking({id, title, text, type, prompts, onNext, onBack
setIsRecording(false);
stopRecording();
}}
className="text-mti-green-light w-8 h-8 cursor-pointer"
className="text-mti-purple-light w-8 h-8 cursor-pointer"
/>
</div>
</>
@@ -133,14 +156,14 @@ export default function Speaking({id, title, text, type, prompts, onNext, onBack
setIsRecording(true);
resumeRecording();
}}
className="text-mti-green-light w-8 h-8 cursor-pointer"
className="text-mti-purple-light w-8 h-8 cursor-pointer"
/>
<BsCheckCircleFill
onClick={() => {
setIsRecording(false);
stopRecording();
}}
className="text-mti-green-light w-8 h-8 cursor-pointer"
className="text-mti-purple-light w-8 h-8 cursor-pointer"
/>
</div>
</>
@@ -178,16 +201,30 @@ export default function Speaking({id, title, text, type, prompts, onNext, onBack
<div className="self-end flex justify-between w-full gap-8">
<Button
color="green"
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: [], score: {correct: 1, total: 1, missing: 0}, type})}
onClick={() =>
onBack({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Back
</Button>
<Button
color="green"
color="purple"
disabled={!mediaBlob}
onClick={() => onNext({exercise: id, solutions: [], score: {correct: 1, total: 1, missing: 0}, type})}
onClick={() =>
onNext({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Next
</Button>

View File

@@ -0,0 +1,121 @@
import {TrueFalseExercise} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import {Fragment, useEffect, useState} from "react";
import {CommonProps} from ".";
import Button from "../Low/Button";
export default function TrueFalse({id, type, prompt, questions, userSolutions, onNext, onBack}: TrueFalseExercise & CommonProps) {
const [answers, setAnswers] = useState<{id: string; solution: "true" | "false" | "not_given"}[]>(userSolutions);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
const calculateScore = () => {
const total = questions.length || 0;
const correct = answers.filter(
(x) => questions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false,
).length;
const missing = total - answers.filter((x) => questions.find((y) => x.id.toString() === y.id.toString())).length;
return {total, correct, missing};
};
const toggleAnswer = (solution: "true" | "false" | "not_given", questionId: string) => {
const answer = answers.find((x) => x.id === questionId);
if (answer && answer.solution === solution) {
setAnswers((prev) => prev.filter((x) => x.id !== questionId));
return;
}
setAnswers((prev) => [...prev.filter((x) => x.id !== questionId), {id: questionId, solution}]);
};
return (
<>
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</span>
<div className="flex flex-col gap-6 mb-4">
<p>For each of the questions below, select</p>
<div className="pl-8 flex gap-8">
<span className="flex flex-col gap-4">
<span className="font-bold italic">TRUE</span>
<span className="font-bold italic">FALSE</span>
<span className="font-bold italic">NOT GIVEN</span>
</span>
<span className="flex flex-col gap-4">
<span>if the statement agrees with the information</span>
<span>if the statement contradicts with the information</span>
<span>if there is no information on this</span>
</span>
</div>
</div>
<span className="text-sm w-full leading-6">You can click a selected option again to deselect it.</span>
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-8">
{questions.map((question, index) => (
<div key={question.id.toString()} className="flex flex-col gap-4">
<span>
{index + 1}. {question.prompt}
</span>
<div className="flex gap-4">
<Button
variant={
answers.find((x) => x.id.toString() === question.id.toString())?.solution === "true" ? "solid" : "outline"
}
onClick={() => toggleAnswer("true", question.id.toString())}
className="!py-2">
True
</Button>
<Button
variant={
answers.find((x) => x.id.toString() === question.id.toString())?.solution === "false" ? "solid" : "outline"
}
onClick={() => toggleAnswer("false", question.id.toString())}
className="!py-2">
False
</Button>
<Button
variant={
answers.find((x) => x.id.toString() === question.id.toString())?.solution === "not_given"
? "solid"
: "outline"
}
onClick={() => toggleAnswer("not_given", question.id.toString())}
className="!py-2">
Not Given
</Button>
</div>
</div>
))}
</div>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] w-full">
Back
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</>
);
}

View File

@@ -8,6 +8,7 @@ import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
import {toast} from "react-toastify";
import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
function Blank({
id,
@@ -35,7 +36,7 @@ function Blank({
return (
<input
className="py-2 px-3 rounded-2xl w-48 bg-white focus:outline-none my-2"
className="py-2 px-3 mx-2 rounded-2xl w-48 bg-white focus:outline-none my-2"
placeholder={id}
onChange={(e) => setUserInput(e.target.value)}
onBlur={() => setUserSolution(userInput)}
@@ -48,14 +49,21 @@ function Blank({
export default function WriteBlanks({id, prompt, type, maxWords, solutions, userSolutions, text, onNext, onBack}: WriteBlanksExercise & CommonProps) {
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = answers.filter(
(x) =>
solutions
.find((y) => x.id === y.id)
?.solution.map((y) => y.toLowerCase())
.includes(x.solution.toLowerCase()) || false,
.find((y) => x.id.toString() === y.id.toString())
?.solution.map((y) => y.toLowerCase().trim())
.includes(x.solution.toLowerCase().trim()) || false,
).length;
const missing = total - answers.filter((x) => solutions.find((y) => x.id === y.id)).length;
@@ -83,10 +91,10 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
<span key={index}>
{line}
<br />
</Fragment>
</span>
))}
</span>
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
@@ -101,7 +109,7 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="green"
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] w-full">
@@ -109,7 +117,7 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
</Button>
<Button
color="green"
color="purple"
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next

View File

@@ -1,20 +1,49 @@
/* eslint-disable @next/next/no-img-element */
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import {WritingExercise} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {CommonProps} from ".";
import {Fragment, useEffect, useState} from "react";
import React, {Fragment, useEffect, useRef, useState} from "react";
import {toast} from "react-toastify";
import Button from "../Low/Button";
import {Dialog, Transition} from "@headlessui/react";
import useExamStore from "@/stores/examStore";
export default function Writing({id, prompt, info, type, wordCounter, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
export default function Writing({
id,
prompt,
prefix,
suffix,
type,
wordCounter,
attachment,
userSolutions,
onNext,
onBack,
}: WritingExercise & CommonProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : "");
const [isSubmitEnabled, setIsSubmitEnabled] = useState(false);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
const listener = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "v") {
e.preventDefault();
}
};
document.addEventListener("keydown", listener);
return () => {
document.removeEventListener("keydown", listener);
};
}, []);
useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
useEffect(() => {
const words = inputText.split(" ").filter((x) => x !== "");
@@ -64,7 +93,14 @@ export default function Writing({id, prompt, info, type, wordCounter, attachment
)}
<div className="flex flex-col h-full w-full gap-9 mb-20">
<div className="flex flex-col w-full gap-7 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
<span>{info}</span>
<span>
{prefix.split("\\n").map((line, index) => (
<React.Fragment key={index}>
{line}
<br />
</React.Fragment>
))}
</span>
<span className="font-semibold">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
@@ -85,27 +121,35 @@ export default function Writing({id, prompt, info, type, wordCounter, attachment
<div className="w-full h-full flex flex-col gap-4">
<span>
You should write {wordCounter.type === "min" ? "at least" : "at most"} {wordCounter.limit} words.
{suffix.split("\\n").map((line, index) => (
<React.Fragment key={index}>
{line}
<br />
</React.Fragment>
))}
</span>
<textarea
className="w-full h-full min-h-[148px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
onContextMenu={(e) => e.preventDefault()}
className="w-full h-full min-h-[300px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
onChange={(e) => setInputText(e.target.value)}
value={inputText}
placeholder="Write your text here..."
spellCheck={false}
/>
<span className="text-base self-end text-mti-gray-cool">Word Count: {inputText.split(" ").filter((x) => x !== "").length}</span>
</div>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="green"
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: [inputText], score: {correct: 1, total: 1, missing: 0}, type})}
onClick={() => onBack({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type})}
className="max-w-[200px] self-end w-full">
Back
</Button>
<Button
color="green"
color="purple"
disabled={!isSubmitEnabled}
onClick={() => onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type})}
className="max-w-[200px] self-end w-full">

View File

@@ -1,9 +1,11 @@
import {
Exercise,
FillBlanksExercise,
InteractiveSpeakingExercise,
MatchSentencesExercise,
MultipleChoiceExercise,
SpeakingExercise,
TrueFalseExercise,
UserSolution,
WriteBlanksExercise,
WritingExercise,
@@ -14,27 +16,55 @@ import MultipleChoice from "./MultipleChoice";
import WriteBlanks from "./WriteBlanks";
import Writing from "./Writing";
import Speaking from "./Speaking";
import TrueFalse from "./TrueFalse";
import InteractiveSpeaking from "./InteractiveSpeaking";
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false});
export interface CommonProps {
updateIndex?: (internalIndex: number) => void;
onNext: (userSolutions: UserSolution) => void;
onBack: (userSolutions: UserSolution) => void;
}
export const renderExercise = (exercise: Exercise, onNext: (userSolutions: UserSolution) => void, onBack: (userSolutions: UserSolution) => void) => {
export const renderExercise = (
exercise: Exercise,
onNext: (userSolutions: UserSolution) => void,
onBack: (userSolutions: UserSolution) => void,
updateIndex?: (internalIndex: number) => void,
) => {
switch (exercise.type) {
case "fillBlanks":
return <FillBlanks {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
case "trueFalse":
return <TrueFalse key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
case "matchSentences":
return <MatchSentences {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
case "multipleChoice":
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
return (
<MultipleChoice
key={exercise.id}
{...(exercise as MultipleChoiceExercise)}
updateIndex={updateIndex}
onNext={onNext}
onBack={onBack}
/>
);
case "writeBlanks":
return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
case "writing":
return <Writing {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
case "speaking":
return <Speaking {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
case "interactiveSpeaking":
return (
<InteractiveSpeaking
key={exercise.id}
{...(exercise as InteractiveSpeakingExercise)}
updateIndex={updateIndex}
onNext={onNext}
onBack={onBack}
/>
);
}
};

View File

@@ -0,0 +1,9 @@
import {useEffect, useState} from "react";
interface Props {
onFocusLayerMouseEnter?: () => void;
}
export default function FocusLayer({onFocusLayerMouseEnter}: Props) {
return <div className="absolute top-0 left-0 bottom-0 right-0" onMouseDown={onFocusLayerMouseEnter} />;
}

View File

@@ -1,6 +1,7 @@
import {User} from "@/interfaces/user";
import clsx from "clsx";
import {useRouter} from "next/router";
import BottomBar from "../BottomBar";
import Navbar from "../Navbar";
import Sidebar from "../Sidebar";
@@ -8,19 +9,35 @@ interface Props {
user: User;
children: React.ReactNode;
className?: string;
navDisabled?: boolean;
focusMode?: boolean;
onFocusLayerMouseEnter?: () => void;
}
export default function Layout({user, children, className}: Props) {
export default function Layout({user, children, className, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
const router = useRouter();
return (
<main className="w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke">
<Navbar user={user} />
<div className="h-full w-full flex py-4 pb-8 gap-2">
<Sidebar path={router.pathname} />
<main className="w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative">
<Navbar
path={router.pathname}
user={user}
navDisabled={navDisabled}
focusMode={focusMode}
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
/>
<div className="h-full w-full flex gap-2">
<Sidebar
path={router.pathname}
navDisabled={navDisabled}
focusMode={focusMode}
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
className="-md:hidden"
userType={user.type}
/>
<div
className={clsx(
"w-5/6 min-h-full h-fit mr-8 bg-white shadow-md rounded-2xl p-12 pb-8 flex flex-col gap-12 relative overflow-hidden",
"w-full min-h-full h-fit md:mr-8 bg-white shadow-md rounded-2xl p-4 xl:p-10 pb-8 flex flex-col gap-8 relative overflow-hidden mt-2",
className,
)}>
{children}

View File

@@ -7,13 +7,14 @@ import ProgressBar from "./ProgressBar";
interface Props {
src: string;
color: "blue" | "orange" | "green" | Module;
color: "red" | "rose" | "purple" | Module;
autoPlay?: boolean;
disabled?: boolean;
onEnd?: () => void;
disablePause?: boolean;
}
export default function AudioPlayer({src, color, autoPlay = false, disabled = false, onEnd}: Props) {
export default function AudioPlayer({src, color, autoPlay = false, disabled = false, onEnd, disablePause = false}: Props) {
const [isPlaying, setIsPlaying] = useState(false);
const [duration, setDuration] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
@@ -21,11 +22,19 @@ export default function AudioPlayer({src, color, autoPlay = false, disabled = fa
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
if (audioPlayerRef && audioPlayerRef.current) {
const seconds = Math.floor(audioPlayerRef.current.duration);
setDuration(seconds);
}
}, [audioPlayerRef?.current?.readyState]);
const durationInterval = setInterval(() => {
if (duration > 0) clearInterval(durationInterval);
const seconds = Math.floor(audioPlayerRef?.current?.duration || 0);
if (seconds > 0) setDuration(seconds);
}, 300);
if (duration > 0) clearInterval(durationInterval);
return () => {
clearInterval(durationInterval);
};
}, [duration]);
useEffect(() => {
let playingInterval: NodeJS.Timer | undefined = undefined;
@@ -54,8 +63,8 @@ export default function AudioPlayer({src, color, autoPlay = false, disabled = fa
<div className="w-full h-fit flex gap-4 items-center mt-2">
{isPlaying && (
<BsPauseFill
className={clsx("text-mti-gray-cool cursor-pointer w-5 h-5", disabled && "opacity-60 cursor-not-allowed")}
onClick={disabled ? undefined : togglePlayPause}
className={clsx("text-mti-gray-cool cursor-pointer w-5 h-5", (disabled || disablePause) && "opacity-60 cursor-not-allowed")}
onClick={disabled || disablePause ? undefined : togglePlayPause}
/>
)}
{!isPlaying && (

View File

@@ -1,44 +1,67 @@
import clsx from "clsx";
import {ReactNode} from "react";
import {BsArrowRepeat} from "react-icons/bs";
interface Props {
children: ReactNode;
color?: "orange" | "green" | "blue";
color?: "rose" | "purple" | "red" | "green";
variant?: "outline" | "solid";
className?: string;
disabled?: boolean;
isLoading?: boolean;
onClick?: () => void;
type?: "button" | "reset" | "submit";
}
export default function Button({color = "green", variant = "solid", disabled = false, className, children, onClick}: Props) {
export default function Button({
color = "purple",
variant = "solid",
disabled = false,
isLoading = false,
className,
children,
type,
onClick,
}: Props) {
const colorClassNames: {[key in typeof color]: {[key in typeof variant]: string}} = {
green: {
solid: "bg-mti-green-light text-white hover:bg-mti-green disabled:text-mti-green disabled:bg-mti-green-ultralight selection:bg-mti-green-dark",
solid: "bg-mti-green-light text-white border border-mti-green-light hover:bg-mti-green disabled:text-mti-green disabled:bg-mti-green-ultralight selection:bg-mti-green-dark",
outline:
"bg-transparent text-mti-green-light border border-mti-green-light hover:bg-mti-green-light disabled:text-mti-green disabled:bg-mti-green-ultralight selection:bg-mti-green-dark hover:text-white selection:text-white",
"bg-transparent text-mti-green-light border border-mti-green-light hover:bg-mti-green-light disabled:text-mti-green disabled:bg-mti-green-ultralight disabled:border-none selection:bg-mti-green-dark hover:text-white selection:text-white",
},
blue: {
solid: "bg-mti-blue-light text-white hover:bg-mti-blue disabled:text-mti-blue disabled:bg-mti-blue-ultralight selection:bg-mti-blue-dark",
purple: {
solid: "bg-mti-purple-light text-white border border-mti-purple-light hover:bg-mti-purple disabled:text-mti-purple disabled:bg-mti-purple-ultralight selection:bg-mti-purple-dark",
outline:
"bg-transparent text-mti-blue-light border border-mti-blue-light hover:bg-mti-blue-light disabled:text-mti-blue disabled:bg-mti-blue-ultralight selection:bg-mti-blue-dark hover:text-white selection:text-white",
"bg-transparent text-mti-purple-light border border-mti-purple-light hover:bg-mti-purple-light disabled:text-mti-purple disabled:bg-mti-purple-ultralight disabled:border-none selection:bg-mti-purple-dark hover:text-white selection:text-white",
},
orange: {
solid: "bg-mti-orange-light text-white hover:bg-mti-orange disabled:text-mti-orange disabled:bg-mti-orange-ultralight selection:bg-mti-orange-dark",
red: {
solid: "bg-mti-red-light text-white border border-mti-red-light hover:bg-mti-red disabled:text-mti-red disabled:bg-mti-red-ultralight selection:bg-mti-red-dark",
outline:
"bg-transparent text-mti-orange-light border border-mti-orange-light hover:bg-mti-orange-light disabled:text-mti-orange disabled:bg-mti-orange-ultralight selection:bg-mti-orange-dark hover:text-white selection:text-white",
"bg-transparent text-mti-red-light border border-mti-red-light hover:bg-mti-red-light disabled:text-mti-red disabled:bg-mti-red-ultralight disabled:border-none selection:bg-mti-red-dark hover:text-white selection:text-white",
},
rose: {
solid: "bg-mti-rose-light text-white border border-mti-rose-light hover:bg-mti-rose disabled:text-mti-rose disabled:bg-mti-rose-ultralight selection:bg-mti-rose-dark",
outline:
"bg-transparent text-mti-rose-light border border-mti-rose-light hover:bg-mti-rose-light disabled:text-mti-rose disabled:bg-mti-rose-ultralight disabled:border-none selection:bg-mti-rose-dark hover:text-white selection:text-white",
},
};
return (
<button
type={type}
onClick={onClick}
className={clsx(
"py-4 px-6 rounded-full transition ease-in-out duration-300 disabled:cursor-not-allowed",
"py-4 px-6 rounded-full transition ease-in-out duration-300 disabled:cursor-not-allowed cursor-pointer",
className,
colorClassNames[color][variant],
)}
disabled={disabled}>
{children}
disabled={disabled || isLoading}>
{!isLoading && children}
{isLoading && (
<div className="flex items-center justify-center">
<BsArrowRepeat className="text-white animate-spin" size={25} />
</div>
)}
</button>
);
}

View File

@@ -0,0 +1,26 @@
import clsx from "clsx";
import {ReactElement, ReactNode} from "react";
import {BsCheck} from "react-icons/bs";
interface Props {
isChecked: boolean;
onChange: (isChecked: boolean) => void;
children: ReactNode;
}
export default function Checkbox({isChecked, onChange, children}: Props) {
return (
<div className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer" onClick={() => onChange(!isChecked)}>
<input type="checkbox" className="hidden" />
<div
className={clsx(
"w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
"transition duration-300 ease-in-out",
isChecked && "!bg-mti-purple-light ",
)}>
<BsCheck color="white" className="w-full h-full" />
</div>
<span>{children}</span>
</div>
);
}

View File

@@ -0,0 +1,81 @@
import {countries, TCountries} from "countries-list";
import {Fragment, useState} from "react";
import {Combobox, Transition} from "@headlessui/react";
import {BsChevronExpand} from "react-icons/bs";
import countryCodes from "country-codes-list";
interface Props {
value?: string;
onChange?: (value: string) => void;
disabled?: boolean;
}
const mapCountries = (codes: string[]) => {
return codes.map((code) => ({
label: `${countryCodes.findOne("countryCode" as any, code).flag} ${countries[code as unknown as keyof TCountries].name} (+${
countries[code as unknown as keyof TCountries].phone
})`,
code,
}));
};
export default function CountrySelect({value, disabled = false, onChange}: Props) {
const [query, setQuery] = useState("");
const filteredCountries =
query === ""
? mapCountries(Object.keys(countries))
: mapCountries(
Object.keys(countries).filter((x) =>
countries[x as unknown as keyof TCountries].name.toLowerCase().includes(query.toLowerCase()),
),
);
return (
<>
<Combobox value={value} onChange={onChange} disabled={disabled}>
<div className="relative mt-1">
<div className="relative w-full cursor-default overflow-hidden ">
<Combobox.Input
className="py-6 w-full px-8 text-sm font-normal placeholder:text-mti-gray-cool bg-white disabled:bg-mti-gray-platinum/40 rounded-full border border-mti-gray-platinum focus:outline-none"
onChange={(e) => setQuery(e.target.value)}
displayValue={(code: string) => {
const country = countries[code as unknown as keyof TCountries];
return `${countryCodes.findOne("countryCode" as any, code).flag} ${country.name} (+${country.phone})`;
}}
/>
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-8">
<BsChevronExpand />
</Combobox.Button>
</div>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
afterLeave={() => setQuery("")}>
<Combobox.Options className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-xl bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
{filteredCountries.length === 0 && query !== "" ? (
<div className="relative cursor-default select-none py-2 px-4 text-gray-700">Nothing found.</div>
) : (
filteredCountries.map((country) => (
<Combobox.Option
key={country.code}
value={country.code}
className={({active}) =>
`relative cursor-default select-none py-2 pl-10 pr-4 ${
active ? "bg-mti-purple-light text-white" : "text-gray-900"
}`
}>
{country.label}
</Combobox.Option>
))
)}
</Combobox.Options>
</Transition>
</div>
</Combobox>
</>
);
}

View File

@@ -1,15 +1,33 @@
import clsx from "clsx";
import {useState} from "react";
interface Props {
type: "email" | "text" | "password";
type: "email" | "text" | "password" | "tel" | "number";
roundness?: "full" | "xl";
required?: boolean;
label?: string;
placeholder?: string;
defaultValue?: string | number;
value?: string | number;
className?: string;
disabled?: boolean;
name: string;
onChange: (value: string) => void;
}
export default function Input({type, label, placeholder, name, required = false, onChange}: Props) {
export default function Input({
type,
label,
placeholder,
name,
required = false,
value,
defaultValue,
className,
roundness = "full",
disabled = false,
onChange,
}: Props) {
const [showPassword, setShowPassword] = useState(false);
if (type === "password") {
@@ -27,6 +45,7 @@ export default function Input({type, label, placeholder, name, required = false,
name={name}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
defaultValue={defaultValue}
className="w-full px-8 py-6 text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
/>
<p
@@ -41,7 +60,7 @@ export default function Input({type, label, placeholder, name, required = false,
}
return (
<div className="flex flex-col gap-3 w-full">
<div className={clsx("flex flex-col gap-3 w-full", className)}>
{label && (
<label className="font-normal text-base text-mti-gray-dim">
{label}
@@ -51,10 +70,18 @@ export default function Input({type, label, placeholder, name, required = false,
<input
type={type}
name={name}
disabled={disabled}
value={value}
onChange={(e) => onChange(e.target.value)}
min={type === "number" ? 0 : undefined}
placeholder={placeholder}
className="px-8 py-6 text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
className={clsx(
"px-8 py-6 text-sm font-normal bg-white border border-mti-gray-platinum focus:outline-none",
"placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed",
roundness === "full" ? "rounded-full" : "rounded-xl",
)}
required={required}
defaultValue={defaultValue}
/>
</div>
);

View File

@@ -4,20 +4,22 @@ import clsx from "clsx";
interface Props {
label: string;
percentage: number;
color: "blue" | "orange" | "green" | Module;
color: "red" | "rose" | "purple" | Module;
useColor?: boolean;
className?: string;
textClassName?: string;
}
export default function ProgressBar({label, percentage, color, useColor = false, className}: Props) {
export default function ProgressBar({label, percentage, color, useColor = false, className, textClassName}: Props) {
const progressColorClass: {[key in typeof color]: string} = {
blue: "bg-mti-blue-light",
orange: "bg-mti-orange-light",
green: "bg-mti-green-light",
red: "bg-mti-red-light",
rose: "bg-mti-rose-light",
purple: "bg-mti-purple-light",
reading: "bg-ielts-reading",
listening: "bg-ielts-listening",
writing: "bg-ielts-writing",
speaking: "bg-ielts-speaking",
level: "bg-ielts-level",
};
return (
@@ -32,7 +34,7 @@ export default function ProgressBar({label, percentage, color, useColor = false,
style={{width: `${percentage}%`}}
className={clsx("absolute transition-all duration-300 ease-in-out top-0 left-0 h-full overflow-hidden", progressColorClass[color])}
/>
<span className="z-10 justify-self-center text-white text-sm font-bold">{label}</span>
<span className={clsx("z-[1] justify-self-center text-white text-sm font-bold", textClassName)}>{label}</span>
</div>
);
}

View File

@@ -1,8 +1,12 @@
import {Module} from "@/interfaces";
import useExamStore from "@/stores/examStore";
import {moduleLabels} from "@/utils/moduleUtils";
import clsx from "clsx";
import {motion} from "framer-motion";
import {ReactNode, useEffect, useState} from "react";
import {BsBook, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
import ProgressBar from "../Low/ProgressBar";
import TimerEndedModal from "../TimerEndedModal";
interface Props {
minTimer: number;
@@ -15,6 +19,9 @@ interface Props {
export default function ModuleTitle({minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false}: Props) {
const [timer, setTimer] = useState(minTimer * 60);
const [showModal, setShowModal] = useState(false);
const [warningMode, setWarningMode] = useState(false);
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
useEffect(() => {
if (!disableTimer) {
@@ -26,18 +33,41 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
}
}, [disableTimer, minTimer]);
useEffect(() => {
if (timer <= 0) setShowModal(true);
}, [timer]);
useEffect(() => {
if (timer < 300 && !warningMode) setWarningMode(true);
}, [timer, warningMode]);
const moduleIcon: {[key in Module]: ReactNode} = {
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,
writing: <BsPen className="text-ielts-writing w-6 h-6" />,
speaking: <BsMegaphone className="text-ielts-speaking w-6 h-6" />,
level: <BsClipboard className="text-ielts-level w-6 h-6" />,
};
return (
<>
<div className="absolute top-4 right-6 bg-mti-gray-seasalt px-3 py-2 flex items-center gap-2 rounded-full text-mti-gray-davy">
<BsStopwatch className="w-4 h-4" />
<span className="text-sm font-semibold w-11">
<TimerEndedModal
isOpen={showModal}
onClose={() => {
setHasExamEnded(true);
setShowModal(false);
}}
/>
<motion.div
className={clsx(
"absolute top-4 right-6 bg-mti-gray-seasalt px-4 py-3 flex items-center gap-2 rounded-full text-mti-gray-davy",
warningMode && !disableTimer && "bg-mti-red-light text-mti-gray-seasalt",
)}
initial={{scale: warningMode && !disableTimer ? 0.8 : 1}}
animate={{scale: warningMode && !disableTimer ? 1.1 : 1}}
transition={{repeat: Infinity, repeatType: "reverse", duration: 0.5, ease: "easeInOut"}}>
<BsStopwatch className="w-6 h-6" />
<span className="text-base font-semibold w-12">
{timer > 0 && (
<>
{Math.floor(timer / 60)
@@ -51,7 +81,7 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
)}
{timer <= 0 && <>00:00</>}
</span>
</div>
</motion.div>
<div className="flex gap-6 w-full h-fit items-center mt-5">
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg">{moduleIcon[module]}</div>
<div className="flex flex-col gap-3 w-full">
@@ -59,8 +89,8 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
<span className="text-base font-semibold">
{moduleLabels[module]} exam {label && `- ${label}`}
</span>
<span className="text-xs font-normal self-end text-mti-gray-davy">
Question {exerciseIndex}/{totalExercises}
<span className="text-sm font-semibold self-end">
Exercise {exerciseIndex}/{totalExercises}
</span>
</div>
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />

View File

@@ -0,0 +1,149 @@
import {User} from "@/interfaces/user";
import {Dialog, Transition} from "@headlessui/react";
import axios from "axios";
import clsx from "clsx";
import Image from "next/image";
import Link from "next/link";
import {useRouter} from "next/router";
import {Fragment} from "react";
import {BsShield, BsShieldFill, BsXLg} from "react-icons/bs";
interface Props {
isOpen: boolean;
onClose: () => void;
path: string;
user: User;
}
export default function MobileMenu({isOpen, onClose, path, user}: Props) {
const router = useRouter();
const logout = async () => {
axios.post("/api/logout").finally(() => {
setTimeout(() => router.reload(), 500);
});
};
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95">
<Dialog.Panel className="w-full h-screen transform overflow-hidden bg-white text-left align-middle shadow-xl transition-all text-black flex flex-col gap-8">
<Dialog.Title as="header" className="w-full px-8 py-2 -md:flex justify-between items-center md:hidden shadow-sm">
<Link href="/">
<Image src="/logo_title.png" alt="EnCoach logo" width={69} height={69} />
</Link>
<div className="cursor-pointer" onClick={onClose} tabIndex={0}>
<BsXLg className="text-2xl text-mti-purple-light" onClick={onClose} />
</div>
</Dialog.Title>
<div className="flex flex-col h-full gap-6 px-8 text-lg">
<Link
href="/"
className={clsx(
"transition ease-in-out duration-300 w-fit",
path === "/" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
)}>
Dashboard
</Link>
{(user.type === "student" || user.type === "teacher" || user.type === "developer") && (
<>
<Link
href="/exam"
className={clsx(
"transition ease-in-out duration-300 w-fit",
path === "/exam" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
)}>
Exams
</Link>
<Link
href="/exercises"
className={clsx(
"transition ease-in-out duration-300 w-fit",
path === "/exercises" &&
"text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
)}>
Exercises
</Link>
</>
)}
<Link
href="/stats"
className={clsx(
"transition ease-in-out duration-300 w-fit",
path === "/stats" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
)}>
Stats
</Link>
<Link
href="/record"
className={clsx(
"transition ease-in-out duration-300 w-fit",
path === "/record" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
)}>
Record
</Link>
{["admin", "developer", "agent"].includes(user.type) && (
<Link
href="/payment-record"
className={clsx(
"transition ease-in-out duration-300 w-fit",
path === "/payment-record" &&
"text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
)}>
Payment Record
</Link>
)}
{["admin", "developer", "corporate", "teacher"].includes(user.type) && (
<Link
href="/settings"
className={clsx(
"transition ease-in-out duration-300 w-fit",
path === "/settings" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
)}>
Settings
</Link>
)}
<Link
href="/profile"
className={clsx(
"transition ease-in-out duration-300 w-fit",
path === "/profile" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
)}>
Profile
</Link>
<span
className={clsx("transition ease-in-out duration-300 w-fit justify-self-end cursor-pointer")}
onClick={logout}>
Logout
</span>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
);
}

50
src/components/Modal.tsx Normal file
View File

@@ -0,0 +1,50 @@
import {Dialog, Transition} from "@headlessui/react";
import {Fragment, ReactElement} from "react";
interface Props {
isOpen: boolean;
onClose: () => void;
title?: string;
children?: ReactElement;
}
export default function Modal({isOpen, title, onClose, children}: Props) {
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95">
<Dialog.Panel className="w-full max-w-6xl transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
{title && (
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
{title}
</Dialog.Title>
)}
{children}
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
);
}

View File

@@ -1,22 +1,82 @@
import {User} from "@/interfaces/user";
import {Avatar} from "primereact/avatar";
import Link from "next/link";
import FocusLayer from "@/components/FocusLayer";
import {preventNavigation} from "@/utils/navigation.disabled";
import {useRouter} from "next/router";
import {BsList} from "react-icons/bs";
import clsx from "clsx";
import moment from "moment";
import MobileMenu from "./MobileMenu";
import {useState} from "react";
interface Props {
user: User;
navDisabled?: boolean;
focusMode?: boolean;
onFocusLayerMouseEnter?: () => void;
path: string;
}
/* eslint-disable @next/next/no-img-element */
export default function Navbar({user}: Props) {
export default function Navbar({user, path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const disableNavigation = preventNavigation(navDisabled, focusMode);
const router = useRouter();
const expirationDateColor = (date: Date) => {
const momentDate = moment(date);
const today = moment(new Date());
if (today.add(1, "days").isAfter(momentDate)) return "!bg-mti-red-ultralight border-mti-red-light";
if (today.add(3, "days").isAfter(momentDate)) return "!bg-mti-rose-ultralight border-mti-rose-light";
if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light";
};
const showExpirationDate = () => {
if (!user.subscriptionExpirationDate) return false;
const momentDate = moment(user.subscriptionExpirationDate);
const today = moment(new Date());
return today.add(7, "days").isAfter(momentDate);
};
return (
<header className="w-full bg-transparent py-4 gap-2 flex items-center">
<h1 className="font-bold text-2xl w-1/6 px-8">eCrop</h1>
<div className="flex justify-between w-5/6 mr-8">
<input type="text" placeholder="Search..." className="rounded-full py-4 px-6 border border-mti-gray-platinum outline-none" />
<div className="flex gap-3 items-center justify-end">
<img src={user.profilePicture} alt={user.name} className="w-10 h-10 rounded-full" />
<span className="text-right">{user.name}</span>
<>
{user && <MobileMenu path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />}
<header className="w-full bg-transparent py-2 md:py-4 -md:justify-between md:gap-12 flex items-center relative -md:px-4">
<Link href={disableNavigation ? "" : "/"} className=" md:px-8 flex gap-8 items-center">
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" />
<h1 className="font-bold text-2xl w-1/6 -md:hidden">EnCoach</h1>
</Link>
<div className="flex justify-end -md:items-center gap-4 md:w-5/6 md:mr-8">
{showExpirationDate() && (
<Link
href="/payment"
data-tip="Expiry date"
className={clsx(
"py-2 px-6 w-fit flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out tooltip tooltip-bottom",
!user.subscriptionExpirationDate
? "bg-mti-green-ultralight border-mti-green-light"
: expirationDateColor(user.subscriptionExpirationDate),
"bg-white border-mti-gray-platinum",
)}>
{!user.subscriptionExpirationDate && "Unlimited"}
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
</Link>
)}
<Link href={disableNavigation ? "" : "/profile"} className="flex gap-6 items-center justify-end -md:hidden">
<img src={user.profilePicture} alt={user.name} className="w-10 h-10 rounded-full object-cover" />
<span className="text-right -md:hidden">{user.name}</span>
</Link>
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}>
<BsList className="text-mti-purple-light w-8 h-8" />
</div>
</div>
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
</header>
</>
);
}

View File

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

View File

@@ -1,31 +0,0 @@
/* eslint-disable @next/next/no-img-element */
import {User} from "@/interfaces/user";
import clsx from "clsx";
import LevelLabel from "./LevelLabel";
import LevelProgressBar from "./LevelProgressBar";
import {Avatar} from "primereact/avatar";
interface Props {
user: User;
className: string;
}
export default function ProfileCard({user, className}: Props) {
return (
<div className={clsx("bg-white drop-shadow-xl p-4 md:p-8 rounded-xl w-full flex flex-col gap-6", className)}>
<div className="flex w-full items-center gap-8">
<div className="w-16 md:w-24 h-16 md:h-24 rounded-full border-2 md:border-4 border-white drop-shadow-md md:drop-shadow-xl">
{user.profilePicture.length > 0 && <img src={user.profilePicture} alt="Profile picture" className="rounded-full" />}
{user.profilePicture.length === 0 && (
<Avatar size="xlarge" style={{width: "100%", height: "100%"}} label={user.name.slice(0, 1)} shape="circle" />
)}
</div>
<div className="flex flex-col justify-center">
<span className="text-neutral-600 font-bold text-xl lg:text-2xl">{user.name}</span>
<LevelLabel experience={user.experience} />
</div>
</div>
<LevelProgressBar experience={user.experience} progressBarWidth="w-32 md:w-96" />
</div>
);
}

View File

@@ -1,31 +0,0 @@
/* eslint-disable @next/next/no-img-element */
import {User} from "@/interfaces/user";
import {levelCalculator} from "@/resources/level";
import clsx from "clsx";
import LevelLabel from "./LevelLabel";
import LevelProgressBar from "./LevelProgressBar";
import {Avatar} from "primereact/avatar";
interface Props {
user: User;
className?: string;
}
export default function ProfileLevel({user, className}: Props) {
const levelResult = levelCalculator(user.experience);
return (
<div className={clsx("flex flex-col items-center justify-center gap-4", className)}>
<div className="w-16 md:w-24 h-16 md:h-24 rounded-full">
{user.profilePicture.length > 0 && <img src={user.profilePicture} alt="Profile picture" className="rounded-full" />}
{user.profilePicture.length === 0 && (
<Avatar size="xlarge" style={{width: "100%", height: "100%"}} label={user.name.slice(0, 1)} shape="circle" />
)}
</div>
<div className="flex flex-col gap-1 items-center">
<LevelLabel experience={user.experience} />
<LevelProgressBar experience={user.experience} className="text-black" />
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
/* eslint-disable @next/next/no-img-element */
import {User} from "@/interfaces/user";
import {USER_TYPE_LABELS} from "@/resources/user";
import {calculateAverageLevel} from "@/utils/score";
import {capitalize} from "lodash";
import {ReactElement} from "react";
import ProgressBar from "./Low/ProgressBar";
interface Props {
user: User;
items: {
icon: ReactElement;
value: string | number;
label: string;
}[];
children?: ReactElement;
}
export default function ProfileSummary({user, items}: Props) {
return (
<section className="w-full flex -md:flex-col gap-4 md:gap-8">
<img
src={user.profilePicture}
alt={user.name}
className="aspect-square h-20 md:h-64 rounded-3xl drop-shadow-xl object-cover -md:hidden"
/>
<div className="flex md:flex-col gap-4 md:py-4 w-full -md:items-center">
<img src={user.profilePicture} alt={user.name} className="aspect-square h-24 md:hidden rounded-3xl drop-shadow-xl object-cover" />
<div className="flex -md:flex-col justify-between w-full gap-8">
<div className="flex flex-col gap-2 py-2">
<h1 className="font-bold text-2xl md:text-4xl">{user.name}</h1>
<h6 className="font-normal text-base text-mti-gray-taupe">{USER_TYPE_LABELS[user.type]}</h6>
</div>
<ProgressBar
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
percentage={100}
color="purple"
className="max-w-xs w-32 md:self-end h-10 -md:hidden"
/>
</div>
<ProgressBar
label=""
percentage={Math.round((calculateAverageLevel(user.levels) * 100) / calculateAverageLevel(user.desiredLevels))}
color="red"
className="w-full h-3 drop-shadow-lg -md:hidden"
/>
<div className="flex justify-between w-full mt-8 -md:hidden">
{items.map((item) => (
<div className="flex gap-4 items-center" key={item.label}>
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
{item.icon}
</div>
<div className="flex flex-col">
<span className="font-bold text-xl">{item.value}</span>
<span className="font-normal text-base text-mti-gray-dim">{item.label}</span>
</div>
</div>
))}
</div>
</div>
<ProgressBar
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
percentage={Math.round((calculateAverageLevel(user.levels) * 100) / calculateAverageLevel(user.desiredLevels))}
color="purple"
className="w-full md:hidden h-8"
textClassName="!text-mti-black"
/>
<div className="grid grid-cols-2 gap-4 w-full mt-4 md:hidden">
{items.map((item) => (
<div className="flex gap-4 items-center" key={item.label}>
<div className="w-12 h-12 md:w-16 md:h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-lg md:rounded-xl">
{item.icon}
</div>
<div className="flex flex-col">
<span className="font-bold text-lg md:text-xl">{item.value}</span>
<span className="font-normal text-sm md:text-base text-mti-gray-dim">{item.label}</span>
</div>
</div>
))}
</div>
</section>
);
}

View File

@@ -1,16 +1,35 @@
import clsx from "clsx";
import {IconType} from "react-icons";
import {MdSpaceDashboard} from "react-icons/md";
import {BsFileEarmarkText, BsClockHistory, BsPencil, BsGraphUp} from "react-icons/bs";
import {
BsFileEarmarkText,
BsClockHistory,
BsPencil,
BsGraphUp,
BsChevronBarRight,
BsChevronBarLeft,
BsShieldFill,
BsCloudFill,
BsCurrencyDollar,
} from "react-icons/bs";
import {RiLogoutBoxFill} from "react-icons/ri";
import {SlPencil} from "react-icons/sl";
import {FaAward} from "react-icons/fa";
import Link from "next/link";
import {useRouter} from "next/router";
import axios from "axios";
import FocusLayer from "@/components/FocusLayer";
import {preventNavigation} from "@/utils/navigation.disabled";
import {useState} from "react";
import usePreferencesStore from "@/stores/preferencesStore";
import {Type} from "@/interfaces/user";
interface Props {
path: string;
navDisabled?: boolean;
focusMode?: boolean;
onFocusLayerMouseEnter?: () => void;
className?: string;
userType?: Type;
}
interface NavProps {
@@ -18,50 +37,138 @@ interface NavProps {
label: string;
path: string;
keyPath: string;
disabled?: boolean;
isMinimized?: boolean;
}
const Nav = ({Icon, label, path, keyPath}: NavProps) => (
const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false}: NavProps) => (
<Link
href={keyPath}
href={!disabled ? keyPath : ""}
className={clsx(
"p-4 px-8 rounded-full flex gap-4 items-center cursor-pointer text-gray-500 hover:bg-mti-green-light hover:text-white transition duration-300 ease-in-out",
path === keyPath && "bg-mti-green-light text-white",
"p-4 rounded-full flex gap-4 items-center cursor-pointer text-gray-500 hover:bg-mti-purple-light hover:text-white",
"transition-all duration-300 ease-in-out",
path === keyPath && "bg-mti-purple-light text-white",
isMinimized ? "w-fit" : "w-full min-w-[200px] 2xl:min-w-[220px] px-8",
)}>
<Icon size={20} />
<span className="text-lg font-semibold">{label}</span>
<Icon size={24} />
{!isMinimized && <span className="text-lg font-semibold">{label}</span>}
</Link>
);
export default function Sidebar({path}: Props) {
export default function Sidebar({path, navDisabled = false, focusMode = false, userType, onFocusLayerMouseEnter, className}: Props) {
const router = useRouter();
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
const logout = async () => {
axios.post("/api/logout").finally(() => {
router.push("/login");
setTimeout(() => router.reload(), 500);
});
};
const disableNavigation = preventNavigation(navDisabled, focusMode);
return (
<section className="h-full flex bg-transparent flex-col justify-between w-1/6 px-4">
<div className="flex flex-col gap-3">
<Nav Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" />
<Nav Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" />
<Nav Icon={BsPencil} label="Exercises" path={path} keyPath="/#" />
<Nav Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" />
<Nav Icon={BsClockHistory} label="Record" path={path} keyPath="/record" />
<section
className={clsx(
"h-full flex bg-transparent flex-col justify-between px-4 py-4 pb-8 relative",
isMinimized ? "w-fit" : "w-1/6 -xl:w-fit",
className,
)}>
<div className="xl:flex -xl:hidden flex-col gap-3">
<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"].includes(userType || "") && (
<Nav
disabled={disableNavigation}
Icon={BsCurrencyDollar}
label="Payment Record"
path={path}
keyPath="/payment-record"
isMinimized={isMinimized}
/>
)}
{["admin", "developer", "corporate", "teacher"].includes(userType || "") && (
<Nav
disabled={disableNavigation}
Icon={BsShieldFill}
label="Settings"
path={path}
keyPath="/settings"
isMinimized={isMinimized}
/>
)}
{userType === "developer" && (
<Nav
disabled={disableNavigation}
Icon={BsCloudFill}
label="Generation"
path={path}
keyPath="/generation"
isMinimized={isMinimized}
/>
)}
</div>
<div className="xl:hidden -xl:flex flex-col gap-3">
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} />
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} />
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
{userType !== "student" && (
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized={true} />
)}
{userType === "developer" && (
<Nav disabled={disableNavigation} Icon={BsCloudFill} label="Generation" path={path} keyPath="/generation" isMinimized={true} />
)}
</div>
<div className="flex flex-col gap-0 absolute bottom-12">
<div
role="button"
tabIndex={1}
onClick={logout}
onClick={toggleMinimize}
className={clsx(
"p-4 px-8 rounded-full flex gap-4 items-center cursor-pointer text-black hover:text-mti-orange transition duration-300 ease-in-out",
"absolute bottom-8",
"p-4 rounded-full flex gap-4 items-center cursor-pointer text-black hover:text-mti-rose -xl:hidden transition duration-300 ease-in-out",
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
)}>
<RiLogoutBoxFill size={20} />
<span className="text-lg font-medium">Log Out</span>
{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(
"p-4 rounded-full flex gap-4 items-center cursor-pointer text-black hover:text-mti-rose transition duration-300 ease-in-out",
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
)}>
<RiLogoutBoxFill size={24} />
{!isMinimized && <span className="text-lg font-medium -xl:hidden">Log Out</span>}
</div>
</div>
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
</section>
);
}

View File

@@ -5,11 +5,21 @@ import {CommonProps} from ".";
import {Fragment} from "react";
import Button from "../Low/Button";
export default function FillBlanksSolutions({prompt, solutions, text, userSolutions, onNext, onBack}: FillBlanksExercise & CommonProps) {
export default function FillBlanksSolutions({id, type, prompt, solutions, text, userSolutions, onNext, onBack}: FillBlanksExercise & CommonProps) {
const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = userSolutions.filter(
(x) => solutions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false,
).length;
const missing = total - userSolutions.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return {total, correct, missing};
};
const renderLines = (line: string) => {
return (
<span>
{reactStringReplace(line, /({{\d}})/g, (match) => {
{reactStringReplace(line, /({{\d+}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = userSolutions.find((x) => x.id === id);
const solution = solutions.find((x) => x.id === id)!;
@@ -18,7 +28,7 @@ export default function FillBlanksSolutions({prompt, solutions, text, userSoluti
return (
<button
className={clsx(
"rounded-full hover:text-white hover:bg-mti-blue transition duration-300 ease-in-out my-1 px-5 py-2 text-center text-white bg-mti-blue-light",
"rounded-full hover:text-white hover:bg-mti-red transition duration-300 ease-in-out my-1 px-5 py-2 text-center text-white bg-mti-red-light",
)}>
{solution.solution}
</button>
@@ -29,8 +39,8 @@ export default function FillBlanksSolutions({prompt, solutions, text, userSoluti
return (
<button
className={clsx(
"rounded-full hover:text-white hover:bg-mti-green transition duration-300 ease-in-out my-1",
userSolution && "px-5 py-2 text-center text-white bg-mti-green-light",
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
)}>
{solution.solution}
</button>
@@ -42,16 +52,16 @@ export default function FillBlanksSolutions({prompt, solutions, text, userSoluti
<>
<button
className={clsx(
"rounded-full hover:text-white hover:bg-mti-orange transition duration-300 ease-in-out my-1 mr-1",
userSolution && "px-5 py-2 text-center text-white bg-mti-orange-light",
"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.solution}
</button>
<button
className={clsx(
"rounded-full hover:text-white hover:bg-mti-green transition duration-300 ease-in-out my-1",
userSolution && "px-5 py-2 text-center text-white bg-mti-green-light",
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
)}>
{solution.solution}
</button>
@@ -75,7 +85,8 @@ export default function FillBlanksSolutions({prompt, solutions, text, userSoluti
))}
</span>
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
{text.split("\\n").map((line, index) => (
{userSolutions &&
text.split("\\n").map((line, index) => (
<p key={index}>
{renderLines(line)}
<br />
@@ -84,26 +95,33 @@ export default function FillBlanksSolutions({prompt, solutions, text, userSoluti
</span>
<div className="flex gap-4 items-center">
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-green" />
<div className="w-4 h-4 rounded-full bg-mti-purple" />
Correct
</div>
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-blue" />
<div className="w-4 h-4 rounded-full bg-mti-red" />
Unanswered
</div>
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-orange" />
<div className="w-4 h-4 rounded-full bg-mti-rose" />
Wrong
</div>
</div>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button color="green" variant="outline" onClick={onBack} className="max-w-[200px] w-full">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] w-full">
Back
</Button>
<Button color="green" onClick={() => onNext()} className="max-w-[200px] self-end w-full">
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>

View File

@@ -0,0 +1,124 @@
/* eslint-disable @next/next/no-img-element */
import {InteractiveSpeakingExercise} from "@/interfaces/exam";
import {CommonProps} from ".";
import {useEffect, useState} from "react";
import Button from "../Low/Button";
import dynamic from "next/dynamic";
import axios from "axios";
import {speakingReverseMarking} from "@/utils/score";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
export default function InteractiveSpeaking({
id,
type,
title,
text,
prompts,
userSolutions,
onNext,
onBack,
}: InteractiveSpeakingExercise & CommonProps) {
const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
useEffect(() => {
if (userSolutions && userSolutions.length > 0) {
Promise.all(userSolutions[0].solution.map((x) => axios.post(`/api/speaking`, {path: x.answer}, {responseType: "arraybuffer"}))).then(
(values) => {
setSolutionsURL(
values.map(({data}) => {
const blob = new Blob([data], {type: "audio/wav"});
const url = URL.createObjectURL(blob);
return url;
}),
);
},
);
}
}, [userSolutions]);
return (
<>
<div className="flex flex-col h-full w-full gap-8 mb-20">
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
<div className="flex flex-col gap-3">
<span className="font-semibold">{title}</span>
</div>
<div className="flex flex-col gap-4">
<span className="font-bold">You should talk about the following things:</span>
<div className="grid grid-cols-3 gap-6 text-center">
{prompts.map((x, index) => (
<div className="italic flex flex-col gap-2 text-sm" key={index}>
<video key={index} controls className="">
<source src={x.video_url} />
</video>
<span>{x.text}</span>
</div>
))}
</div>
</div>
</div>
<div className="w-full h-full flex flex-col gap-8">
<div className="flex items-center gap-8">
{solutionsURL.map((x, index) => (
<div
key={index}
className="w-full min-w-[460px] p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
<div className="flex gap-8 items-center justify-center py-8">
<Waveform audio={x} waveColor="#FCDDEC" progressColor="#EF5DA8" />
</div>
</div>
))}
</div>
{userSolutions && userSolutions.length > 0 && (
<div className="flex flex-col gap-4 w-full">
<div className="flex gap-4 px-1">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
<div className="bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2" key={key}>
{key}: Level {userSolutions[0].evaluation!.task_response[key]}
</div>
))}
</div>
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-mti-gray-smoke rounded-3xl">
{userSolutions[0].evaluation!.comment}
</div>
</div>
)}
</div>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() =>
onBack({
exercise: id,
solutions: userSolutions,
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Back
</Button>
<Button
color="purple"
onClick={() =>
onNext({
exercise: id,
solutions: userSolutions,
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</>
);
}

View File

@@ -9,7 +9,26 @@ import {Fragment} from "react";
import Button from "../Low/Button";
import Xarrow from "react-xarrows";
export default function MatchSentencesSolutions({options, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) {
export default function MatchSentencesSolutions({
id,
type,
options,
prompt,
sentences,
userSolutions,
onNext,
onBack,
}: MatchSentencesExercise & CommonProps) {
const calculateScore = () => {
const total = sentences.length;
const correct = userSolutions.filter(
(x) => sentences.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false,
).length;
const missing = total - userSolutions.filter((x) => sentences.find((y) => y.id.toString() === x.question.toString())).length;
return {total, correct, missing};
};
return (
<>
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
@@ -31,9 +50,9 @@ export default function MatchSentencesSolutions({options, prompt, sentences, use
className={clsx(
"w-8 h-8 rounded-full z-10 text-white",
"transition duration-300 ease-in-out",
!userSolutions.find((x) => x.question === id) && "!bg-mti-blue",
userSolutions.find((x) => x.question === id)?.option === solution && "bg-mti-green",
userSolutions.find((x) => x.question === id)?.option !== solution && "bg-mti-orange",
!userSolutions.find((x) => x.question.toString() === id.toString()) && "!bg-mti-red",
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>
@@ -46,7 +65,7 @@ export default function MatchSentencesSolutions({options, prompt, sentences, use
<button
id={id}
className={clsx(
"bg-mti-green-ultralight text-mti-green hover:text-white hover:bg-mti-green w-8 h-8 rounded-full z-10",
"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}
@@ -55,17 +74,18 @@ export default function MatchSentencesSolutions({options, prompt, sentences, use
</div>
))}
</div>
{sentences.map((sentence, index) => (
{userSolutions &&
sentences.map((sentence, index) => (
<Xarrow
key={index}
start={sentence.id}
end={sentence.solution}
lineColor={
!userSolutions.find((x) => x.question === sentence.id)
? "#0696ff"
? "#CC5454"
: userSolutions.find((x) => x.question === sentence.id)?.option === sentence.solution
? "#307912"
: "#FF6000"
? "#7872BF"
: "#CC5454"
}
showHead={false}
/>
@@ -73,23 +93,30 @@ export default function MatchSentencesSolutions({options, prompt, sentences, use
</div>
<div className="flex gap-4 items-center">
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-green" /> Correct
<div className="w-4 h-4 rounded-full bg-mti-purple" /> Correct
</div>
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-blue" /> Unanswered
<div className="w-4 h-4 rounded-full bg-mti-red" /> Unanswered
</div>
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-orange" /> Wrong
<div className="w-4 h-4 rounded-full bg-mti-rose" /> Wrong
</div>
</div>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button color="green" variant="outline" onClick={() => onBack()} className="max-w-[200px] w-full">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] w-full">
Back
</Button>
<Button color="green" onClick={() => onNext()} className="max-w-[200px] self-end w-full">
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>

View File

@@ -1,7 +1,7 @@
/* eslint-disable @next/next/no-img-element */
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
import clsx from "clsx";
import {useState} from "react";
import {useEffect, useState} from "react";
import {CommonProps} from ".";
import Button from "../Low/Button";
@@ -14,14 +14,14 @@ function Question({
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
const optionColor = (option: string) => {
if (option === solution && !userSolution) {
return "!border-mti-blue-light !text-mti-blue-light";
return "!border-mti-red-light !text-mti-red-light";
}
if (option === solution) {
return "!border-mti-green-light !text-mti-green-light";
return "!border-mti-purple-light !text-mti-purple-light";
}
return userSolution === option ? "!border-mti-orange-light !text-mti-orange-light" : "";
return userSolution === option ? "!border-mti-rose-light !text-mti-rose-light" : "";
};
return (
@@ -54,12 +54,35 @@ function Question({
);
}
export default function MultipleChoice({prompt, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
export default function MultipleChoice({
id,
type,
prompt,
questions,
userSolutions,
updateIndex,
onNext,
onBack,
}: MultipleChoiceExercise & CommonProps) {
const [questionIndex, setQuestionIndex] = useState(0);
const calculateScore = () => {
const total = questions.length;
const correct = userSolutions.filter(
(x) => questions.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false,
).length;
const missing = total - userSolutions.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length;
return {total, correct, missing};
};
useEffect(() => {
if (updateIndex) updateIndex(questionIndex);
}, [questionIndex, updateIndex]);
const next = () => {
if (questionIndex === questions.length - 1) {
onNext();
onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
} else {
setQuestionIndex((prev) => prev + 1);
}
@@ -67,7 +90,7 @@ export default function MultipleChoice({prompt, questions, userSolutions, onNext
const back = () => {
if (questionIndex === 0) {
onBack();
onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type});
} else {
setQuestionIndex((prev) => prev - 1);
}
@@ -78,7 +101,7 @@ export default function MultipleChoice({prompt, questions, userSolutions, onNext
<div className="flex flex-col gap-4 w-full h-full mb-20">
<div className="flex flex-col gap-2 mt-4 h-full bg-mti-gray-smoke rounded-xl px-16 py-8">
<span className="text-xl font-semibold">{prompt}</span>
{questionIndex < questions.length && (
{userSolutions && questionIndex < questions.length && (
<Question
{...questions[questionIndex]}
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option}
@@ -87,26 +110,26 @@ export default function MultipleChoice({prompt, questions, userSolutions, onNext
</div>
<div className="flex gap-4 items-center">
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-green" />
<div className="w-4 h-4 rounded-full bg-mti-purple" />
Correct
</div>
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-blue" />
<div className="w-4 h-4 rounded-full bg-mti-red" />
Unanswered
</div>
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-orange" />
<div className="w-4 h-4 rounded-full bg-mti-rose" />
Wrong
</div>
</div>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button color="green" variant="outline" onClick={back} className="max-w-[200px] w-full">
<Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full">
Back
</Button>
<Button color="green" onClick={next} className="max-w-[200px] self-end w-full">
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
Next
</Button>
</div>

View File

@@ -0,0 +1,120 @@
/* eslint-disable @next/next/no-img-element */
import {SpeakingExercise} from "@/interfaces/exam";
import {CommonProps} from ".";
import {Fragment, useEffect, useState} from "react";
import Button from "../Low/Button";
import dynamic from "next/dynamic";
import axios from "axios";
import {speakingReverseMarking} from "@/utils/score";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
export default function Speaking({id, type, title, video_url, text, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
const [solutionURL, setSolutionURL] = useState<string>();
useEffect(() => {
if (userSolutions && userSolutions.length > 0) {
axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => {
const blob = new Blob([data], {type: "audio/wav"});
const url = URL.createObjectURL(blob);
setSolutionURL(url);
});
}
}, [userSolutions]);
return (
<>
<div className="flex flex-col h-full w-full gap-8 mb-20">
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
<div className="flex flex-col gap-3">
<span className="font-semibold">{title}</span>
{!video_url && (
<span className="font-regular">
{text.split("\\n").map((line, index) => (
<Fragment key={index}>
<span>{line}</span>
<br />
</Fragment>
))}
</span>
)}
</div>
<div className="flex gap-6">
{video_url && (
<div className="flex flex-col gap-4 w-full items-center">
<video key={id} autoPlay controls className="max-w-3xl rounded-xl">
<source src={video_url} />
</video>
</div>
)}
{prompts && prompts.length > 0 && (
<div className="flex flex-col gap-4">
<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 className="w-full h-full flex flex-col gap-8">
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
<div className="flex gap-8 items-center justify-center py-8">
{solutionURL && <Waveform audio={solutionURL} waveColor="#FCDDEC" progressColor="#EF5DA8" />}
</div>
</div>
{userSolutions && userSolutions.length > 0 && (
<div className="flex flex-col gap-4 w-full">
<div className="flex gap-4 px-1">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
<div className="bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2" key={key}>
{key}: Level {userSolutions[0].evaluation!.task_response[key]}
</div>
))}
</div>
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-mti-gray-smoke rounded-3xl">
{userSolutions[0].evaluation!.comment}
</div>
</div>
)}
</div>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() =>
onBack({
exercise: id,
solutions: userSolutions,
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Back
</Button>
<Button
color="purple"
onClick={() =>
onNext({
exercise: id,
solutions: userSolutions,
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</>
);
}

View File

@@ -0,0 +1,136 @@
import {FillBlanksExercise, TrueFalseExercise} from "@/interfaces/exam";
import clsx from "clsx";
import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
import {Fragment} from "react";
import Button from "../Low/Button";
type Solution = "true" | "false" | "not_given";
export default function TrueFalseSolution({prompt, type, id, questions, userSolutions, onNext, onBack}: TrueFalseExercise & CommonProps) {
const calculateScore = () => {
const total = questions.length || 0;
const correct = userSolutions.filter(
(x) => questions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false,
).length;
const missing = total - userSolutions.filter((x) => questions.find((y) => x.id.toString() === y.id.toString())).length;
return {total, correct, missing};
};
const getButtonColor = (buttonSolution: Solution, solution: Solution, userSolution: Solution | undefined) => {
if (buttonSolution !== userSolution && buttonSolution !== solution) return "purple";
if (userSolution) {
if (userSolution === buttonSolution && solution === buttonSolution) {
return "purple";
}
if (solution === buttonSolution) {
return "purple";
}
return "rose";
}
return "red";
};
return (
<>
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</span>
<div className="flex flex-col gap-6 mb-4">
<p>For each of the questions below, select</p>
<div className="pl-8 flex gap-8">
<span className="flex flex-col gap-4">
<span className="font-bold italic">TRUE</span>
<span className="font-bold italic">FALSE</span>
<span className="font-bold italic">NOT GIVEN</span>
</span>
<span className="flex flex-col gap-4">
<span>if the statement agrees with the information</span>
<span>if the statement contradicts with the information</span>
<span>if there is no information on this</span>
</span>
</div>
</div>
<span className="text-sm w-full leading-6">You can click a selected option again to deselect it.</span>
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-8">
{userSolutions &&
questions.map((question, index) => {
const userSolution = userSolutions.find((x) => x.id === question.id.toString());
return (
<div key={question.id.toString()} className="flex flex-col gap-4">
<span>
{index + 1}. {question.prompt}
</span>
<div className="flex gap-4">
<Button
variant={question.solution === "true" || userSolution?.solution === "true" ? "solid" : "outline"}
className="!py-2"
color={getButtonColor("true", question.solution, userSolution?.solution)}>
True
</Button>
<Button
variant={question.solution === "false" || userSolution?.solution === "false" ? "solid" : "outline"}
className="!py-2"
color={getButtonColor("false", question.solution, userSolution?.solution)}>
False
</Button>
<Button
variant={
question.solution === "not_given" || userSolution?.solution === "not_given" ? "solid" : "outline"
}
className="!py-2"
color={getButtonColor("not_given", question.solution, userSolution?.solution)}>
Not Given
</Button>
</div>
</div>
);
})}
</div>
<div className="flex gap-4 items-center">
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-purple" />
Correct
</div>
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-red" />
Unanswered
</div>
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-rose" />
Wrong
</div>
</div>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] w-full">
Back
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</>
);
}

View File

@@ -29,7 +29,6 @@ function Blank({
useEffect(() => {
const words = userInput.split(" ").filter((x) => x !== "");
if (words.length >= maxWords) {
toast.warning(`You have reached your word limit of ${maxWords} words!`, {toastId: "word-limit"});
setUserInput(words.join(" ").trim());
if (setUserSolution) setUserSolution(words.join(" ").trim());
}
@@ -39,36 +38,35 @@ function Blank({
const getSolutionStyling = () => {
if (!userSolution) {
return "bg-mti-blue-ultralight text-mti-blue-light";
return "bg-mti-red-ultralight text-mti-red-light";
}
return "bg-mti-green-ultralight text-mti-green-light";
return "bg-mti-purple-ultralight text-mti-purple-light";
};
return (
<span className="inline-flex gap-2">
<span className="inline-flex gap-2 ml-2">
{userSolution && !isUserSolutionCorrect() && (
<input
className="py-2 px-3 rounded-2xl w-48 focus:outline-none my-2 bg-mti-orange-ultralight text-mti-orange-light"
<div
className="py-2 px-3 rounded-2xl w-fit focus:outline-none my-2 bg-mti-rose-ultralight text-mti-rose-light"
placeholder={id}
onChange={(e) => setUserInput(e.target.value)}
value={userSolution}
contentEditable={disabled}
/>
contentEditable={disabled}>
{userSolution}
</div>
)}
<input
className={clsx("py-2 px-3 rounded-2xl w-48 focus:outline-none my-2", getSolutionStyling())}
<div
className={clsx("py-2 px-3 rounded-2xl w-fit focus:outline-none my-2", getSolutionStyling())}
placeholder={id}
onChange={(e) => setUserInput(e.target.value)}
value={!solutions ? userInput : solutions.join(" / ")}
contentEditable={disabled}
/>
contentEditable={disabled}>
{!solutions ? userInput : solutions.join(" / ")}
</div>
</span>
);
}
export default function WriteBlanksSolutions({
id,
type,
prompt,
maxWords,
solutions,
@@ -77,15 +75,31 @@ export default function WriteBlanksSolutions({
onNext,
onBack,
}: WriteBlanksExercise & CommonProps) {
const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = userSolutions.filter(
(x) =>
solutions
.find((y) => x.id.toString() === y.id.toString())
?.solution.map((y) => y.toLowerCase().trim())
.includes(x.solution.toLowerCase().trim()) || false,
).length;
const missing = total - userSolutions.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return {total, correct, missing};
};
const renderLines = (line: string) => {
return (
<span className="text-base leading-5">
{reactStringReplace(line, /({{\d+}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = userSolutions.find((x) => x.id === id);
const solution = solutions.find((x) => x.id === id)!;
const id = match.replaceAll(/[\{\}]/g, "").toString();
const userSolution = userSolutions.find((x) => x.id.toString() === id.toString());
const solution = solutions.find((x) => x.id.toString() === id.toString())!;
return <Blank userSolution={userSolution?.solution} maxWords={maxWords} id={id} solutions={solution.solution} disabled />;
return (
<Blank userSolution={userSolution?.solution} maxWords={maxWords} id={id.toString()} solutions={solution.solution} disabled />
);
})}
</span>
);
@@ -103,7 +117,8 @@ export default function WriteBlanksSolutions({
))}
</span>
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
{text.split("\\n").map((line, index) => (
{userSolutions &&
text.split("\\n").map((line, index) => (
<p key={index}>
{renderLines(line)}
<br />
@@ -112,26 +127,33 @@ export default function WriteBlanksSolutions({
</span>
<div className="flex gap-4 items-center">
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-green" />
<div className="w-4 h-4 rounded-full bg-mti-purple" />
Correct
</div>
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-blue" />
<div className="w-4 h-4 rounded-full bg-mti-red" />
Unanswered
</div>
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-orange" />
<div className="w-4 h-4 rounded-full bg-mti-rose" />
Wrong
</div>
</div>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button color="green" variant="outline" onClick={() => onBack()} className="max-w-[200px] w-full">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] w-full">
Back
</Button>
<Button color="green" onClick={() => onNext()} className="max-w-[200px] self-end w-full">
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>

View File

@@ -9,8 +9,9 @@ import {Fragment, useEffect, useState} from "react";
import {toast} from "react-toastify";
import Button from "../Low/Button";
import {Dialog, Transition} from "@headlessui/react";
import {writingReverseMarking} from "@/utils/score";
export default function Writing({id, prompt, info, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
return (
@@ -96,11 +97,31 @@ export default function Writing({id, prompt, info, attachment, userSolutions, on
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button color="green" variant="outline" onClick={onBack} className="max-w-[200px] w-full">
<Button
color="purple"
variant="outline"
onClick={() =>
onBack({
exercise: id,
solutions: userSolutions,
score: {total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Back
</Button>
<Button color="green" onClick={() => onNext()} className="max-w-[200px] self-end w-full">
<Button
color="purple"
onClick={() =>
onNext({
exercise: id,
solutions: userSolutions,
score: {total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>

View File

@@ -1,28 +1,49 @@
import {Exercise, FillBlanksExercise, MatchSentencesExercise, MultipleChoiceExercise, WriteBlanksExercise, WritingExercise} from "@/interfaces/exam";
import {
Exercise,
FillBlanksExercise,
InteractiveSpeakingExercise,
MatchSentencesExercise,
MultipleChoiceExercise,
SpeakingExercise,
TrueFalseExercise,
UserSolution,
WriteBlanksExercise,
WritingExercise,
} from "@/interfaces/exam";
import dynamic from "next/dynamic";
import FillBlanks from "./FillBlanks";
import InteractiveSpeaking from "./InteractiveSpeaking";
import MultipleChoice from "./MultipleChoice";
import Speaking from "./Speaking";
import TrueFalseSolution from "./TrueFalse";
import WriteBlanks from "./WriteBlanks";
import Writing from "./Writing";
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), {ssr: false});
export interface CommonProps {
onNext: () => void;
onBack: () => void;
updateIndex?: (internalIndex: number) => void;
onNext: (userSolutions: UserSolution) => void;
onBack: (userSolutions: UserSolution) => void;
}
export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: () => void) => {
export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: () => void, updateIndex?: (internalIndex: number) => void) => {
switch (exercise.type) {
case "fillBlanks":
return <FillBlanks {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
case "trueFalse":
return <TrueFalseSolution {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
case "matchSentences":
return <MatchSentences {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
case "multipleChoice":
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} updateIndex={updateIndex} onNext={onNext} onBack={onBack} />;
case "writeBlanks":
return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
case "writing":
return <Writing {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
case "speaking":
return <Speaking {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
case "interactiveSpeaking":
return <InteractiveSpeaking {...(exercise as InteractiveSpeakingExercise)} onNext={onNext} onBack={onBack} />;
}
};

View File

@@ -0,0 +1,49 @@
import {Dialog, Transition} from "@headlessui/react";
import {Fragment} from "react";
import Button from "./Low/Button";
interface Props {
isOpen: boolean;
onClose: () => void;
}
export default function TimerEndedModal({isOpen, onClose}: Props) {
return (
<Transition show={isOpen} as={Fragment}>
<Dialog onClose={onClose} className="relative z-50">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-black/30" />
</Transition.Child>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95">
<div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="w-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
<Dialog.Title className="font-bold text-xl">Time&apos;s up!</Dialog.Title>
<span>
The timer has ended! Your answers have been registered and saved, you will now move on to the next module (or to the
finish screen, if this was the last one).
</span>
<Button color="purple" onClick={onClose} className="max-w-[200px] self-end w-full mt-8">
Continue
</Button>
</Dialog.Panel>
</div>
</Transition.Child>
</Dialog>
</Transition>
);
}

489
src/components/UserCard.tsx Normal file
View File

@@ -0,0 +1,489 @@
import useStats from "@/hooks/useStats";
import {EMPLOYMENT_STATUS, User} from "@/interfaces/user";
import {groupBySession, averageScore} from "@/utils/stats";
import {RadioGroup} from "@headlessui/react";
import axios from "axios";
import clsx from "clsx";
import moment from "moment";
import {Divider} from "primereact/divider";
import {useEffect, useState} from "react";
import ReactDatePicker from "react-datepicker";
import {BsFileEarmarkText, BsPencil, BsStar} from "react-icons/bs";
import {toast} from "react-toastify";
import Button from "./Low/Button";
import Checkbox from "./Low/Checkbox";
import CountrySelect from "./Low/CountrySelect";
import Input from "./Low/Input";
import ProfileSummary from "./ProfileSummary";
import Select from "react-select";
import useUsers from "@/hooks/useUsers";
import {USER_TYPE_LABELS} from "@/resources/user";
import {CURRENCIES} from "@/resources/paypal";
const expirationDateColor = (date: Date) => {
const momentDate = moment(date);
const today = moment(new Date());
if (today.add(1, "days").isAfter(momentDate)) return "!bg-mti-red-ultralight border-mti-red-light";
if (today.add(3, "days").isAfter(momentDate)) return "!bg-mti-rose-ultralight border-mti-rose-light";
if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light";
};
interface Props {
user: User;
loggedInUser: User;
onClose: (reload?: boolean) => void;
onViewStudents?: () => void;
onViewTeachers?: () => void;
onViewCorporate?: () => void;
}
const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers, onViewCorporate}: Props) => {
const [expiryDate, setExpiryDate] = useState<Date | null | undefined>(user.subscriptionExpirationDate);
const [type, setType] = useState(user.type);
const [status, setStatus] = useState(user.status);
const [referralAgentLabel, setReferralAgentLabel] = useState<string>();
const [position, setPosition] = useState<string | undefined>(user.type === "corporate" ? user.demographicInformation?.position : undefined);
const [referralAgent, setReferralAgent] = useState(user.type === "corporate" ? user.corporateInformation?.referralAgent : undefined);
const [companyName, setCompanyName] = useState(
user.type === "corporate"
? user.corporateInformation?.companyInformation.name
: user.type === "agent"
? user.agentInformation.companyName
: undefined,
);
const [commercialRegistration, setCommercialRegistration] = useState(
user.type === "agent" ? user.agentInformation.commercialRegistration : undefined,
);
const [userAmount, setUserAmount] = useState(user.type === "corporate" ? user.corporateInformation?.companyInformation.userAmount : undefined);
const [paymentValue, setPaymentValue] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.value : undefined);
const [paymentCurrency, setPaymentCurrency] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.currency : undefined);
const [monthlyDuration, setMonthlyDuration] = useState(user.type === "corporate" ? user.corporateInformation?.monthlyDuration : undefined);
const {stats} = useStats(user.id);
const {users} = useUsers();
useEffect(() => {
if (users && users.length > 0) {
if (!referralAgent) {
setReferralAgentLabel("No manager");
return;
}
const agent = users.find((x) => x.id === referralAgent);
setReferralAgentLabel(`${agent?.name} - ${agent?.email}`);
}
}, [users, referralAgent]);
const updateUser = () => {
if (user.type === "corporate" && (!paymentValue || paymentValue < 0))
return toast.error("Please set a price for the user's package before updating!");
if (!confirm(`Are you sure you want to update ${user.name}'s account?`)) return;
axios
.post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {
...user,
subscriptionExpirationDate: expiryDate,
type,
status,
agentInformation:
type === "agent"
? {
companyName,
commercialRegistration,
}
: undefined,
corporateInformation:
type === "corporate"
? {
referralAgent,
monthlyDuration,
companyInformation: {
companyName,
userAmount,
},
payment: {
value: paymentValue,
currency: paymentCurrency,
},
}
: undefined,
})
.then(() => {
toast.success("User updated successfully!");
onClose(true);
})
.catch(() => {
toast.error("Something went wrong!", {toastId: "update-error"});
});
};
return (
<>
<ProfileSummary
user={user}
items={[
{
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: Object.keys(groupBySession(stats)).length,
label: "Exams",
},
{
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: stats.length,
label: "Exercises",
},
{
icon: <BsStar className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
label: "Average Score",
},
]}
/>
{user.type === "agent" && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full">
<Input
label="Company Name"
type="text"
name="companyName"
onChange={setCompanyName}
placeholder="Enter company name"
defaultValue={companyName}
required
/>
<Input
label="Commercial Registration"
type="text"
name="commercialRegistration"
onChange={setCommercialRegistration}
placeholder="Enter company name"
defaultValue={commercialRegistration}
required
/>
</div>
<Divider className="w-full !m-0" />
</>
)}
{user.type === "corporate" && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 w-full">
<Input
label="Company Name"
type="text"
name="companyName"
onChange={setCompanyName}
placeholder="Enter company name"
defaultValue={companyName}
/>
<Input
label="Number of Users"
type="number"
name="userAmount"
onChange={(e) => setUserAmount(e ? parseInt(e) : undefined)}
placeholder="Enter number of users"
defaultValue={userAmount}
/>
<Input
label="Monthly Duration"
type="number"
name="monthlyDuration"
onChange={(e) => setMonthlyDuration(e ? parseInt(e) : undefined)}
placeholder="Enter monthly duration"
defaultValue={monthlyDuration}
/>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Country Manager</label>
{referralAgentLabel && (
<Select
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
options={[
{value: "", label: "No referral"},
...users.filter((u) => u.type === "agent").map((x) => ({value: x.id, label: `${x.name} - ${x.email}`})),
]}
defaultValue={{
value: referralAgent,
label: referralAgentLabel,
}}
onChange={(value) => setReferralAgent(value?.value)}
styles={{
control: (styles) => ({
...styles,
paddingLeft: "4px",
border: "none",
outline: "none",
":focus": {
outline: "none",
},
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
)}
</div>
<div className="flex flex-col gap-3 w-full lg:col-span-2">
<label className="font-normal text-base text-mti-gray-dim">Pricing</label>
<div className="w-full grid grid-cols-5 gap-2">
<Input
name="paymentValue"
onChange={(e) => setPaymentValue(e ? parseInt(e) : undefined)}
type="number"
defaultValue={paymentValue || 0}
className="col-span-3"
/>
<select
defaultValue={paymentCurrency}
onChange={(e) => setPaymentCurrency(e.target.value)}
className="p-6 col-span-2 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
{CURRENCIES.map(({label, currency}) => (
<option value={currency} key={currency}>
{label}
</option>
))}
</select>
</div>
</div>
</div>
<Divider className="w-full !m-0" />
</>
)}
<section className="flex flex-col gap-4 justify-between">
<div className="flex flex-col md:flex-row gap-8 w-full">
<Input
label="Name"
type="text"
name="name"
onChange={() => null}
placeholder="Enter your name"
defaultValue={user.name}
disabled
/>
<Input
label="E-mail Address"
type="email"
name="email"
onChange={() => null}
placeholder="Enter email address"
defaultValue={user.email}
disabled
/>
</div>
<div className="flex flex-col md:flex-row gap-8 w-full">
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Country</label>
<CountrySelect disabled value={user.demographicInformation?.country} />
</div>
<Input
type="tel"
name="phone"
label="Phone number"
onChange={() => null}
placeholder="Enter phone number"
defaultValue={user.demographicInformation?.phone}
disabled
/>
</div>
<div className="flex flex-col md:flex-row gap-8 w-full">
{user.type !== "corporate" && (
<div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Employment Status</label>
<RadioGroup
value={user.demographicInformation?.employment}
className="grid grid-cols-2 items-center gap-4 place-items-center">
{EMPLOYMENT_STATUS.map(({status, label}) => (
<RadioGroup.Option value={status} key={status}>
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-40 md:w-48 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked
? "bg-white border-mti-gray-platinum"
: "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
{label}
</span>
)}
</RadioGroup.Option>
))}
</RadioGroup>
</div>
)}
{user.type === "corporate" && (
<Input
name="position"
onChange={setPosition}
type="text"
label="Position"
defaultValue={position}
placeholder="CEO, Head of Marketing..."
disabled
required
/>
)}
<div className="flex flex-col gap-8 w-full">
<div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Gender</label>
<RadioGroup value={user.demographicInformation?.gender} className="flex flex-row gap-4 justify-between">
<RadioGroup.Option value="male">
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked
? "bg-white border-mti-gray-platinum"
: "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
Male
</span>
)}
</RadioGroup.Option>
<RadioGroup.Option value="female">
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked
? "bg-white border-mti-gray-platinum"
: "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
Female
</span>
)}
</RadioGroup.Option>
<RadioGroup.Option value="other">
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked
? "bg-white border-mti-gray-platinum"
: "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
Other
</span>
)}
</RadioGroup.Option>
</RadioGroup>
</div>
<div className="flex flex-col gap-3">
<div className="flex justify-between items-center">
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
<Checkbox
isChecked={!!expiryDate}
onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : null)}>
Enabled
</Checkbox>
</div>
{!expiryDate && (
<div
className={clsx(
"p-6 w-full flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!expiryDate ? "!bg-mti-green-ultralight !border-mti-green-light" : expirationDateColor(expiryDate),
"bg-white border-mti-gray-platinum",
)}>
{!expiryDate && "Unlimited"}
{expiryDate && moment(expiryDate).format("DD/MM/YYYY")}
</div>
)}
{expiryDate && (
<ReactDatePicker
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip",
expirationDateColor(expiryDate),
"transition duration-300 ease-in-out",
)}
filterDate={(date) =>
moment(date).isAfter(new Date()) &&
(loggedInUser.subscriptionExpirationDate
? moment(date).isBefore(moment(loggedInUser.subscriptionExpirationDate))
: true)
}
dateFormat="dd/MM/yyyy"
selected={moment(expiryDate).toDate()}
onChange={(date) => setExpiryDate(date)}
/>
)}
</div>
</div>
</div>
{(loggedInUser.type === "developer" || loggedInUser.type === "admin") && (
<>
<Divider className="w-full !m-0" />
<div className="flex flex-col md:flex-row gap-8 w-full">
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Status</label>
<select
defaultValue={user.status}
onChange={(e) => setStatus(e.target.value as typeof user.status)}
className="p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
<option value="active">Active</option>
<option value="disabled">Disabled</option>
<option value="paymentDue">Payment Due</option>
</select>
</div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Type</label>
<select
defaultValue={user.type}
onChange={(e) => setType(e.target.value as typeof user.type)}
className="p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
{Object.keys(USER_TYPE_LABELS).map((type) => (
<option key={type} value={type}>
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
</option>
))}
</select>
</div>
</div>
</>
)}
</section>
<div className="flex gap-4 justify-between mt-4 w-full">
<div className="self-start flex gap-4 justify-start items-center w-full">
{onViewCorporate && (
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewCorporate}>
View Corporate
</Button>
)}
{onViewStudents && (
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewStudents}>
View Students
</Button>
)}
{onViewTeachers && (
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewTeachers}>
View Teachers
</Button>
)}
</div>
<div className="self-end flex gap-4 w-full justify-end">
<Button className="w-full max-w-[200px]" variant="outline" onClick={onClose}>
Close
</Button>
<Button onClick={updateUser} className="w-full max-w-[200px]">
Update
</Button>
</div>
</div>
</>
);
};
export default UserCard;

View File

@@ -1,31 +0,0 @@
import {SEMI_TRANSPARENT} from "@/resources/colors";
import {Chart as ChartJS, RadialLinearScale, ArcElement, Tooltip, Legend} from "chart.js";
import clsx from "clsx";
import {PolarArea} from "react-chartjs-2";
import {Chart} from "primereact/chart";
interface Props {
data: {label: string; value: number}[];
label?: string;
title: string;
type: string;
colors?: string[];
}
ChartJS.register(RadialLinearScale, ArcElement, Tooltip, Legend);
export default function SingleDatasetChart({data, type, label, title, colors = Object.values(SEMI_TRANSPARENT)}: Props) {
const labels = data.map((x) => x.label);
const chartData = {
labels,
datasets: [
{
label,
data: data.map((x) => x.value),
backgroundColor: colors,
},
],
};
return <Chart type={type} data={chartData} options={{plugins: {title: {text: title, display: true}}}} />;
}

10
src/constants/errors.ts Normal file
View File

@@ -0,0 +1,10 @@
export type Error = "E001" | "E002";
export interface ErrorMessage {
error: Error;
message: string;
}
export const errorMessages: {[key in Error]: string} = {
E001: "Wrong password!",
E002: "Invalid e-mail",
};

View File

@@ -1,21 +1,84 @@
import {Module} from "@/interfaces";
export const MODULES: Module[] = ["reading", "listening", "writing", "speaking"];
export const BAND_SCORES: {[key in Module]: number[]} = {
reading: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
listening: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
writing: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
speaking: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
level: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
};
export const LEVEL_TEXT = {
excellent:
"Congratulations on your exam performance! You achieved an impressive {{level}}, demonstrating excellent mastery of the assessed knowledge.\n\nIf you disagree with the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of the results.\n\nPlease contact us for further information. Congratulations again on your outstanding achievement! We are here to support you on your academic journey.",
high: "Congratulations on your exam performance! You achieved a commendable {{level}}, demonstrating a good understanding of the assessed knowledge.\n\nIf you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of the results.\n\nPlease contact us for further information. Congratulations again on your achievement! We are here to support you on your academic journey.",
medium: "Congratulations on your exam performance! You achieved a {{level}}, demonstrating a satisfactory understanding of the assessed knowledge.\n\nIf you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of the results.\n\nPlease contact us for further information. Congratulations again on your achievement! We are here to support you on your academic journey.",
low: "Thank you for taking the exam. You achieved a {{level}}, but unfortunately, it did not meet the required standards.\n\nIf you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of the results.\n\nPlease contact us for further information. We encourage you to continue your studies and wish you the best of luck in your future endeavors.",
};
export const levelText = (level: number) => {
export const moduleResultText = (level: number) => {
if (level === 9) {
return (
<>
Congratulations on your exam performance! You achieved an impressive <span className="font-bold">level {level}</span>, demonstrating
excellent mastery of the assessed knowledge.
<br />
<br />
If you disagree with the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of
the results.
<br />
<br />
Please contact us for further information. Congratulations again on your outstanding achievement! We are here to support you on your
academic journey.
</>
);
}
if (level >= 6) {
return (
<>
Congratulations on your exam performance! You achieved a commendable <span className="font-bold">level {level}</span>, demonstrating a
good understanding of the assessed knowledge.
<br />
<br />
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
transparency of the results.
<br />
<br />
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
journey.
</>
);
}
if (level >= 3) {
return (
<>
Congratulations on your exam performance! You achieved a <span className="font-bold">level of {level}</span>, demonstrating a
satisfactory understanding of the assessed knowledge.
<br />
<br />
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
transparency of the results.
<br />
<br />
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
journey.
</>
);
}
return (
<>
Thank you for taking the exam. You achieved a <span className="font-bold">level {level}</span>, but unfortunately, it did not meet the
required standards.
<br />
<br />
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
transparency of the results.
<br />
<br />
Please contact us for further information. We encourage you to continue your studies and wish you the best of luck in your future
endeavors.
</>
);
};
export const levelResultText = (level: number) => {
if (level === 9) {
return (
<>

View File

@@ -0,0 +1,13 @@
{
"type": "service_account",
"project_id": "mti-ielts",
"private_key_id": "22b783a14c760d1215a8d1f5de0fa40a33a840e7",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDoNkd7s/izUBRb\nlmJYWl0xk4X9wEVJU4LKA4HPeha8RFDse4T4suVP08oCP9ODSXF5A83+IqXNMs/N\na7PtFABBAx433JrB7I4NsAUrDSjI4LeYEIqh6YzHsQvBU53HAmPChX525S4i0IBy\ncNnyXut0nmlHz5ZwCPXgqg4eN44C+m0f7sxzivcnPth/zLupnMiDAHFZrxQolWO2\n6JfozMWGw0TmCkUxngzeGBMVYmsGiKRIxEi3MWeuwjYjGO4nR1krEUlcpjCbx4UX\nxYXicJb17HOs9LTcSh9bpDWZPHKXR48hxd2cMLr+XQzw7Otwu2p8fEUOJ+CiTyNz\nlkN9p7OhAgMBAAECggEAB5DsMZdGu1X4wdazr+AK4RCG2UKkZ0wbqvgkCMX4O2xo\n7BmmtqFCmEAk+P+KJWEVW81wTu9jUl0tWOrBVzBThUrEF2seVkL+SmshsfpI6cmr\npb5lO/sTgZau1L7kGU3GQRpvKVHUl+EODFyJt2xZFOjL8qFsjAw4sbgsw1aJT6a4\nFilm6Gapi1qSKOPSlXVmi0NJ9DUtNbKaQK8/coqEJRizeXs9MORvzyKQaV8PBmWI\noEnkxahKOD48U2kmI7rT9/YsCuaP2BlGdLxvANXLjAKcrDccVZkYEH82tPtCicED\noow3i956HPdWSXQgUOU65MfGccjOmqGaGa4zUTICyQKBgQD6zLMwL9YS+n9EKZaK\nEbzRybN2d+eKbXyDJzkDi6FnSGVre2ndShsimoOtwZDLmOF/XhN79YOLJVbI124p\npAWO+WxAfe9Xy3iFEBmL4kSREA873Sd8EN5OfYS2DsN7IbjZkoaLuM8QlyXL9ZRS\nBJDVGjx+wFKRjnClcBNbVMMXiQKBgQDtBumKZS0ZCtJuBeuwLGJ1ZJtYECykIrsD\nUtQ7zxwXJzPGqZ2c5JLpHdDm/bb9nllpLsh4SpDRqxFa2H2FF8x5KWaS7JQUsS8e\ner6x5wUt6wAJqV/ZvttVrLZCa8VYn+K7bTANnkPNJZHTqBTJbxkXMDTtkwWXUN2z\nQP3N9lodWQKBgFBHiewYw9ubV3WIImnbt6cne0ymoPUMitioi3V5Epcu81fuTzrI\nZ9sxvoi19xVUwIm2oWICerLlptvvKZImsKjNajtSlHRz6wYc2zCNowkULOwqpGLw\nO1jAkOR94VDewH7UikDbTVywJSceWvXOBFZSaZ7hDQ0OnTw3ndqUTUaRAoGAd2BG\n2PPyDa28o7sJpBYGlJdSAb1LrnLre1YJHAJIZITS99hPUEhykUP6BYx80CkjYO01\n/BeZ7m9Y80cbmJ+O1Or8BT1vqyg90f0B8/mlSyYTQ8pxQupz7ydoN/WtU+BawgjQ\n7drqzPSCCHab2YPBwEMANTMZ2sbYkcJG0aekZSkCgYBbnFJm8kUy57isxHyvrci+\nR30KQl2Y9okPytF8PpLH+yNjLDoduTOHL/hZoFC0M4Gklx4wPKpsEhImIrWmG9VC\n0UrQC6TT1WoY6/S3YehVmTXo/nBPD1XTUcbF/xxUrWDjmMjnt1IlXBbIzUPD3U4P\niRXzHnXb7yi+/iRxSDts2w==\n-----END PRIVATE KEY-----\n",
"client_email": "firebase-adminsdk-dyg6p@mti-ielts.iam.gserviceaccount.com",
"client_id": "104980563453519094431",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-dyg6p%40mti-ielts.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}

View File

@@ -0,0 +1,39 @@
import {Type} from "@/interfaces/user";
export const PERMISSIONS = {
generateCode: {
student: ["corporate", "developer", "admin"],
teacher: ["corporate", "developer", "admin"],
corporate: ["admin", "developer"],
admin: ["developer", "admin"],
agent: ["developer", "admin"],
developer: ["developer"],
},
deleteUser: {
student: ["teacher", "corporate", "developer", "admin"],
teacher: ["corporate", "developer", "admin"],
corporate: ["admin", "developer"],
admin: ["developer", "admin"],
agent: ["developer", "admin"],
developer: ["developer"],
},
updateUser: {
student: ["teacher", "corporate", "developer", "admin"],
teacher: ["corporate", "developer", "admin"],
corporate: ["admin", "developer"],
admin: ["developer", "admin"],
agent: ["developer", "admin"],
developer: ["developer"],
},
updateExpiryDate: {
student: ["developer", "admin"],
teacher: ["developer", "admin"],
corporate: ["admin", "developer"],
admin: ["developer", "admin"],
agent: ["developer", "admin"],
developer: ["developer"],
},
examManagement: {
delete: ["developer", "admin"],
},
};

460
src/dashboards/Admin.tsx Normal file
View File

@@ -0,0 +1,460 @@
/* eslint-disable @next/next/no-img-element */
import Modal from "@/components/Modal";
import useStats from "@/hooks/useStats";
import useUsers from "@/hooks/useUsers";
import {User} from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils";
import moment from "moment";
import {useEffect, useState} from "react";
import {
BsArrowLeft,
BsBriefcaseFill,
BsGlobeCentralSouthAsia,
BsPerson,
BsPersonFill,
BsPersonFillGear,
BsPersonGear,
BsPersonLinesFill,
} from "react-icons/bs";
import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups";
import IconCard from "./IconCard";
import useFilterStore from "@/stores/listFilterStore";
import {useRouter} from "next/router";
interface Props {
user: User;
}
export default function AdminDashboard({user}: Props) {
const [page, setPage] = useState("");
const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false);
const {stats} = useStats(user.id);
const {users, reload} = useUsers();
const {groups} = useGroups();
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter();
useEffect(() => {
setShowModal(!!selectedUser && page === "");
}, [selectedUser, page]);
const UserDisplay = (displayUser: User) => (
<div
onClick={() => setSelectedUser(displayUser)}
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
<div className="flex flex-col gap-1 items-start">
<span>
{displayUser.type === "corporate"
? displayUser.corporateInformation?.companyInformation?.name || displayUser.name
: displayUser.name}
</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
const StudentsList = () => {
const filter = (x: User) =>
x.type === "student" &&
(!!selectedUser
? groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.flatMap((g) => g.participants)
.includes(x.id)
: true);
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
</div>
<UserList user={user} filters={[filter]} />
</>
);
};
const TeachersList = () => {
const filter = (x: User) =>
x.type === "teacher" &&
(!!selectedUser
? groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.flatMap((g) => g.participants)
.includes(x.id) || false
: true);
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Teachers ({users.filter(filter).length})</h2>
</div>
<UserList user={user} filters={[filter]} />
</>
);
};
const AgentsList = () => {
const filter = (x: User) => x.type === "agent";
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Country Managers ({users.filter(filter).length})</h2>
</div>
<UserList user={user} filters={[filter]} />
</>
);
};
const CorporateList = () => (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Corporate ({users.filter((x) => x.type === "corporate").length})</h2>
</div>
<UserList user={user} filters={[(x) => x.type === "corporate"]} />
</>
);
const InactiveStudentsList = () => {
const filter = (x: User) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Inactive Students ({users.filter(filter).length})</h2>
</div>
<UserList user={user} filters={[filter]} />
</>
);
};
const InactiveCorporateList = () => {
const filter = (x: User) => x.type === "corporate" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Inactive Corporate ({users.filter(filter).length})</h2>
</div>
<UserList user={user} filters={[filter]} />
</>
);
};
const DefaultDashboard = () => (
<>
<section className="w-full flex flex-wrap gap-4 items-center justify-between">
<IconCard
Icon={BsPersonFill}
label="Students"
value={users.filter((x) => x.type === "student").length}
onClick={() => setPage("students")}
color="purple"
/>
<IconCard
Icon={BsPersonLinesFill}
label="Teachers"
value={users.filter((x) => x.type === "teacher").length}
onClick={() => setPage("teachers")}
color="purple"
/>
<IconCard
Icon={BsPersonLinesFill}
label="Corporate"
value={users.filter((x) => x.type === "corporate").length}
onClick={() => setPage("corporate")}
color="purple"
/>
<IconCard
Icon={BsBriefcaseFill}
label="Country Managers"
value={users.filter((x) => x.type === "agent").length}
onClick={() => setPage("agents")}
color="purple"
/>
<IconCard
Icon={BsGlobeCentralSouthAsia}
label="Countries"
value={[...new Set(users.filter((x) => x.demographicInformation).map((x) => x.demographicInformation?.country))].length}
color="purple"
/>
<IconCard
onClick={() => setPage("inactiveStudents")}
Icon={BsPerson}
label="Inactive Students"
value={
users.filter((x) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
.length
}
color="rose"
/>
<IconCard
onClick={() => setPage("inactiveCorporate")}
Icon={BsPerson}
label="Inactive Corporate"
value={
users.filter((x) => x.type === "corporate" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
.length
}
color="rose"
/>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between">
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter((x) => x.type === "student")
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest corporate</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter((x) => x.type === "corporate")
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Unpaid Corporate</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter((x) => x.type === "corporate" && x.status === "paymentDue")
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Students expiring in 1 month</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(
(x) =>
x.type === "student" &&
x.subscriptionExpirationDate &&
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
moment().isBefore(moment(x.subscriptionExpirationDate)),
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Teachers expiring in 1 month</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(
(x) =>
x.type === "teacher" &&
x.subscriptionExpirationDate &&
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
moment().isBefore(moment(x.subscriptionExpirationDate)),
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Corporate expiring in 1 month</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(
(x) =>
x.type === "corporate" &&
x.subscriptionExpirationDate &&
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
moment().isBefore(moment(x.subscriptionExpirationDate)),
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Expired Students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(
(x) => x.type === "student" && x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)),
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Expired Teachers</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(
(x) => x.type === "teacher" && x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)),
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Expired Corporate</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(
(x) =>
x.type === "corporate" && x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)),
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
</section>
</>
);
return (
<>
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
<>
{selectedUser && (
<div className="w-full flex flex-col gap-8">
<UserCard
loggedInUser={user}
onClose={(shouldReload) => {
setSelectedUser(undefined);
if (shouldReload) reload();
}}
onViewStudents={
selectedUser.type === "corporate" || selectedUser.type === "teacher"
? () => {
appendUserFilters({
id: "view-students",
filter: (x: User) => x.type === "student",
});
appendUserFilters({
id: "belongs-to-admin",
filter: (x: User) =>
groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.flatMap((g) => g.participants)
.includes(x.id),
});
router.push("/list/users");
}
: undefined
}
onViewTeachers={
selectedUser.type === "corporate" || selectedUser.type === "student"
? () => {
appendUserFilters({
id: "view-teachers",
filter: (x: User) => x.type === "teacher",
});
appendUserFilters({
id: "belongs-to-admin",
filter: (x: User) =>
groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.flatMap((g) => g.participants)
.includes(x.id),
});
router.push("/list/users");
}
: undefined
}
onViewCorporate={
selectedUser.type === "teacher" || selectedUser.type === "student"
? () => {
appendUserFilters({
id: "view-corporate",
filter: (x: User) => x.type === "corporate",
});
appendUserFilters({
id: "belongs-to-admin",
filter: (x: User) =>
groups
.filter((g) => g.participants.includes(selectedUser.id))
.flatMap((g) => [g.admin, ...g.participants])
.includes(x.id),
});
router.push("/list/users");
}
: undefined
}
user={selectedUser}
/>
</div>
)}
</>
</Modal>
{page === "students" && <StudentsList />}
{page === "teachers" && <TeachersList />}
{page === "corporate" && <CorporateList />}
{page === "agents" && <AgentsList />}
{page === "inactiveStudents" && <InactiveStudentsList />}
{page === "inactiveCorporate" && <InactiveCorporateList />}
{page === "" && <DefaultDashboard />}
</>
);
}

183
src/dashboards/Agent.tsx Normal file
View File

@@ -0,0 +1,183 @@
/* eslint-disable @next/next/no-img-element */
import Modal from "@/components/Modal";
import useStats from "@/hooks/useStats";
import useUsers from "@/hooks/useUsers";
import {Group, Stat, User} from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils";
import moment from "moment";
import {useEffect, useState} from "react";
import {
BsArrowLeft,
BsClipboard2Data,
BsClipboard2DataFill,
BsClock,
BsGlobeCentralSouthAsia,
BsPaperclip,
BsPerson,
BsPersonAdd,
BsPersonFill,
BsPersonFillGear,
BsPersonGear,
BsPersonLinesFill,
} from "react-icons/bs";
import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups";
import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {Module} from "@/interfaces";
import {groupByExam} from "@/utils/stats";
import IconCard from "./IconCard";
import GroupList from "@/pages/(admin)/Lists/GroupList";
interface Props {
user: User;
}
export default function AgentDashboard({user}: Props) {
const [page, setPage] = useState("");
const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false);
const {stats} = useStats();
const {users, reload} = useUsers();
const {groups} = useGroups(user.id);
useEffect(() => {
setShowModal(!!selectedUser && page === "");
}, [selectedUser, page]);
const corporateFilter = (user: User) => user.type === "corporate";
const referredCorporateFilter = (x: User) =>
x.type === "corporate" && !!x.corporateInformation && x.corporateInformation.referralAgent === user.id;
const UserDisplay = (displayUser: User) => (
<div
onClick={() => setSelectedUser(displayUser)}
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
<div className="flex flex-col gap-1 items-start">
<span>
{displayUser.type === "corporate"
? displayUser.corporateInformation?.companyInformation?.name || displayUser.name
: displayUser.name}
</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
const ReferredCorporateList = () => {
const filter = (x: User) => x.type === "corporate" && !!x.corporateInformation && x.corporateInformation.referralAgent === user.id;
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Referred Corporate ({users.filter(filter).length})</h2>
</div>
<UserList user={user} filters={[filter]} />
</>
);
};
const CorporateList = () => {
const filter = (x: User) => x.type === "corporate";
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Referred Corporate ({users.filter(filter).length})</h2>
</div>
<UserList user={user} filters={[filter]} />
</>
);
};
const DefaultDashboard = () => (
<>
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:gap-4 text-center">
<IconCard
onClick={() => setPage("referredCorporate")}
Icon={BsPersonFill}
label="Referred Corporate"
value={users.filter(referredCorporateFilter).length}
color="purple"
/>
<IconCard
onClick={() => setPage("corporate")}
Icon={BsPersonFill}
label="Corporate"
value={users.filter(corporateFilter).length}
color="purple"
/>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest Referred Corporate</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(referredCorporateFilter)
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest corporate</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(corporateFilter)
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
</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 === "" && <DefaultDashboard />}
</>
);
}

View File

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

View File

@@ -0,0 +1,313 @@
import Input from "@/components/Low/Input";
import Modal from "@/components/Modal";
import {Module} from "@/interfaces";
import clsx from "clsx";
import {useState} from "react";
import {BsBook, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
import {generate} from "random-words";
import {capitalize} from "lodash";
import useUsers from "@/hooks/useUsers";
import {Group, User} from "@/interfaces/user";
import ProgressBar from "@/components/Low/ProgressBar";
import {calculateAverageLevel} from "@/utils/score";
import Button from "@/components/Low/Button";
import ReactDatePicker from "react-datepicker";
import moment from "moment";
import axios from "axios";
import {getExam} from "@/utils/exams";
import {toast} from "react-toastify";
import {uuidv4} from "@firebase/util";
import {Assignment} from "@/interfaces/results";
interface Props {
isCreating: boolean;
assigner: string;
users: User[];
groups: Group[];
assignment?: Assignment;
cancelCreation: () => void;
}
export default function AssignmentCreator({isCreating, assignment, assigner, groups, users, cancelCreation}: Props) {
const [selectedModules, setSelectedModules] = useState<Module[]>(assignment?.exams.map((e) => e.module) || []);
const [assignees, setAssignees] = useState<string[]>(assignment?.assignees || []);
const [name, setName] = useState(assignment?.name || generate({minLength: 6, maxLength: 8, min: 2, max: 3, join: " ", formatter: capitalize}));
const [isLoading, setIsLoading] = useState(false);
const [startDate, setStartDate] = useState<Date | null>(assignment ? moment(assignment.startDate).toDate() : moment().add(1, "day").toDate());
const [endDate, setEndDate] = useState<Date | null>(assignment ? moment(assignment.endDate).toDate() : moment().add(8, "day").toDate());
const toggleModule = (module: Module) => {
const modules = selectedModules.filter((x) => x !== module);
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module]));
};
const toggleAssignee = (user: User) => {
setAssignees((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id]));
};
const createAssignment = () => {
setIsLoading(true);
const examPromises = selectedModules.map(async (module) => getExam(module, false));
Promise.all(examPromises)
.then((exams) => {
(assignment ? axios.patch : axios.post)(`/api/assignments${assignment ? `/${assignment.id}` : ""}`, {
assigner,
assignees,
name,
startDate,
endDate,
results: [],
exams: exams.map((e) => ({module: e?.module, id: e?.id})),
})
.then(() => {
toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`);
cancelCreation();
})
.catch((e) => {
console.log(e);
toast.error("Something went wrong, please try again later!");
})
.finally(() => setIsLoading(false));
})
.catch((e) => {
console.log(e);
toast.error("Something went wrong, please try again later!");
setIsLoading(false);
});
};
const deleteAssignment = () => {
if (assignment) {
setIsLoading(true);
if (!confirm(`Are you sure you want to delete the "${assignment.name}" assignment?`)) return;
axios
.delete(`api/assignments/${assignment.id}`)
.then(() => {
toast.success(`The assignment "${name}" has been deleted successfully!`);
cancelCreation();
})
.catch((e) => {
console.log(e);
toast.error("Something went wrong, please try again later!");
})
.finally(() => setIsLoading(false));
}
};
return (
<Modal isOpen={isCreating} onClose={cancelCreation} title="New Assignment">
<div className="w-full flex flex-col gap-4">
<section className="w-full grid -md:grid-cols-1 md:grid-cols-2 place-items-center lg:grid-cols-6 -md:flex-col -md:items-center -md:gap-12 justify-between gap-8 mt-8 px-8">
<div
onClick={!selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"lg:col-span-2",
selectedModules.includes("reading") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsBook className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Reading</span>
{!selectedModules.includes("reading") && !selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("reading") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div>
<div
onClick={!selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"lg:col-span-2",
selectedModules.includes("listening") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsHeadphones className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Listening</span>
{!selectedModules.includes("listening") && !selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("listening") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div>
<div
onClick={!selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"lg:col-span-2",
selectedModules.includes("writing") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsPen className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Writing</span>
{!selectedModules.includes("writing") && !selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("writing") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div>
<div
onClick={!selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"lg:col-span-3",
selectedModules.includes("speaking") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsMegaphone className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Speaking</span>
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("speaking") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div>
<div
onClick={
(!selectedModules.includes("level") && selectedModules.length === 0) || selectedModules.includes("level")
? () => toggleModule("level")
: undefined
}
className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"lg:col-span-3",
selectedModules.includes("level") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsClipboard className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Level</span>
{!selectedModules.includes("level") && selectedModules.length === 0 && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{!selectedModules.includes("level") && selectedModules.length > 0 && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("level") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div>
</section>
<Input type="text" name="name" onChange={(e) => setName(e)} defaultValue={name} label="Assignment Name" required />
<div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8">
<div className="flex flex-col gap-2">
<label className="font-normal text-base text-mti-gray-dim">Start Date *</label>
<ReactDatePicker
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out",
)}
popperClassName="!z-20"
filterDate={(date) => moment(date).isAfter(new Date())}
dateFormat="dd/MM/yyyy HH:mm"
selected={startDate}
showTimeSelect
onChange={(date) => setStartDate(date)}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-normal text-base text-mti-gray-dim">End Date *</label>
<ReactDatePicker
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out",
)}
popperClassName="!z-20"
filterDate={(date) => moment(date).isAfter(startDate)}
dateFormat="dd/MM/yyyy HH:mm"
selected={endDate}
showTimeSelect
onChange={(date) => setEndDate(date)}
/>
</div>
</div>
<section className="w-full flex flex-col gap-3">
<span className="font-semibold">Assignees ({assignees.length} selected)</span>
<div className="flex gap-4 overflow-x-scroll scrollbar-hide">
{groups.map((g) => (
<button
key={g.id}
onClick={() => {
const groupStudentIds = users.filter((u) => g.participants.includes(u.id)).map((u) => u.id);
if (groupStudentIds.every((u) => assignees.includes(u))) {
setAssignees((prev) => prev.filter((a) => !groupStudentIds.includes(a)));
} else {
setAssignees((prev) => [...prev.filter((a) => !groupStudentIds.includes(a)), ...groupStudentIds]);
}
}}
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
users.filter((u) => g.participants.includes(u.id)).every((u) => assignees.includes(u.id)) &&
"!bg-mti-purple-light !text-white",
)}>
{g.name}
</button>
))}
</div>
<div className="flex flex-wrap -md:justify-center gap-4">
{users.map((user) => (
<div
onClick={() => toggleAssignee(user)}
className={clsx(
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
"transition ease-in-out duration-300",
assignees.includes(user.id) ? "border-mti-purple" : "border-mti-gray-platinum",
)}
key={user.id}>
<span className="flex flex-col gap-0 justify-center">
<span className="font-semibold">{user.name}</span>
<span className="text-sm opacity-80">{user.email}</span>
</span>
<ProgressBar
color="purple"
textClassName="!text-mti-black/80"
label={`Level ${calculateAverageLevel(user.levels)}`}
percentage={(calculateAverageLevel(user.levels) / 9) * 100}
className="h-6"
/>
<span className="text-mti-black/80 text-sm whitespace-pre-wrap mt-2">
Groups:{" "}
{groups
.filter((g) => g.participants.includes(user.id))
.map((g) => g.name)
.join(", ")}
</span>
</div>
))}
</div>
</section>
<div className="flex gap-4 w-full justify-end">
<Button className="w-full max-w-[200px]" variant="outline" onClick={cancelCreation} disabled={isLoading} isLoading={isLoading}>
Cancel
</Button>
{assignment && (
<Button
className="w-full max-w-[200px]"
color="red"
variant="outline"
onClick={deleteAssignment}
disabled={isLoading}
isLoading={isLoading}>
Delete
</Button>
)}
<Button
disabled={selectedModules.length === 0 || !name || !startDate || !endDate || assignees.length === 0}
className="w-full max-w-[200px]"
onClick={createAssignment}
isLoading={isLoading}>
{assignment ? "Update" : "Create"}
</Button>
</div>
</div>
</Modal>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
import clsx from "clsx";
import {IconType} from "react-icons";
interface Props {
Icon: IconType;
label: string;
value: string | number;
color: "purple" | "rose" | "red";
onClick?: () => void;
}
export default function IconCard({Icon, label, value, color, onClick}: Props) {
const colorClasses: {[key in typeof color]: string} = {
purple: "text-mti-purple-light",
red: "text-mti-red-light",
rose: "text-mti-rose-light",
};
return (
<div
onClick={onClick}
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
<Icon className={clsx("text-6xl", colorClasses[color])} />
<span className="flex flex-col gap-1 items-center text-xl">
<span className="text-lg">{label}</span>
<span className={clsx("font-semibold", colorClasses[color])}>{value}</span>
</span>
</div>
);
}

215
src/dashboards/Student.tsx Normal file
View File

@@ -0,0 +1,215 @@
import Button from "@/components/Low/Button";
import ProgressBar from "@/components/Low/ProgressBar";
import PayPalPayment from "@/components/PayPalPayment";
import ProfileSummary from "@/components/ProfileSummary";
import useAssignments from "@/hooks/useAssignments";
import useStats from "@/hooks/useStats";
import {Assignment} from "@/interfaces/results";
import {User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams";
import {MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
import {averageScore, groupBySession} from "@/utils/stats";
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js";
import {PayPalButtons} from "@paypal/react-paypal-js";
import axios from "axios";
import clsx from "clsx";
import {capitalize} from "lodash";
import moment from "moment";
import Link from "next/link";
import {useRouter} from "next/router";
import {BsArrowRepeat, BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs";
import {toast} from "react-toastify";
interface Props {
user: User;
}
export default function StudentDashboard({user}: Props) {
const {stats} = useStats(user.id);
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id});
const router = useRouter();
const setExams = useExamStore((state) => state.setExams);
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const setAssignment = useExamStore((state) => state.setAssignment);
const startAssignment = (assignment: Assignment) => {
const examPromises = assignment.exams.map((e) => getExamById(e.module, e.id));
Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) {
setUserSolutions([]);
setShowSolutions(false);
setExams(exams.map((x) => x!).sort(sortByModule));
setSelectedModules(
exams
.map((x) => x!)
.sort(sortByModule)
.map((x) => x!.module),
);
setAssignment(assignment);
router.push("/exercises");
}
});
};
return (
<>
<ProfileSummary
user={user}
items={[
{
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: Object.keys(groupBySession(stats)).length,
label: "Exams",
},
{
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: stats.length,
label: "Exercises",
},
{
icon: <BsStar className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
label: "Average Score",
},
]}
/>
<section className="flex flex-col gap-1 md:gap-3">
<span className="font-bold text-lg">Bio</span>
<span className="text-mti-gray-taupe">
{user.bio || "Your bio will appear here, you can change it by clicking on your name in the top right corner."}
</span>
</section>
<section className="flex flex-col gap-1 md:gap-3">
<div className="flex gap-4 items-center">
<div
onClick={reloadAssignments}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<span className="font-bold text-lg text-mti-black">Assignments</span>
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
</div>
</div>
<span className="text-mti-gray-taupe flex gap-8 overflow-x-scroll scrollbar-hide">
{assignments.filter((a) => moment(a.endDate).isSameOrAfter(moment())).length === 0 &&
"Assignments will appear here. It seems that for now there are no assignments for you."}
{assignments
.filter((a) => moment(a.endDate).isSameOrAfter(moment()))
.sort((a, b) => moment(a.startDate).diff(b.startDate))
.map((assignment) => (
<div
className={clsx(
"border border-mti-gray-anti-flash rounded-xl flex flex-col gap-6 p-4 min-w-[300px]",
assignment.results.map((r) => r.user).includes(user.id) && "border-mti-green-light",
)}
key={assignment.id}>
<div className="flex flex-col gap-1">
<h3 className="font-semibold text-xl text-mti-black/90">{assignment.name}</h3>
<span className="flex gap-1 justify-between">
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
<span>-</span>
<span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span>
</span>
</div>
<div className="flex justify-between w-full items-center">
<div className="grid grid-cols-2 gap-2 place-items-center justify-center w-fit min-w-[104px] -md:mt-2">
{assignment.exams
.map((e) => e.module)
.sort(sortByModuleName)
.map((module) => (
<div
key={module}
data-tip={capitalize(module)}
className={clsx(
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl tooltip",
module === "reading" && "bg-ielts-reading",
module === "listening" && "bg-ielts-listening",
module === "writing" && "bg-ielts-writing",
module === "speaking" && "bg-ielts-speaking",
module === "level" && "bg-ielts-level",
)}>
{module === "reading" && <BsBook className="w-4 h-4" />}
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
{module === "writing" && <BsPen className="w-4 h-4" />}
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
{module === "level" && <BsClipboard className="w-4 h-4" />}
</div>
))}
</div>
{!assignment.results.map((r) => r.user).includes(user.id) && (
<>
<div
className="tooltip w-full md:hidden h-full flex items-center justify-end pl-8"
data-tip="Your screen size is too small to perform an assignment">
<Button
disabled={moment(assignment.startDate).isAfter(moment())}
className="w-full h-full !rounded-xl"
variant="outline">
Start
</Button>
</div>
<Button
disabled={moment(assignment.startDate).isAfter(moment())}
className="w-full max-w-[50%] h-full !rounded-xl -md:hidden"
onClick={() => startAssignment(assignment)}
variant="outline">
Start
</Button>
</>
)}
{assignment.results.map((r) => r.user).includes(user.id) && (
<Button
onClick={() => router.push("/record")}
color="green"
className="w-full max-w-[50%] h-full !rounded-xl -md:hidden"
variant="outline">
Submitted
</Button>
)}
</div>
</div>
))}
</span>
</section>
<section className="flex flex-col gap-3">
<span className="font-bold text-lg">Score History</span>
<div className="grid -md:grid-rows-4 md:grid-cols-2 gap-6">
{MODULE_ARRAY.map((module) => (
<div className="border border-mti-gray-anti-flash rounded-xl flex flex-col gap-2 p-4" key={module}>
<div className="flex gap-2 md:gap-3 items-center">
<div className="w-8 h-8 md:w-12 md:h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg md:rounded-xl">
{module === "reading" && <BsBook className="text-ielts-reading w-4 h-4 md:w-5 md:h-5" />}
{module === "listening" && <BsHeadphones className="text-ielts-listening w-4 h-4 md:w-5 md:h-5" />}
{module === "writing" && <BsPen className="text-ielts-writing w-4 h-4 md:w-5 md:h-5" />}
{module === "speaking" && <BsMegaphone className="text-ielts-speaking w-4 h-4 md:w-5 md:h-5" />}
</div>
<div className="flex justify-between w-full">
<span className="font-bold md:font-extrabold text-sm">{capitalize(module)}</span>
<span className="text-sm font-normal text-mti-gray-dim">
Level {user.levels[module]} / Level {user.desiredLevels[module]}
</span>
</div>
</div>
<div className="md:pl-14">
<ProgressBar
color={module}
label=""
percentage={Math.round((user.levels[module] * 100) / user.desiredLevels[module])}
className="w-full h-2"
/>
</div>
</div>
))}
</div>
</section>
</>
);
}

341
src/dashboards/Teacher.tsx Normal file
View File

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

42
src/email/index.ts Normal file
View File

@@ -0,0 +1,42 @@
import nodemailer from "nodemailer";
import hbs from "nodemailer-express-handlebars";
import path from "path";
interface MailOptions {
from: string;
to: string[];
subject: string;
template: string;
context: object;
}
export function prepareMailer(template?: string): nodemailer.Transporter {
const transport = nodemailer.createTransport({
host: process.env.SMTP_HOST,
auth: {
user: process.env.MAIL_USER!,
pass: process.env.MAIL_PASS!,
},
});
const handlebarOptions: hbs.NodemailerExpressHandlebarsOptions = {
viewEngine: {
partialsDir: path.resolve("src/email/templates"),
defaultLayout: `src/email/templates/${template || "main"}`,
},
viewPath: path.resolve("src/email/templates"),
};
transport.use("compile", hbs(handlebarOptions));
return transport;
}
export function prepareMailOptions(context: object, to: string[], subject: string, template: string): MailOptions {
return {
from: process.env.MAIL_USER!,
to,
subject,
template,
context,
};
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
</head>
<div style="background-color: #ffffff; color: #353338;"
class="h-full min-h-screen w-full flex flex-col p-8 gap-16 text-base">
<img src="/logo_title.png" class="w-48 h-48 self-center" />
<div>
<span>Hello future {{type}} of <b>EnCoach</b>,</span><br />
<span>You have been invited to register at <a href="https://platform.encoach.com/register?code={{code}}">EnCoach</a>
to
become a
{{type}}!</span><br />
<span>Please use the following code when registering:</span>
</div>
<br />
<br />
<a href="https://platform.encoach.com/register?code={{code}}"></a>
<span class="self-center p-4 px-12 text-lg text-[#]" style="background-color: #D5D9F0; color: #353338">
<b>{{code}}</b>
</span>
</a>
<br />
<br />
<div>
<span>Thanks, <br /> Your EnCoach team</span>
</div>
</div>
</html>

View File

@@ -0,0 +1,4 @@
{
"type": "student",
"code": "123a"
}

View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
</head>
<div>
<p>Hello {{name}},</p>
<br />
<p>Follow this link to verify your email address.</p>
<a href="https://platform.encoach.com/action?mode=signIn&continueUrl={{email}}&oobCode={{code}}">Verify account</a>
<br />
<br />
<p>If you didnt ask to verify this address, you can ignore this email.</p>
<br />
<p>Thanks,</p>
<p>Your EnCoach team</p>
</div>
</html>

View File

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

View File

@@ -1,6 +1,6 @@
import Button from "@/components/Low/Button";
import ModuleTitle from "@/components/Medium/ModuleTitle";
import {levelText, LEVEL_TEXT} from "@/constants/ielts";
import {moduleResultText} from "@/constants/ielts";
import {Module} from "@/interfaces";
import {User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore";
@@ -9,7 +9,7 @@ import clsx from "clsx";
import Link from "next/link";
import {useRouter} from "next/router";
import {Fragment, useEffect, useState} from "react";
import {BsArrowCounterclockwise, BsBook, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs";
import {BsArrowCounterclockwise, BsBook, BsClipboard, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs";
interface Score {
module: Module;
@@ -51,6 +51,19 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
progress: "text-ielts-speaking",
inner: "bg-ielts-speaking-light",
},
level: {
progress: "text-ielts-level",
inner: "bg-ielts-level-light",
},
};
const getTotalExercises = () => {
const exam = exams.find((x) => x.module === selectedModule)!;
if (exam.module === "reading" || exam.module === "listening") {
return exam.parts.flatMap((x) => x.exercises).length;
}
return exam.exercises.length;
};
return (
@@ -58,8 +71,8 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
<div className="w-full min-h-full h-fit flex flex-col items-center justify-between gap-8">
<ModuleTitle
module={selectedModule}
totalExercises={exams.find((x) => x.module === selectedModule)!.exercises.length}
exerciseIndex={exams.find((x) => x.module === selectedModule)!.exercises.length}
totalExercises={getTotalExercises()}
exerciseIndex={getTotalExercises()}
minTimer={exams.find((x) => x.module === selectedModule)!.minTimer}
disableTimer
/>
@@ -108,6 +121,17 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
<span className="font-semibold">Speaking</span>
</div>
)}
{modules.includes("level") && (
<div
onClick={() => setSelectedModule("level")}
className={clsx(
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-level hover:text-white",
selectedModule === "level" ? "bg-ielts-level text-white" : "bg-mti-gray-smoke text-ielts-level",
)}>
<BsClipboard className="w-6 h-6" />
<span className="font-semibold">Level</span>
</div>
)}
</div>
{isLoading && (
<div className="w-fit h-fit absolute top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 animate-pulse flex flex-col gap-12 items-center">
@@ -116,9 +140,9 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
</div>
)}
{!isLoading && (
<div className="w-full flex gap-9 mt-32 items-center justify-between">
<div className="w-full flex gap-9 mt-32 items-center justify-between mb-20">
<span className="max-w-3xl">
{levelText(calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus))}
{moduleResultText(calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus))}
</span>
<div className="flex gap-9 px-16">
<div
@@ -139,25 +163,25 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
</div>
<div className="flex flex-col gap-5">
<div className="flex gap-2">
<div className="w-3 h-3 bg-mti-blue-light rounded-full mt-1" />
<div className="w-3 h-3 bg-mti-red-light rounded-full mt-1" />
<div className="flex flex-col">
<span className="text-mti-blue-light">
<span className="text-mti-red-light">
{(((selectedScore.total - selectedScore.missing) / selectedScore.total) * 100).toFixed(0)}%
</span>
<span className="text-lg">Completion</span>
</div>
</div>
<div className="flex gap-2">
<div className="w-3 h-3 bg-mti-green-light rounded-full mt-1" />
<div className="w-3 h-3 bg-mti-purple-light rounded-full mt-1" />
<div className="flex flex-col">
<span className="text-mti-green-light">{selectedScore.correct.toString().padStart(2, "0")}</span>
<span className="text-mti-purple-light">{selectedScore.correct.toString().padStart(2, "0")}</span>
<span className="text-lg">Correct</span>
</div>
</div>
<div className="flex gap-2">
<div className="w-3 h-3 bg-mti-orange-light rounded-full mt-1" />
<div className="w-3 h-3 bg-mti-rose-light rounded-full mt-1" />
<div className="flex flex-col">
<span className="text-mti-orange-light">
<span className="text-mti-rose-light">
{(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")}
</span>
<span className="text-lg">Wrong</span>
@@ -175,7 +199,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
<div className="w-fit flex flex-col items-center gap-1 cursor-pointer">
<button
onClick={() => window.location.reload()}
className="w-11 h-11 rounded-full bg-mti-green-light hover:bg-mti-green flex items-center justify-center transition duration-300 ease-in-out">
className="w-11 h-11 rounded-full bg-mti-purple-light hover:bg-mti-purple flex items-center justify-center transition duration-300 ease-in-out">
<BsArrowCounterclockwise className="text-white w-7 h-7" />
</button>
<span>Play Again</span>
@@ -183,7 +207,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
<div className="w-fit flex flex-col items-center gap-1 cursor-pointer">
<button
onClick={onViewResults}
className="w-11 h-11 rounded-full bg-mti-green-light hover:bg-mti-green flex items-center justify-center transition duration-300 ease-in-out">
className="w-11 h-11 rounded-full bg-mti-purple-light hover:bg-mti-purple flex items-center justify-center transition duration-300 ease-in-out">
<BsEyeFill className="text-white w-7 h-7" />
</button>
<span>Review Answers</span>
@@ -191,7 +215,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
</div>
<Link href="/" className="max-w-[200px] w-full self-end">
<Button color="green" className="max-w-[200px] self-end w-full">
<Button color="purple" className="max-w-[200px] self-end w-full">
Dashboard
</Button>
</Link>

102
src/exams/Level.tsx Normal file
View File

@@ -0,0 +1,102 @@
import {renderExercise} from "@/components/Exercises";
import ModuleTitle from "@/components/Medium/ModuleTitle";
import {renderSolution} from "@/components/Solutions";
import {infoButtonStyle} from "@/constants/buttonStyles";
import {LevelExam, UserSolution, WritingExam} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils";
import {mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {Fragment, useEffect, useState} from "react";
import {toast} from "react-toastify";
interface Props {
exam: LevelExam;
showSolutions?: boolean;
onFinish: (userSolutions: UserSolution[]) => void;
}
export default function Level({exam, showSolutions = false, onFinish}: Props) {
const [questionIndex, setQuestionIndex] = useState(0);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [exerciseIndex, setExerciseIndex] = useState(0);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
useEffect(() => {
setCurrentQuestionIndex(0);
}, [questionIndex]);
useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) {
setExerciseIndex((prev) => prev + 1);
}
}, [hasExamEnded, exerciseIndex]);
const nextExercise = (solution?: UserSolution) => {
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
}
setQuestionIndex((prev) => prev + currentQuestionIndex);
if (exerciseIndex + 1 < exam.exercises.length) {
setExerciseIndex((prev) => prev + 1);
return;
}
if (exerciseIndex >= exam.exercises.length) return;
setHasExamEnded(false);
if (solution) {
onFinish(
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "level", exam: exam.id})),
);
} else {
onFinish(userSolutions.map((x) => ({...x, module: "level", exam: exam.id})));
}
};
const previousExercise = (solution?: UserSolution) => {
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
}
if (exerciseIndex > 0) {
setExerciseIndex((prev) => prev - 1);
}
};
const getExercise = () => {
const exercise = exam.exercises[exerciseIndex];
return {
...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
};
};
return (
<>
<div className="flex flex-col h-full w-full gap-8 items-center">
<ModuleTitle
minTimer={exam.minTimer}
exerciseIndex={exerciseIndex + 1 + questionIndex + currentQuestionIndex}
module="level"
totalExercises={countExercises(exam.exercises)}
disableTimer={showSolutions}
/>
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
!showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
showSolutions &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
</div>
</>
);
}

View File

@@ -1,14 +1,14 @@
import {ListeningExam, UserSolution} from "@/interfaces/exam";
import {useState} from "react";
import Icon from "@mdi/react";
import {mdiArrowRight} from "@mdi/js";
import clsx from "clsx";
import {infoButtonStyle} from "@/constants/buttonStyles";
import {useEffect, useState} from "react";
import {renderExercise} from "@/components/Exercises";
import {renderSolution} from "@/components/Solutions";
import ModuleTitle from "@/components/Medium/ModuleTitle";
import AudioPlayer from "@/components/Low/AudioPlayer";
import Button from "@/components/Low/Button";
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils";
interface Props {
exam: ListeningExam;
@@ -17,20 +17,68 @@ interface Props {
}
export default function Listening({exam, showSolutions = false, onFinish}: Props) {
const [exerciseIndex, setExerciseIndex] = useState(-1);
const [questionIndex, setQuestionIndex] = useState(0);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [exerciseIndex, setExerciseIndex] = useState(showSolutions ? 0 : -1);
const [partIndex, setPartIndex] = useState(0);
const [timesListened, setTimesListened] = useState(0);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>([]);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(
exam.parts.flatMap((x) => x.exercises).map((x) => defaultUserSolutions(x, exam)),
);
const [showBlankModal, setShowBlankModal] = useState(false);
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) {
setExerciseIndex((prev) => prev + 1);
}
}, [hasExamEnded, exerciseIndex]);
useEffect(() => {
setCurrentQuestionIndex(0);
}, [questionIndex]);
const confirmFinishModule = (keepGoing?: boolean) => {
if (!keepGoing) {
setShowBlankModal(false);
return;
}
onFinish(userSolutions.map((x) => ({...x, module: "listening", exam: exam.id})));
};
const nextExercise = (solution?: UserSolution) => {
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
}
setQuestionIndex((prev) => prev + currentQuestionIndex);
if (exerciseIndex + 1 < exam.exercises.length) {
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
setExerciseIndex((prev) => prev + 1);
return;
}
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
setPartIndex((prev) => prev + 1);
setExerciseIndex(showSolutions ? 0 : -1);
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);
if (solution) {
onFinish(
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "listening", exam: exam.id})),
@@ -49,7 +97,7 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
};
const getExercise = () => {
const exercise = exam.exercises[exerciseIndex];
const exercise = exam.parts[partIndex].exercises[exerciseIndex];
return {
...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
@@ -61,17 +109,19 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
<div className="flex flex-col w-full gap-2">
<h4 className="text-xl font-semibold">Please listen to the following audio attentively.</h4>
<span className="text-base">
{exam.audio.repeatableTimes > 0
? `You will only be allowed to listen to the audio ${exam.audio.repeatableTimes - timesListened} time(s).`
{exam.parts[partIndex].audio.repeatableTimes > 0
? `You will only be allowed to listen to the audio ${exam.parts[partIndex].audio.repeatableTimes - timesListened} time(s).`
: "You may listen to the audio as many times as you would like."}
</span>
</div>
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
<AudioPlayer
src={exam.audio.source}
key={partIndex}
src={exam.parts[partIndex].audio.source}
color="listening"
onEnd={() => setTimesListened((prev) => prev + 1)}
disabled={timesListened === exam.audio.repeatableTimes}
disabled={timesListened === exam.parts[partIndex].audio.repeatableTimes}
disablePause
/>
</div>
</div>
@@ -79,27 +129,54 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
return (
<>
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
<div className="flex flex-col h-full w-full gap-8 justify-between">
<ModuleTitle
exerciseIndex={exerciseIndex + 1}
exerciseIndex={
(exam.parts
.flatMap((x) => x.exercises)
.findIndex(
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
) || 0) +
(exerciseIndex === -1 ? 0 : 1) +
questionIndex +
currentQuestionIndex
}
minTimer={exam.minTimer}
module="listening"
totalExercises={exam.exercises.length}
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
disableTimer={showSolutions}
/>
{exerciseIndex === -1 && renderAudioPlayer()}
{renderAudioPlayer()}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise)}
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
showSolutions &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
</div>
{exerciseIndex === -1 && partIndex > 0 && (
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => {
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
setPartIndex((prev) => prev - 1);
}}
className="max-w-[200px] w-full">
Back
</Button>
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)}
{exerciseIndex === -1 && (
<Button color="green" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end">
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end">
Start now
</Button>
)}

View File

@@ -15,6 +15,10 @@ import ProgressBar from "@/components/Low/ProgressBar";
import ModuleTitle from "@/components/Medium/ModuleTitle";
import {Divider} from "primereact/divider";
import Button from "@/components/Low/Button";
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils";
interface Props {
exam: ReadingExam;
@@ -47,12 +51,12 @@ function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: s
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95">
<Dialog.Panel className="w-full max-w-4xl transform rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
<Dialog.Panel className="w-full relative max-w-4xl transform rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
{title}
</Dialog.Title>
<div className="mt-2 overflow-auto">
<p className="text-sm text-gray-500">
<div className="mt-2 overflow-auto mb-28">
<p className="text-sm">
{content.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
@@ -62,13 +66,10 @@ function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: s
</p>
</div>
<div className="mt-4">
<button
type="button"
className="inline-flex justify-center rounded-md border border-transparent bg-blue-100 px-4 py-2 text-sm font-medium text-blue-900 hover:bg-blue-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
onClick={onClose}>
Got it, thanks!
</button>
<div className="absolute bottom-8 right-8 max-w-[200px] self-end w-full">
<Button color="purple" variant="outline" className="max-w-[200px] self-end w-full" onClick={onClose}>
Close
</Button>
</div>
</Dialog.Panel>
</Transition.Child>
@@ -80,20 +81,82 @@ function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: s
}
export default function Reading({exam, showSolutions = false, onFinish}: Props) {
const [exerciseIndex, setExerciseIndex] = useState(-1);
const [questionIndex, setQuestionIndex] = useState(0);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [exerciseIndex, setExerciseIndex] = useState(showSolutions ? 0 : -1);
const [partIndex, setPartIndex] = useState(0);
const [showTextModal, setShowTextModal] = useState(false);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>([]);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(
exam.parts.flatMap((x) => x.exercises).map((x) => defaultUserSolutions(x, exam)),
);
const [showBlankModal, setShowBlankModal] = useState(false);
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
useEffect(() => {
const listener = (e: KeyboardEvent) => {
if (e.key === "F3" || ((e.ctrlKey || e.metaKey) && e.key === "f")) {
e.preventDefault();
}
};
document.addEventListener("keydown", listener);
return () => {
document.removeEventListener("keydown", listener);
};
}, []);
useEffect(() => {
setCurrentQuestionIndex(0);
}, [questionIndex]);
useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) {
setExerciseIndex((prev) => prev + 1);
}
}, [hasExamEnded, exerciseIndex]);
const confirmFinishModule = (keepGoing?: boolean) => {
if (!keepGoing) {
setShowBlankModal(false);
return;
}
onFinish(userSolutions.map((x) => ({...x, module: "reading", exam: exam.id})));
};
const nextExercise = (solution?: UserSolution) => {
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
}
setQuestionIndex((prev) => prev + currentQuestionIndex);
if (exerciseIndex + 1 < exam.exercises.length) {
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
setExerciseIndex((prev) => prev + 1);
return;
}
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
setPartIndex((prev) => prev + 1);
setExerciseIndex(showSolutions ? 0 : -1);
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);
if (solution) {
onFinish(
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "reading", exam: exam.id})),
@@ -112,7 +175,7 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
};
const getExercise = () => {
const exercise = exam.exercises[exerciseIndex];
const exercise = exam.parts[partIndex].exercises[exerciseIndex];
return {
...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
@@ -120,7 +183,7 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
};
const renderText = () => (
<div className="flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
<div className="flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16 mt-4">
<div className="flex flex-col 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.
@@ -128,10 +191,10 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
</div>
<div className="flex flex-col gap-2 w-full">
<h3 className="text-xl font-semibold">{exam.text.title}</h3>
<h3 className="text-xl font-semibold">{exam.parts[partIndex].text.title}</h3>
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
<span className="overflow-auto">
{exam.text.content.split("\\n").map((line, index) => (
{exam.parts[partIndex].text.content.split("\\n").map((line, index) => (
<p key={index}>{line}</p>
))}
</span>
@@ -142,27 +205,66 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
return (
<>
<div className="flex flex-col h-full w-full gap-8">
<TextModal {...exam.text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
<TextModal {...exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />
<ModuleTitle
minTimer={exam.minTimer}
exerciseIndex={exerciseIndex + 1}
exerciseIndex={
(exam.parts
.flatMap((x) => x.exercises)
.findIndex(
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
) || 0) +
(exerciseIndex === -1 ? 0 : 1) +
questionIndex +
currentQuestionIndex
}
module="reading"
totalExercises={exam.exercises.length}
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
disableTimer={showSolutions}
label={exerciseIndex === -1 ? undefined : convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
label={exerciseIndex === -1 ? undefined : convertCamelCaseToReadable(exam.parts[partIndex].exercises[exerciseIndex].type)}
/>
{exerciseIndex === -1 && renderText()}
<div className={clsx("mb-20 w-full", exerciseIndex > -1 && "grid grid-cols-2 gap-4")}>
{renderText()}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise)}
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
showSolutions &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
</div>
{exerciseIndex === -1 && (
<Button color="green" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
{exerciseIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
<Button
color="purple"
variant="outline"
onClick={() => setShowTextModal(true)}
className="max-w-[200px] self-end w-full absolute bottom-[31px] right-64">
Read text
</Button>
)}
</div>
{exerciseIndex === -1 && partIndex > 0 && (
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => {
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
setPartIndex((prev) => prev - 1);
}}
className="max-w-[200px] w-full">
Back
</Button>
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)}
{exerciseIndex === -1 && partIndex === 0 && (
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
Start now
</Button>
)}

View File

@@ -4,20 +4,25 @@ import {Module} from "@/interfaces";
import clsx from "clsx";
import {User} from "@/interfaces/user";
import ProgressBar from "@/components/Low/ProgressBar";
import {BsBook, BsCheckCircle, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
import {BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
import {totalExamsByModule} from "@/utils/stats";
import useStats from "@/hooks/useStats";
import Button from "@/components/Low/Button";
import {calculateAverageLevel} from "@/utils/score";
import {sortByModuleName} from "@/utils/moduleUtils";
import {capitalize} from "lodash";
import ProfileSummary from "@/components/ProfileSummary";
interface Props {
user: User;
onStart: (modules: Module[]) => void;
page: "exercises" | "exams";
onStart: (modules: Module[], avoidRepeated: boolean) => void;
disableSelection?: boolean;
}
export default function Selection({user, onStart}: Props) {
export default function Selection({user, page, onStart, disableSelection = false}: Props) {
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
const {stats} = useStats(user?.id);
const toggleModule = (module: Module) => {
@@ -27,83 +32,72 @@ export default function Selection({user, onStart}: Props) {
return (
<>
<div className="w-full h-full relative flex flex-col gap-16">
<section className="w-full flex gap-8">
<img src={user.profilePicture} alt={user.name} className="aspect-square h-64 rounded-3xl drop-shadow-xl" />
<div className="flex flex-col gap-4 py-4 w-full">
<div className="flex justify-between w-full gap-8">
<div className="flex flex-col gap-2 py-2">
<h1 className="font-bold text-4xl">{user.name}</h1>
<h6 className="font-normal text-base text-mti-gray-taupe capitalize">{user.type}</h6>
</div>
<ProgressBar
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
percentage={100}
color="blue"
className="max-w-xs w-32 self-end h-10"
<div className="w-full h-full relative flex flex-col gap-8 md:gap-16">
{user && (
<ProfileSummary
user={user}
items={[
{
icon: <BsBook className="text-ielts-reading w-6 h-6 md:w-8 md:h-8" />,
label: "Reading",
value: totalExamsByModule(stats, "reading"),
},
{
icon: <BsHeadphones className="text-ielts-listening w-6 h-6 md:w-8 md:h-8" />,
label: "Listening",
value: totalExamsByModule(stats, "listening"),
},
{
icon: <BsPen className="text-ielts-writing w-6 h-6 md:w-8 md:h-8" />,
label: "Writing",
value: totalExamsByModule(stats, "writing"),
},
{
icon: <BsMegaphone className="text-ielts-speaking w-6 h-6 md:w-8 md:h-8" />,
label: "Speaking",
value: totalExamsByModule(stats, "speaking"),
},
{
icon: <BsClipboard className="text-ielts-level w-6 h-6 md:w-8 md:h-8" />,
label: "Level",
value: totalExamsByModule(stats, "level"),
},
]}
/>
</div>
<ProgressBar
label=""
percentage={Math.round((calculateAverageLevel(user.levels) * 100) / calculateAverageLevel(user.desiredLevels))}
color="blue"
className="w-full h-3 drop-shadow-lg"
/>
<div className="flex justify-between w-full mt-8">
<div className="flex gap-4 items-center">
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
<BsBook className="text-ielts-reading w-8 h-8" />
</div>
<div className="flex flex-col">
<span className="font-bold text-xl">{totalExamsByModule(stats, "reading")}</span>
<span className="font-normal text-base text-mti-gray-dim">Reading</span>
</div>
</div>
<div className="flex gap-4 items-center">
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
<BsHeadphones className="text-ielts-listening w-8 h-8" />
</div>
<div className="flex flex-col">
<span className="font-bold text-xl">{totalExamsByModule(stats, "listening")}</span>
<span className="font-normal text-base text-mti-gray-dim">Listening</span>
</div>
</div>
<div className="flex gap-4 items-center">
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
<BsPen className="text-ielts-writing w-8 h-8" />
</div>
<div className="flex flex-col">
<span className="font-bold text-xl">{totalExamsByModule(stats, "writing")}</span>
<span className="font-normal text-base text-mti-gray-dim">Writing</span>
</div>
</div>
<div className="flex gap-4 items-center">
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
<BsMegaphone className="text-ielts-speaking w-8 h-8" />
</div>
<div className="flex flex-col">
<span className="font-bold text-xl">{totalExamsByModule(stats, "speaking")}</span>
<span className="font-normal text-base text-mti-gray-dim">Speaking</span>
</div>
</div>
</div>
</div>
</section>
)}
<section className="flex flex-col gap-3">
<span className="font-bold text-lg">About Exams</span>
<span className="font-bold text-lg">About {capitalize(page)}</span>
<span className="text-mti-gray-taupe">
This comprehensive test will assess your proficiency in reading, listening, writing, and speaking English. Be prepared to dive
into a variety of interesting and challenging topics while showcasing your ability to communicate effectively in English.
Master the vocabulary, grammar, and interpretation skills required to succeed in this high-level exam. Are you ready to
demonstrate your mastery of the English language to the world?
{page === "exercises" && (
<>
In the realm of language acquisition, practice makes perfect, and our exercises are the key to unlocking your full
potential. Dive into a world of interactive and engaging exercises that cater to diverse learning styles. From grammar
drills that build a strong foundation to vocabulary challenges that broaden your lexicon, our exercises are carefully
designed to make learning English both enjoyable and effective. Whether you&apos;re looking to reinforce specific
skills or embark on a holistic language journey, our exercises are your companions in the pursuit of excellence.
Embrace the joy of learning as you navigate through a variety of activities that cater to every facet of language
acquisition. Your linguistic adventure starts here!
</>
)}
{page === "exams" && (
<>
Welcome to the heart of success on your English language journey! Our exams are crafted with precision to assess and
enhance your language skills. Each test is a passport to your linguistic prowess, designed to challenge and elevate
your abilities. Whether you&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>
<section className="w-full flex justify-between gap-8 mt-8">
<section className="w-full flex -lg:flex-col -lg:items-center -lg:gap-12 justify-between gap-8 mt-8">
<div
onClick={() => toggleModule("reading")}
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
className={clsx(
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
selectedModules.includes("reading") ? "border-mti-green-light" : "border-mti-gray-platinum",
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
selectedModules.includes("reading") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-0 -translate-y-1/2">
<BsBook className="text-white w-7 h-7" />
@@ -112,14 +106,19 @@ export default function Selection({user, onStart}: Props) {
<p className="text-center text-xs">
Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
</p>
{!selectedModules.includes("reading") && <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />}
{selectedModules.includes("reading") && <BsCheckCircle className="mt-4 text-mti-green-light w-8 h-8" />}
{!selectedModules.includes("reading") && !selectedModules.includes("level") && !disableSelection && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
)}
{(selectedModules.includes("reading") || disableSelection) && (
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
)}
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
</div>
<div
onClick={() => toggleModule("listening")}
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
className={clsx(
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
selectedModules.includes("listening") ? "border-mti-green-light" : "border-mti-gray-platinum",
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
selectedModules.includes("listening") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-0 -translate-y-1/2">
<BsHeadphones className="text-white w-7 h-7" />
@@ -128,14 +127,19 @@ export default function Selection({user, onStart}: Props) {
<p className="text-center text-xs">
Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
</p>
{!selectedModules.includes("listening") && <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />}
{selectedModules.includes("listening") && <BsCheckCircle className="mt-4 text-mti-green-light w-8 h-8" />}
{!selectedModules.includes("listening") && !selectedModules.includes("level") && !disableSelection && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
)}
{(selectedModules.includes("listening") || disableSelection) && (
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
)}
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
</div>
<div
onClick={() => toggleModule("writing")}
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
className={clsx(
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
selectedModules.includes("writing") ? "border-mti-green-light" : "border-mti-gray-platinum",
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
selectedModules.includes("writing") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-0 -translate-y-1/2">
<BsPen className="text-white w-7 h-7" />
@@ -144,14 +148,19 @@ export default function Selection({user, onStart}: Props) {
<p className="text-center text-xs">
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
</p>
{!selectedModules.includes("writing") && <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />}
{selectedModules.includes("writing") && <BsCheckCircle className="mt-4 text-mti-green-light w-8 h-8" />}
{!selectedModules.includes("writing") && !selectedModules.includes("level") && !disableSelection && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
)}
{(selectedModules.includes("writing") || disableSelection) && (
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
)}
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
</div>
<div
onClick={() => toggleModule("speaking")}
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
className={clsx(
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
selectedModules.includes("speaking") ? "border-mti-green-light" : "border-mti-gray-platinum",
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
selectedModules.includes("speaking") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-0 -translate-y-1/2">
<BsMegaphone className="text-white w-7 h-7" />
@@ -160,18 +169,73 @@ export default function Selection({user, onStart}: Props) {
<p className="text-center text-xs">
You&apos;ll have access to interactive dialogs, pronunciation exercises and speech recordings.
</p>
{!selectedModules.includes("speaking") && <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />}
{selectedModules.includes("speaking") && <BsCheckCircle className="mt-4 text-mti-green-light w-8 h-8" />}
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && !disableSelection && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
)}
{(selectedModules.includes("speaking") || disableSelection) && (
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
)}
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
</div>
{!disableSelection && (
<div
onClick={selectedModules.length === 0 || selectedModules.includes("level") ? () => toggleModule("level") : undefined}
className={clsx(
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
selectedModules.includes("level") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-0 -translate-y-1/2">
<BsClipboard className="text-white w-7 h-7" />
</div>
<span className="font-semibold">Level:</span>
<p className="text-center text-xs">You&apos;ll be able to test your english level with multiple choice questions.</p>
{!selectedModules.includes("level") && selectedModules.length === 0 && !disableSelection && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
)}
{(selectedModules.includes("level") || disableSelection) && (
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
)}
{!selectedModules.includes("level") && selectedModules.length > 0 && (
<BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />
)}
</div>
)}
</section>
<Button
onClick={() => onStart(selectedModules.sort(sortByModuleName))}
color="green"
className="px-12 w-full max-w-xs self-end"
disabled={selectedModules.length === 0}>
<div className="flex w-full -md:flex-col -md:gap-4 -md:justify-center md:justify-between items-center">
<div
className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer tooltip w-full -md:justify-center"
data-tip="If possible, the platform will choose exams not yet done"
onClick={() => setAvoidRepeatedExams((prev) => !prev)}>
<input type="checkbox" className="hidden" />
<div
className={clsx(
"w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
"transition duration-300 ease-in-out",
avoidRepeatedExams && "!bg-mti-purple-light ",
)}>
<BsCheck color="white" className="w-full h-full" />
</div>
<span>Avoid Repeated Questions</span>
</div>
<div className="tooltip w-full" data-tip={`Your screen size is too small to do ${page}`}>
<Button color="purple" className="px-12 w-full max-w-xs md:hidden" disabled>
Start Exam
</Button>
</div>
<Button
onClick={() =>
onStart(
!disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"],
avoidRepeatedExams,
)
}
color="purple"
className="px-12 w-full max-w-xs md:self-end -md:hidden"
disabled={selectedModules.length === 0 && !disableSelection}>
Start Exam
</Button>
</div>
</div>
</>
);
}

View File

@@ -3,6 +3,10 @@ import ModuleTitle from "@/components/Medium/ModuleTitle";
import {renderSolution} from "@/components/Solutions";
import {infoButtonStyle} from "@/constants/buttonStyles";
import {UserSolution, SpeakingExam} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils";
import {convertCamelCaseToReadable} from "@/utils/string";
import {mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
@@ -16,19 +20,31 @@ interface Props {
}
export default function Speaking({exam, showSolutions = false, onFinish}: Props) {
const [questionIndex, setQuestionIndex] = useState(0);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [exerciseIndex, setExerciseIndex] = useState(0);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>([]);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
useEffect(() => {
setCurrentQuestionIndex(0);
}, [questionIndex]);
const nextExercise = (solution?: UserSolution) => {
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
}
setQuestionIndex((prev) => prev + currentQuestionIndex);
if (exerciseIndex + 1 < exam.exercises.length) {
setExerciseIndex((prev) => prev + 1);
return;
}
if (exerciseIndex >= exam.exercises.length) return;
setHasExamEnded(false);
if (solution) {
onFinish(
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "speaking", exam: exam.id})),
@@ -60,20 +76,21 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
<>
<div className="flex flex-col h-full w-full gap-8 items-center">
<ModuleTitle
label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
minTimer={exam.minTimer}
exerciseIndex={exerciseIndex + 1}
exerciseIndex={exerciseIndex + 1 + questionIndex + currentQuestionIndex}
module="speaking"
totalExercises={exam.exercises.length}
totalExercises={countExercises(exam.exercises)}
disableTimer={showSolutions}
/>
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
!showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise)}
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
showSolutions &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
</div>
</>
);

View File

@@ -3,6 +3,9 @@ import ModuleTitle from "@/components/Medium/ModuleTitle";
import {renderSolution} from "@/components/Solutions";
import {infoButtonStyle} from "@/constants/buttonStyles";
import {UserSolution, WritingExam} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils";
import {mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
@@ -17,7 +20,9 @@ interface Props {
export default function Writing({exam, showSolutions = false, onFinish}: Props) {
const [exerciseIndex, setExerciseIndex] = useState(0);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>([]);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
const nextExercise = (solution?: UserSolution) => {
if (solution) {
@@ -29,6 +34,10 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
return;
}
if (exerciseIndex >= exam.exercises.length) return;
setHasExamEnded(false);
if (solution) {
onFinish(
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "writing", exam: exam.id})),
@@ -63,7 +72,7 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
minTimer={exam.minTimer}
exerciseIndex={exerciseIndex + 1}
module="writing"
totalExercises={exam.exercises.length}
totalExercises={countExercises(exam.exercises)}
disableTimer={showSolutions}
/>
{exerciseIndex > -1 &&

View File

@@ -1,5 +1,7 @@
import {initializeApp} from "firebase/app";
import {getFirestore} from "firebase/firestore";
import * as admin from "firebase-admin/app";
const serviceAccount = require("@/constants/serviceAccountKey.json");
const firebaseConfig = {
apiKey: process.env.FIREBASE_PUBLIC_API_KEY || "",
@@ -8,7 +10,12 @@ const firebaseConfig = {
storageBucket: process.env.FIREBASE_STORAGE_BUCKET || "",
messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID || "",
appId: process.env.FIREBASE_APP_ID || "",
measurementId: process.env.FIREBASE_MEASUREMENT_ID || "",
};
export const app = initializeApp(firebaseConfig);
export const app = initializeApp(firebaseConfig, Math.random().toString());
export const adminApp = admin.initializeApp(
{
credential: admin.cert(serviceAccount),
},
Math.random().toString(),
);

View File

@@ -0,0 +1,32 @@
import {Assignment} from "@/interfaces/results";
import axios from "axios";
import {useEffect, useState} from "react";
export default function useAssignments({assigner, assignees}: {assigner?: string; assignees?: string}) {
const [assignments, setAssignments] = useState<Assignment[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
setIsLoading(true);
axios
.get<Assignment[]>("/api/assignments")
.then((response) => {
if (assigner) {
setAssignments(response.data.filter((a) => a.assigner === assigner));
return;
}
if (assignees) {
setAssignments(response.data.filter((a) => a.assignees.filter((x) => assignees.includes(x)).length > 0));
return;
}
setAssignments(response.data);
})
.finally(() => setIsLoading(false));
};
useEffect(getData, [assignees, assigner]);
return {assignments, isLoading, isError, reload: getData};
}

21
src/hooks/useExams.tsx Normal file
View File

@@ -0,0 +1,21 @@
import {Exam} from "@/interfaces/exam";
import axios from "axios";
import {useEffect, useState} from "react";
export default function useExams() {
const [exams, setExams] = useState<Exam[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
setIsLoading(true);
axios
.get<Exam[]>("/api/exam")
.then((response) => setExams(response.data))
.finally(() => setIsLoading(false));
};
useEffect(getData, []);
return {exams, isLoading, isError, reload: getData};
}

26
src/hooks/useGroups.tsx Normal file
View File

@@ -0,0 +1,26 @@
import {Group, User} from "@/interfaces/user";
import axios from "axios";
import {useEffect, useState} from "react";
export default function useGroups(admin?: string) {
const [groups, setGroups] = useState<Group[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
setIsLoading(true);
axios
.get<Group[]>("/api/groups")
.then((response) => {
const filter = (g: Group) => g.admin === admin || g.participants.includes(admin || "");
const filteredGroups = admin ? response.data.filter(filter) : response.data;
return setGroups(admin ? filteredGroups.map((g) => ({...g, disableEditing: g.disableEditing || g.admin !== admin})) : filteredGroups);
})
.finally(() => setIsLoading(false));
};
useEffect(getData, [admin]);
return {groups, isLoading, isError, reload: getData};
}

22
src/hooks/usePackages.tsx Normal file
View File

@@ -0,0 +1,22 @@
import {Exam} from "@/interfaces/exam";
import {Package} from "@/interfaces/paypal";
import axios from "axios";
import {useEffect, useState} from "react";
export default function usePackages() {
const [packages, setPackages] = useState<Package[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
setIsLoading(true);
axios
.get<Package[]>("/api/packages")
.then((response) => setPackages(response.data))
.finally(() => setIsLoading(false));
};
useEffect(getData, []);
return {packages, isLoading, isError, reload: getData};
}

24
src/hooks/usePayments.tsx Normal file
View File

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

View File

@@ -16,9 +16,9 @@ export default function useUser({redirectTo = "", redirectIfFound = false} = {})
if (
// If redirectTo is set, redirect if the user was not found.
(redirectTo && !redirectIfFound && !user) ||
(redirectTo && !redirectIfFound && (!user || (user && !user.isVerified))) ||
// If redirectIfFound is also set, redirect if the user was found
(redirectIfFound && user)
(redirectIfFound && user && user.isVerified)
) {
Router.push(redirectTo);
}

View File

@@ -7,13 +7,15 @@ export default function useUsers() {
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
useEffect(() => {
const getData = () => {
setIsLoading(true);
axios
.get<User[]>("/api/users/list")
.get<User[]>("/api/users/list", {headers: {page: "register"}})
.then((response) => setUsers(response.data))
.finally(() => setIsLoading(false));
}, []);
};
return {users, isLoading, isError};
useEffect(getData, []);
return {users, isLoading, isError, reload: getData};
}

View File

@@ -1,30 +1,46 @@
import {Module} from ".";
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam;
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
export interface ReadingExam {
text: {
title: string;
content: string;
};
parts: ReadingPart[];
id: string;
exercises: Exercise[];
module: "reading";
minTimer: number;
type: "academic" | "general";
isDiagnostic: boolean;
}
export interface ReadingPart {
text: {
title: string;
content: string;
};
exercises: Exercise[];
}
export interface LevelExam {
module: "level";
id: string;
exercises: Exercise[];
minTimer: number;
isDiagnostic: boolean;
}
export interface ListeningExam {
parts: ListeningPart[];
id: string;
module: "listening";
minTimer: number;
isDiagnostic: boolean;
}
export interface ListeningPart {
audio: {
source: string;
repeatableTimes: number; // *The amount of times the user is allowed to repeat the audio, 0 for unlimited
};
id: string;
exercises: Exercise[];
module: "listening";
minTimer: number;
isDiagnostic: boolean;
}
export interface UserSolution {
@@ -63,33 +79,34 @@ export interface SpeakingExam {
export type Exercise =
| FillBlanksExercise
| TrueFalseExercise
| MatchSentencesExercise
| MultipleChoiceExercise
| WriteBlanksExercise
| WritingExercise
| SpeakingExercise;
| SpeakingExercise
| InteractiveSpeakingExercise;
export interface WritingEvaluation {
export interface Evaluation {
comment: string;
overall: number;
task_response: {[key: string]: number};
}
export interface WritingExercise {
id: string;
type: "writing";
info: string; //* The information about the task, like the amount of time they should spend on it
prefix: string; //* The information about the task, like the amount of time they should spend on it
suffix: string;
prompt: string; //* The context given to the user containing what they should write about
wordCounter: WordCounter; //* The minimum or maximum amount of words that should be written
attachment?: {
url: string;
description: string;
}; //* The url for an image to work as an attachment to show the user
evaluation?: WritingEvaluation;
userSolutions: {
id: string;
solution: string;
evaluation?: WritingEvaluation;
evaluation?: Evaluation;
}[];
}
@@ -99,9 +116,24 @@ export interface SpeakingExercise {
title: string;
text: string;
prompts: string[];
video_url: string;
userSolutions: {
id: string;
solution: string;
evaluation?: Evaluation;
}[];
}
export interface InteractiveSpeakingExercise {
id: string;
type: "interactiveSpeaking";
title: string;
text: string;
prompts: {text: string; video_url: string}[];
userSolutions: {
id: string;
solution: {question: string; answer: string}[];
evaluation?: Evaluation;
}[];
}
@@ -122,6 +154,20 @@ export interface FillBlanksExercise {
}[];
}
export interface TrueFalseExercise {
type: "trueFalse";
id: string;
prompt: string; // *EXAMPLE: "Select the appropriate option."
questions: TrueFalseQuestion[];
userSolutions: {id: string; solution: "true" | "false" | "not_given"}[];
}
export interface TrueFalseQuestion {
id: string; // *EXAMPLE: "1"
prompt: string; // *EXAMPLE: "What does her briefcase look like?"
solution: "true" | "false" | "not_given"; // *EXAMPLE: "True"
}
export interface WriteBlanksExercise {
prompt: string; // *EXAMPLE: "Complete the notes below by writing NO MORE THAN THREE WORDS in the spaces provided."
maxWords: number; // *EXAMPLE: 3 - The maximum amount of words allowed per blank, 0 for unlimited

View File

@@ -1 +1 @@
export type Module = "reading" | "listening" | "writing" | "speaking";
export type Module = "reading" | "listening" | "writing" | "speaking" | "level";

35
src/interfaces/paypal.ts Normal file
View File

@@ -0,0 +1,35 @@
export interface TokenSuccess {
scope: string;
access_token: string;
token_type: string;
app_id: string;
expires_in: number;
nonce: string;
}
export interface TokenError {
error: string;
error_description: string;
}
export interface Package {
id: string;
currency: string;
duration: number;
duration_unit: DurationUnit;
price: number;
}
export type DurationUnit = "weeks" | "days" | "months" | "years";
export interface Payment {
id: string;
corporate: string;
agent?: string;
agentCommission: number;
agentValue: number;
currency: string;
value: number;
isPaid: boolean;
date: Date;
}

View File

@@ -1,4 +1,5 @@
import {Module} from "@/interfaces";
import {Stat} from "./user";
export type UserResults = {[key in Module]: ModuleResult};
@@ -7,3 +8,18 @@ interface ModuleResult {
score: number;
total: number;
}
export interface Assignment {
id: string;
name: string;
assigner: string;
assignees: string[];
results: {
user: string;
type: "academic" | "general";
stats: Stat[];
}[];
exams: {id: string; module: Module}[];
startDate: Date;
endDate: Date;
}

View File

@@ -1,18 +1,101 @@
import {Module} from ".";
export interface User {
export type User = StudentUser | TeacherUser | CorporateUser | AgentUser | AdminUser | DeveloperUser;
export interface BasicUser {
email: string;
name: string;
profilePicture: string;
id: string;
experience: number;
isFirstLogin: boolean;
focus: "academic" | "general";
levels: {[key in Module]: number};
desiredLevels: {[key in Module]: number};
type: Type;
bio: string;
isVerified: boolean;
subscriptionExpirationDate?: null | Date;
registrationDate?: Date;
status: "active" | "disabled" | "paymentDue";
}
export interface StudentUser extends BasicUser {
type: "student";
demographicInformation?: DemographicInformation;
}
export interface TeacherUser extends BasicUser {
type: "teacher";
demographicInformation?: DemographicInformation;
}
export interface CorporateUser extends BasicUser {
type: "corporate";
corporateInformation: CorporateInformation;
demographicInformation?: DemographicCorporateInformation;
}
export interface AgentUser extends BasicUser {
type: "agent";
agentInformation: AgentInformation;
demographicInformation?: DemographicInformation;
}
export interface AdminUser extends BasicUser {
type: "admin";
demographicInformation?: DemographicInformation;
}
export interface DeveloperUser extends BasicUser {
type: "developer";
demographicInformation?: DemographicInformation;
}
export interface CorporateInformation {
companyInformation: CompanyInformation;
monthlyDuration: number;
payment?: {
value: number;
currency: string;
};
referralAgent?: string;
}
export interface AgentInformation {
companyName: string;
commercialRegistration: string;
}
export interface CompanyInformation {
name: string;
userAmount: number;
}
export interface DemographicInformation {
country: string;
phone: string;
gender: Gender;
employment: EmploymentStatus;
}
export interface DemographicCorporateInformation {
country: string;
phone: string;
gender: Gender;
position: string;
}
export type Gender = "male" | "female" | "other";
export type EmploymentStatus = "employed" | "student" | "self-employed" | "unemployed" | "retired" | "other";
export const EMPLOYMENT_STATUS: {status: EmploymentStatus; label: string}[] = [
{status: "student", label: "Student"},
{status: "employed", label: "Employed"},
{status: "unemployed", label: "Unemployed"},
{status: "self-employed", label: "Self-employed"},
{status: "retired", label: "Retired"},
{status: "other", label: "Other"},
];
export interface Stat {
user: string;
exam: string;
@@ -22,6 +105,8 @@ export interface Stat {
module: Module;
solutions: any[];
type: string;
timeSpent?: number;
assignment?: string;
score: {
correct: number;
total: number;
@@ -29,5 +114,13 @@ export interface Stat {
};
}
export type Type = "student" | "teacher" | "admin" | "owner" | "developer";
export const userTypes: Type[] = ["student", "teacher", "admin", "owner", "developer"];
export interface Group {
admin: string;
name: string;
participants: string[];
id: string;
disableEditing?: boolean;
}
export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent";
export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent"];

1
src/lib/formidable-serverless.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module "formidable-serverless";

View File

@@ -14,5 +14,6 @@ export const sessionOptions: IronSessionOptions = {
declare module "iron-session" {
interface IronSessionData {
user?: User | null;
envVariables?: {[key: string]: string};
}
}

View File

@@ -0,0 +1,135 @@
import Button from "@/components/Low/Button";
import Checkbox from "@/components/Low/Checkbox";
import {PERMISSIONS} from "@/constants/userPermissions";
import useUsers from "@/hooks/useUsers";
import {Type, User} from "@/interfaces/user";
import {USER_TYPE_LABELS} from "@/resources/user";
import axios from "axios";
import clsx from "clsx";
import {capitalize} from "lodash";
import moment from "moment";
import {useEffect, useState} from "react";
import ReactDatePicker from "react-datepicker";
import {toast} from "react-toastify";
import ShortUniqueId from "short-unique-id";
import {useFilePicker} from "use-file-picker";
export default function BatchCodeGenerator({user}: {user: User}) {
const [emails, setEmails] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [type, setType] = useState<Type>("student");
const {users} = useUsers();
const {openFilePicker, filesContent} = useFilePicker({
accept: ".txt",
multiple: false,
});
useEffect(() => {
if (user && (user.type === "corporate" || user.type === "teacher")) {
setExpiryDate(user.subscriptionExpirationDate || null);
}
}, [user]);
useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null);
}, [isExpiryDateEnabled]);
useEffect(() => {
if (filesContent.length > 0) {
const file = filesContent[0];
const emails = file.content
.split("\n")
.map((x) => x.trim())
.filter((x) => new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/).test(x))
.filter((x) => !users.map((u) => u.email).includes(x));
if (emails.length === 0) {
toast.error("Please upload a .txt file containing e-mails, one per line! All already registered e-mails have also been ignored!");
return;
}
setEmails([...new Set(emails)]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filesContent]);
const generateCode = (type: Type) => {
const uid = new ShortUniqueId();
const codes = emails.map(() => uid.randomUUID(6));
setIsLoading(true);
axios
.post("/api/code", {type, codes, emails, expiryDate})
.then(({data, status}) => {
if (data.ok) {
toast.success(`Successfully generated ${capitalize(type)} codes and they have been notified by e-mail!`, {toastId: "success"});
return;
}
if (status === 403) {
toast.error(data.reason, {toastId: "forbidden"});
}
})
.catch(({response: {status, data}}) => {
if (status === 403) {
toast.error(data.reason, {toastId: "forbidden"});
return;
}
toast.error(`Something went wrong, please try again later!`, {toastId: "error"});
})
.finally(() => setIsLoading(false));
};
return (
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
<label className="font-normal text-base text-mti-gray-dim">Choose a .txt file containing e-mails</label>
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
</Button>
{user && (user.type === "developer" || user.type === "admin") && (
<>
<div className="flex -md:flex-row md:flex-col -md:items-center 2xl:flex-row 2xl:items-center justify-between gap-2">
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled}>
Enabled
</Checkbox>
</div>
{isExpiryDateEnabled && (
<ReactDatePicker
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out",
)}
filterDate={(date) => moment(date).isAfter(new Date())}
dateFormat="dd/MM/yyyy"
selected={expiryDate}
onChange={(date) => setExpiryDate(date)}
/>
)}
</>
)}
<label className="font-normal text-base text-mti-gray-dim">Select the type of user they should be</label>
{user && (
<select
defaultValue="student"
onChange={(e) => setType(e.target.value as typeof user.type)}
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
{Object.keys(USER_TYPE_LABELS).map((type) => (
<option key={type} value={type}>
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
</option>
))}
</select>
)}
<Button onClick={() => generateCode(type)} disabled={emails.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}>
Generate & Send
</Button>
</div>
);
}

View File

@@ -0,0 +1,115 @@
import Button from "@/components/Low/Button";
import Checkbox from "@/components/Low/Checkbox";
import {PERMISSIONS} from "@/constants/userPermissions";
import {Type, User} from "@/interfaces/user";
import {USER_TYPE_LABELS} from "@/resources/user";
import axios from "axios";
import clsx from "clsx";
import {capitalize} from "lodash";
import moment from "moment";
import {useEffect, useState} from "react";
import ReactDatePicker from "react-datepicker";
import {toast} from "react-toastify";
import ShortUniqueId from "short-unique-id";
export default function CodeGenerator({user}: {user: User}) {
const [generatedCode, setGeneratedCode] = useState<string>();
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [type, setType] = useState<Type>("student");
useEffect(() => {
if (user && (user.type === "corporate" || user.type === "teacher")) {
setExpiryDate(user.subscriptionExpirationDate || null);
}
}, [user]);
useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null);
}, [isExpiryDateEnabled]);
const generateCode = (type: Type) => {
const uid = new ShortUniqueId();
const code = uid.randomUUID(6);
axios
.post("/api/code", {type, codes: [code], expiryDate})
.then(({data, status}) => {
if (data.ok) {
toast.success(`Successfully generated a ${capitalize(type)} code!`, {toastId: "success"});
setGeneratedCode(code);
return;
}
if (status === 403) {
toast.error(data.reason, {toastId: "forbidden"});
}
})
.catch(({response: {status, data}}) => {
if (status === 403) {
toast.error(data.reason, {toastId: "forbidden"});
return;
}
toast.error(`Something went wrong, please try again later!`, {toastId: "error"});
});
};
return (
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
<label className="font-normal text-base text-mti-gray-dim">User Code Generator</label>
{user && (
<select
defaultValue="student"
onChange={(e) => setType(e.target.value as typeof user.type)}
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
{Object.keys(USER_TYPE_LABELS).map((type) => (
<option key={type} value={type}>
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
</option>
))}
</select>
)}
{user && (user.type === "developer" || user.type === "admin") && (
<>
<div className="flex -md:flex-row md:flex-col -md:items-center 2xl:flex-row 2xl:items-center justify-between gap-2">
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled}>
Enabled
</Checkbox>
</div>
{isExpiryDateEnabled && (
<ReactDatePicker
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out",
)}
filterDate={(date) => moment(date).isAfter(new Date())}
dateFormat="dd/MM/yyyy"
selected={expiryDate}
onChange={(date) => setExpiryDate(date)}
/>
)}
</>
)}
<Button onClick={() => generateCode(type)} disabled={isExpiryDateEnabled ? !expiryDate : false}>
Generate
</Button>
<label className="font-normal text-base text-mti-gray-dim">Generated Code:</label>
<div
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out",
)}
data-tip="Click to copy"
onClick={() => {
if (generatedCode) navigator.clipboard.writeText(generatedCode);
}}>
{generatedCode}
</div>
{generatedCode && <span className="text-sm text-mti-gray-dim font-light">Give this code to the user to complete their registration</span>}
</div>
);
}

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