Compare commits

...

318 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
Tiago Ribeiro
93cef3d58f Moved the Logout button to be sticky 2023-06-23 14:17:24 +01:00
Tiago Ribeiro
60b23ce1b5 Removed unused console.log calls 2023-06-23 14:15:57 +01:00
Tiago Ribeiro
d3a37eed3e Redesigned the Record page along with solving some bugs on the FillBlanks 2023-06-23 14:14:12 +01:00
Tiago Ribeiro
447cecbf3f Removed an unneeded console.log 2023-06-23 10:29:10 +01:00
Tiago Ribeiro
b2cc706a5e Updated the Writing exercise to have the evaluation in the user solutions instead of the exercise itself 2023-06-23 10:28:33 +01:00
Tiago Ribeiro
9cbb5b93c8 Added a subtitle of the colors 2023-06-22 23:16:07 +01:00
Tiago Ribeiro
747c07f84e Updated the sidebar 2023-06-22 22:39:25 +01:00
Tiago Ribeiro
79ed521703 Redesigned the MatchSentences exercise 2023-06-22 22:28:29 +01:00
Tiago Ribeiro
fe4a97ec85 Implemented the Writing exercise's solution display 2023-06-22 16:59:13 +01:00
Tiago Ribeiro
b194a9183e Updated the text related to the finish screen depending on the level 2023-06-21 16:43:06 +01:00
Tiago Ribeiro
f369234e8a Updated the stats to have missing 2023-06-21 15:02:42 +01:00
Tiago Ribeiro
808ec6315b Updated the Finish screen along with other tweaks 2023-06-21 14:54:22 +01:00
Tiago Ribeiro
d2cf50be68 Updated the ModuleTitle 2023-06-21 11:00:14 +01:00
Tiago Ribeiro
294f00952e Updated the design of the WriteBlanks exercise 2023-06-20 22:43:28 +01:00
Tiago Ribeiro
7beb1c84e7 Solved a bug in WriteBlanks where it wasn't saving the user's answer 2023-06-20 22:21:50 +01:00
Tiago Ribeiro
3a7c29de56 Made it so the code remembers the user's previous answers to current exercises 2023-06-20 17:07:54 +01:00
Tiago Ribeiro
dd357d991c Started updating the stats page 2023-06-20 09:32:33 +01:00
Tiago Ribeiro
47b1784615 Reverted the yarn version 2023-06-18 23:46:07 +01:00
Tiago Ribeiro
d4156c83f4 Transitioned to yarn classic 2023-06-18 23:31:57 +01:00
Tiago Ribeiro
572bc25eed Removed a trailing comma 2023-06-18 23:20:34 +01:00
Tiago Ribeiro
e80b163b4a Let's try this 2023-06-18 23:11:43 +01:00
Tiago Ribeiro
87e0610c79 Also updated the MultipleChoice exercise to the new design 2023-06-18 22:57:53 +01:00
Tiago Ribeiro
52218ff8b8 Updated the FillBlanks exercise and solution to the new design 2023-06-18 22:02:48 +01:00
Tiago Ribeiro
84b0b8ac42 Removed placeholders 2023-06-15 16:53:11 +01:00
Tiago Ribeiro
989a7449bf Turned on the normalize 2023-06-15 16:35:30 +01:00
Tiago Ribeiro
bc7eaea911 Implemented the speaking exercise;
Cleaned up a bit of the code;
2023-06-15 15:39:40 +01:00
Tiago Ribeiro
f5ec910010 Did the new designs for the Writing 2023-06-15 15:27:04 +01:00
Tiago Ribeiro
2d46bad40f Implemented the Reading and Listening initial screens according to the new designs, creating new components as needed 2023-06-15 14:43:29 +01:00
Tiago Ribeiro
65ebdd7dde Extracted the Input into its own component 2023-06-15 10:10:33 +01:00
Tiago Ribeiro
60217e9a66 - Updated the icons;
- Extracted the Layout into its own component;
2023-06-15 09:12:13 +01:00
Tiago Ribeiro
ec3157870e Updated the selection page 2023-06-14 22:22:18 +01:00
Tiago Ribeiro
9cf4bf7184 Improved the appearance of the Waveform 2023-06-14 17:18:22 +01:00
Tiago Ribeiro
f5fc85e1a7 Created a waveform component to display the recording's waveform 2023-06-14 16:22:48 +01:00
Tiago Ribeiro
31f2eb510e Created a simple test page where I'll implement the recorder for the speaking module 2023-06-14 14:37:12 +01:00
Tiago Ribeiro
31e2e56833 Updated the yarn version and recorder 2023-06-14 13:28:28 +01:00
Tiago Ribeiro
efaa32cd68 Completed the rest of the Selection screen to the new design 2023-06-13 16:24:01 +01:00
Tiago Ribeiro
b41ee8e2ad Updated part of the Selection screen to the new design 2023-06-13 15:43:26 +01:00
Tiago Ribeiro
e055b84688 Moved the exam page to the root pages 2023-06-13 15:25:45 +01:00
Tiago Ribeiro
1e286bb65b Added the ability for the user to show the password they're typing 2023-06-13 15:24:27 +01:00
Tiago Ribeiro
abe986313f Updated the <a> to <Link> 2023-06-12 15:58:17 +01:00
Tiago Ribeiro
088b77a66b Created a placeholder of the register page 2023-06-12 15:47:42 +01:00
Tiago Ribeiro
72fc98fccd Completed the Login page and updated the overall colors and font 2023-06-12 15:21:30 +01:00
Tiago Ribeiro
9ce45dfc30 Recreated most of the login screen with the new designs 2023-06-12 14:57:30 +01:00
Tiago Ribeiro
e864e16064 Updated the code to use the new desired levels 2023-06-12 14:05:48 +01:00
Tiago Ribeiro
6fe8a678ea Completed more of the home page of the new designs 2023-06-12 09:31:20 +01:00
Tiago Ribeiro
b2232df0c7 Created part of the homepage of the new Figma designs 2023-06-11 17:58:06 +01:00
Tiago Ribeiro
9a7853bd05 Created a score calculator 2023-06-05 14:04:58 +01:00
Tiago Ribeiro
1e8e95da34 Continued implementing the new design;
Added an average level calculator;
2023-05-31 14:01:12 +01:00
Tiago Ribeiro
4d37bf536a Merge branch 'main' into task/design/dashboard-redesign 2023-05-29 11:57:37 +01:00
Tiago Ribeiro
d0704e573b Removed unused Navbar calls 2023-05-27 11:17:43 +01:00
Tiago Ribeiro
31dc29b812 Removed the Navbar calls 2023-05-26 20:26:11 +01:00
Tiago Ribeiro
9ed3672cb6 Started the redesign of the dashboard 2023-05-26 19:46:50 +01:00
192 changed files with 18673 additions and 2854 deletions

7
.dockerignore Normal file
View File

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

3
.gitignore vendored
View File

@@ -36,3 +36,6 @@ yarn-error.log*
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

5
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"recommendations": [
"dbaeumer.vscode-eslint"
]
}

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,
}
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,50 +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",
"daisyui": "^2.50.0",
"countries-list": "^3.0.1",
"country-codes-list": "^1.6.11",
"currency-symbol-map": "^5.1.0",
"daisyui": "^3.1.5",
"eslint": "8.33.0",
"eslint-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.6",
"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

Binary file not shown.

After

Width:  |  Height:  |  Size: 832 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,116 +3,228 @@ 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();
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, 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)}>
Academic
</button>
<button onClick={() => setFocus("general")} className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}>
General
</button>
</div>
</div>
);
}
if (isInsert) {
return (
<div className="bg-white p-16 rounded-2xl flex flex-col items-center justify-center gap-8 shadow-md">
<h2 className="font-semibold text-xl">What is your level?</h2>
<div className="flex w-full flex-col gap-4 justify-self-stretch">
{Object.keys(levels).map((module) => (
<div key={module} className="flex items-center gap-4 justify-between">
<span className="font-medium text-lg">{capitalize(module)}</span>
<input
type="number"
className={clsx(
"input input-bordered bg-white w-24",
!BAND_SCORES[module as Module].includes(levels[module as keyof typeof levels]) && "input-error",
)}
value={levels[module as keyof typeof levels]}
min={0}
max={9}
step={0.5}
onChange={(e) => setLevels((prev) => ({...prev, [module]: parseFloat(e.target.value)}))}
/>
</div>
))}
</div>
<button
onClick={() => updateUser(onFinish)}
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
disabled={!Object.keys(levels).every((module) => BAND_SCORES[module as Module].includes(levels[module as keyof typeof levels]))}>
Next
</button>
</div>
);
}
return (
<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 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(
"w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-center items-center gap-12 bg-white",
"hover:bg-mti-purple-light hover:text-white",
focus === "general" && "!bg-mti-purple-light !text-white",
"transition duration-300 ease-in-out",
)}>
General
</button>
</div>
</div>
</div>
<div className="flex flex-col items-center justify-center gap-8 w-full">
<h2 className="font-semibold text-xl">What is your current IELTS level?</h2>
<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>
</div>
</div>
</div>
);

View File

@@ -1,95 +1,123 @@
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import {FillBlanksExercise} from "@/interfaces/exam";
import {Dialog, Transition} from "@headlessui/react";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import useExamStore from "@/stores/examStore";
import clsx from "clsx";
import {Fragment, useState} from "react";
import {Fragment, useEffect, useState} from "react";
import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
import Button from "../Low/Button";
interface WordsPopoutProps {
interface WordsDrawerProps {
words: {word: string; isDisabled: boolean}[];
isOpen: boolean;
blankId?: string;
previouslySelectedWord?: string;
onCancel: () => void;
onAnswer: (answer: string) => void;
}
function WordsPopout({words, isOpen, onCancel, onAnswer}: WordsPopoutProps) {
function WordsDrawer({words, isOpen, blankId, previouslySelectedWord, onCancel, onAnswer}: WordsDrawerProps) {
const [selectedWord, setSelectedWord] = useState<string | undefined>(previouslySelectedWord);
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={onCancel}>
<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-fit transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all flex flex-col">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
List of words
</Dialog.Title>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{words.map((word) => (
<button
key={word.word}
onClick={() => onAnswer(word.word)}
disabled={word.isDisabled}
className={clsx("btn sm:btn-wide gap-4 relative text-white", infoButtonStyle)}>
{word.word}
</button>
))}
</div>
<div className="mt-4 self-end">
<button onClick={onCancel} className={clsx("btn md:btn-wide gap-4 relative text-white", errorButtonStyle)}>
Close
</button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
<>
<div
className={clsx(
"w-full h-full absolute top-0 left-0 bg-gradient-to-t from-mti-black to-transparent z-10",
isOpen ? "visible opacity-10" : "invisible opacity-0",
)}
/>
<div
className={clsx(
"absolute w-full bg-white px-7 py-8 bottom-0 left-0 shadow-2xl rounded-2xl z-20 flex flex-col gap-8 transition-opacity duration-300 ease-in-out",
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-purple-light">{blankId}</div>
<span> Choose the correct word:</span>
</div>
</Dialog>
</Transition>
<div className="grid grid-cols-6 gap-6" key="word-array">
{words.map(({word, isDisabled}) => (
<button
key={`${word}_${blankId}`}
onClick={() => setSelectedWord((prev) => (prev === word ? undefined : word))}
className={clsx(
"rounded-full py-3 text-center transition duration-300 ease-in-out",
selectedWord === word ? "text-white bg-mti-purple-light" : "bg-mti-purple-ultralight",
!isDisabled && "hover:text-white hover:bg-mti-purple",
"disabled:cursor-not-allowed disabled:text-mti-gray-dim",
)}
disabled={isDisabled}>
{word}
</button>
))}
</div>
<div className="flex justify-between w-full">
<Button color="purple" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
Back
</Button>
<Button color="purple" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
Confirm
</Button>
</div>
</div>
</>
);
}
export default function FillBlanks({id, allowRepetition, type, prompt, solutions, text, words, onNext, onBack}: FillBlanksExercise & CommonProps) {
const [userSolutions, setUserSolutions] = useState<{id: string; solution: string}[]>([]);
export default function FillBlanks({
id,
allowRepetition,
type,
prompt,
solutions,
text,
words,
userSolutions,
onNext,
onBack,
}: FillBlanksExercise & CommonProps) {
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
const [currentBlankId, setCurrentBlankId] = useState<string>();
const [isDrawerShowing, setIsDrawerShowing] = useState(false);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const 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 = userSolutions.filter((x) => solutions.find((y) => x.id === y.id)?.solution === x.solution.toLowerCase() || false).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};
return {total, correct, missing};
};
const renderLines = (line: string) => {
return (
<span>
<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 userSolution = answers.find((x) => x.id === id);
return (
<button className="border-2 rounded-xl px-4 text-blue-400 border-blue-400 my-2" onClick={() => setCurrentBlankId(id)}>
<button
className={clsx(
"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}
</button>
);
@@ -100,17 +128,26 @@ export default function FillBlanks({id, allowRepetition, type, prompt, solutions
return (
<>
<div className="flex flex-col gap-4">
<WordsPopout
words={words.map((word) => ({word, isDisabled: allowRepetition ? false : userSolutions.map((x) => x.solution).includes(word)}))}
isOpen={!!currentBlankId}
onCancel={() => setCurrentBlankId(undefined)}
onAnswer={(solution: string) => {
setUserSolutions((prev) => [...prev.filter((x) => x.id !== currentBlankId), {id: currentBlankId!, solution}]);
setCurrentBlankId(undefined);
}}
/>
<span className="text-base md:text-lg font-medium text-center px-2 md:px-4 lg:px-48">
<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}
isOpen={isDrawerShowing}
onCancel={() => setCurrentBlankId(undefined)}
onAnswer={(solution: string) => {
setAnswers((prev) => [...prev.filter((x) => x.id !== currentBlankId), {id: currentBlankId!, solution}]);
if (allBlanks.findIndex((x) => x === currentBlankId) + 1 < allBlanks.length) {
setCurrentBlankId(allBlanks[allBlanks.findIndex((x) => x === currentBlankId) + 1]);
return;
}
setCurrentBlankId(undefined);
}}
/>
)}
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
@@ -118,31 +155,31 @@ export default function FillBlanks({id, allowRepetition, type, prompt, solutions
</Fragment>
))}
</span>
<span>
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
{text.split("\\n").map((line, index) => (
<Fragment key={index}>
<p key={index}>
{renderLines(line)}
<br />
</Fragment>
</p>
))}
</span>
</div>
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
<div className="absolute left-4">
<Icon path={mdiArrowLeft} color="white" size={1} />
</div>
<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
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}>
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</Button>
</div>
</>
);

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,35 +3,44 @@ 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, onNext, onBack}: MatchSentencesExercise & CommonProps) {
export default function MatchSentences({id, options, type, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) {
const [selectedQuestion, setSelectedQuestion] = useState<string>();
const [userSolutions, setUserSolutions] = useState<{question: string; option: string}[]>([]);
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const calculateScore = () => {
const total = sentences.length;
const correct = userSolutions.filter((x) => sentences.find((y) => y.id === x.question)?.solution === x.option || false).length;
const correct = answers.filter(
(x) => sentences.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false,
).length;
const missing = total - answers.filter((x) => sentences.find((y) => y.id.toString() === x.question.toString())).length;
return {total, correct};
return {total, correct, missing};
};
const selectOption = (option: string) => {
if (!selectedQuestion) return;
setUserSolutions((prev) => [...prev.filter((x) => x.question !== selectedQuestion), {question: selectedQuestion, option}]);
setAnswers((prev) => [...prev.filter((x) => x.question !== selectedQuestion), {question: selectedQuestion, option}]);
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 (
<>
<div className="flex flex-col items-center gap-8">
<span className="text-base md:text-lg font-medium text-center px-2 md:px-4 lg:px-48">
<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}
@@ -39,74 +48,63 @@ export default function MatchSentences({id, options, type, prompt, sentences, on
</Fragment>
))}
</span>
<div className="grid grid-cols-2 gap-16 place-items-center">
<div className="flex flex-col gap-1">
{sentences.map(({sentence, id, color}) => (
<div
key={`question_${id}`}
className="flex items-center justify-end gap-2 cursor-pointer"
onClick={() => setSelectedQuestion((prev) => (prev === id ? undefined : id))}>
<span>
<span className="font-semibold">{id}.</span> {sentence}{" "}
</span>
<div
style={{borderColor: color, backgroundColor: selectedQuestion === id ? color : "transparent"}}
className={clsx("border-2 border-blue-500 w-4 h-4 rounded-full", id)}
/>
<div 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}) => (
<div key={`question_${id}`} className="flex items-center justify-end gap-2 cursor-pointer">
<span>{sentence} </span>
<button
id={id}
onClick={() => setSelectedQuestion((prev) => (prev === id ? undefined : id))}
className={clsx(
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
"transition duration-300 ease-in-out",
selectedQuestion === id && "!text-white !bg-mti-purple",
id,
)}>
{id}
</button>
</div>
))}
</div>
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-4">
{options.map(({sentence, id}) => (
<div
key={`answer_${id}`}
className={clsx("flex items-center justify-start gap-2 cursor-pointer")}
onClick={() => selectOption(id)}>
<div
style={
userSolutions.find((x) => x.option === id)
? {
border: `2px solid ${getSentenceColor(userSolutions.find((x) => x.option === id)!.question)}`,
}
: {}
}
className={clsx("border-2 border-green-500 bg-transparent w-4 h-4 rounded-full", id)}
/>
<span>
<span className="font-semibold">{id}.</span> {sentence}{" "}
</span>
<div key={`answer_${id}`} className={clsx("flex items-center justify-start gap-2 cursor-pointer")}>
<button
id={id}
onClick={() => selectOption(id)}
className={clsx(
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
"transition duration-300 ease-in-out",
id,
)}>
{id}
</button>
<span>{sentence}</span>
</div>
))}
</div>
{userSolutions.map((solution, index) => (
<div key={`solution_${index}`} className="absolute">
<LineTo
className="rounded-full"
from={solution.question}
to={solution.option}
borderColor={sentences.find((x) => x.id === solution.question)!.color}
borderWidth={5}
/>
</div>
{answers.map((solution, index) => (
<Xarrow key={index} start={solution.question} end={solution.option} lineColor="#7872BF" showHead={false} />
))}
</div>
</div>
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
<div className="absolute left-4">
<Icon path={mdiArrowLeft} color="white" size={1} />
</div>
<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
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}>
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</Button>
</div>
</>
);

View File

@@ -1,80 +1,45 @@
/* eslint-disable @next/next/no-img-element */
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight, mdiCheck, mdiClose} from "@mdi/js";
import Icon from "@mdi/react";
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";
function Question({
variant,
prompt,
solution,
options,
userSolution,
onSelectOption,
showSolution = false,
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
const optionColor = (option: string) => {
if (!showSolution) {
return userSolution === option ? "border-blue-400" : "";
}
if (option === solution) {
return "border-green-500 text-green-500";
}
return userSolution === option ? "border-red-500 text-red-500" : "";
};
const optionBadge = (option: string) => {
if (option === userSolution) {
if (solution === option) {
return (
<div className="badge badge-lg bg-green-500 border-green-500 absolute -top-2 -right-4">
<div className="tooltip" data-tip="You have correctly answered!">
<Icon path={mdiCheck} color="white" size={0.8} />
</div>
</div>
);
}
return (
<div className="badge badge-lg bg-red-500 border-red-500 absolute -top-2 -right-4">
<div className="tooltip" data-tip="You have wrongly answered!">
<Icon path={mdiClose} color="white" size={0.8} />
</div>
</div>
);
}
};
return (
<div className="flex flex-col items-center gap-4">
<span className="text-center">{prompt}</span>
<div className="grid grid-rows-4 md:grid-rows-1 md:grid-cols-4 gap-4 place-items-center">
<div className="flex flex-col gap-10">
<span className="">{prompt}</span>
<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-2 p-4 rounded-xl gap-4 cursor-pointer bg-white relative",
optionColor(option.id),
"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.toString() && "border-mti-purple-light",
)}>
{showSolution && optionBadge(option.id)}
<img src={option.src!} alt={`Option ${option.id}`} />
<span>{option.id}</span>
<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)}
className={clsx("flex border-2 p-4 rounded-xl gap-2 cursor-pointer bg-white", optionColor(option.id))}>
<span className="font-bold">{option.id}.</span>
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.toString() && "border-mti-purple-light",
)}>
<span className="font-semibold">{option.id.toString()}.</span>
<span>{option.text}</span>
</div>
))}
@@ -83,25 +48,48 @@ function Question({
);
}
export default function MultipleChoice({id, prompt, type, questions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
const [userSolutions, setUserSolutions] = useState<{question: string; option: string}[]>([]);
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];
setUserSolutions((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);
};
const calculateScore = () => {
const total = questions.length;
const correct = userSolutions.filter((x) => questions.find((y) => y.id === x.question)?.solution === x.option || false).length;
const correct = answers.filter(
(x) => questions.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false,
).length;
const missing = total - answers.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length;
return {total, correct};
return {total, correct, missing};
};
const next = () => {
if (questionIndex === questions.length - 1) {
onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
onNext({exercise: id, solutions: answers, score: calculateScore(), type});
} else {
setQuestionIndex((prev) => prev + 1);
}
@@ -109,7 +97,7 @@ export default function MultipleChoice({id, prompt, type, questions, onNext, onB
const back = () => {
if (questionIndex === 0) {
onBack();
onBack({exercise: id, solutions: answers, score: calculateScore(), type});
} else {
setQuestionIndex((prev) => prev - 1);
}
@@ -117,30 +105,25 @@ export default function MultipleChoice({id, prompt, type, questions, onNext, onB
return (
<>
<div className="flex flex-col items-center gap-4">
<span className="text-base md:text-lg font-medium text-center px-2 md:px-4 lg:px-48">{prompt}</span>
<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
{...questions[questionIndex]}
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option}
userSolution={answers.find((x) => questions[questionIndex].id === x.question)?.option}
onSelectOption={onSelectOption}
/>
)}
</div>
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={back}>
<div className="absolute left-4">
<Icon path={mdiArrowLeft} color="white" size={1} />
</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={back} className="max-w-[200px] w-full">
Back
</button>
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={next}>
</Button>
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</Button>
</div>
</>
);

View File

@@ -1,52 +1,233 @@
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, 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) {
recordingInterval = setInterval(() => setRecordingDuration((prev) => prev + 1), 1000);
} else if (recordingInterval) {
clearInterval(recordingInterval);
}
return () => {
if (recordingInterval) clearInterval(recordingInterval);
};
}, [isRecording]);
export default function Speaking({id, title, text, type, prompts, onNext, onBack}: SpeakingExercise & CommonProps) {
return (
<div className="flex flex-col h-full w-2/3 items-center justify-center gap-8">
<div className="flex flex-col max-w-2xl gap-4">
<span className="font-bold">{title}</span>
<span className="font-regular ml-8">
{text.split("\\n").map((line, index) => (
<Fragment key={index}>
<span>{line}</span>
<br />
</Fragment>
))}
</span>
<div className="flex gap-8">
<span>You should talk about the following things:</span>
<div className="flex flex-col gap-4">
{prompts.map((x, index) => (
<li className="italic" key={index}>
{x}
</li>
))}
</div>
<div className="flex flex-col h-full w-full gap-9">
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
<div className="flex flex-col 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="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
<div className="absolute left-4">
<Icon path={mdiArrowLeft} color="white" size={1} />
<ReactMediaRecorder
audio
onStop={(blob) => setMediaBlob(blob)}
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => (
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
<p className="text-base font-normal">Record your answer:</p>
<div className="flex gap-8 items-center justify-center py-8">
{status === "idle" && (
<>
<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: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Back
</button>
<button
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
onClick={() => onNext({exercise: id, solutions: [], score: {correct: 1, total: 1}, type})}>
</Button>
<Button
color="purple"
disabled={!mediaBlob}
onClick={() =>
onNext({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</Button>
</div>
</div>
);

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

@@ -3,14 +3,17 @@ import {WriteBlanksExercise} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {useEffect, useState} from "react";
import {Fragment, useEffect, useState} from "react";
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,
maxWords,
userSolution,
showSolutions = false,
setUserSolution,
}: {
@@ -19,9 +22,9 @@ function Blank({
userSolution?: string;
maxWords: number;
showSolutions?: boolean;
setUserSolution?: (solution: string) => void;
setUserSolution: (solution: string) => void;
}) {
const [userInput, setUserInput] = useState("");
const [userInput, setUserInput] = useState(userSolution || "");
useEffect(() => {
const words = userInput.split(" ").filter((x) => x !== "");
@@ -33,39 +36,48 @@ function Blank({
return (
<input
className={clsx("input border rounded-xl px-2 py-1 bg-white text-blue-400 border-blue-400 my-2")}
className="py-2 px-3 mx-2 rounded-2xl w-48 bg-white focus:outline-none my-2"
placeholder={id}
onChange={(e) => setUserInput(e.target.value)}
onBlur={() => setUserSolution(userInput)}
value={userInput}
contentEditable={showSolutions}
/>
);
}
export default function WriteBlanks({id, prompt, type, maxWords, solutions, text, onNext, onBack}: WriteBlanksExercise & CommonProps) {
const [userSolutions, setUserSolutions] = useState<{id: string; solution: string}[]>([]);
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 = userSolutions.filter(
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;
return {total, correct};
return {total, correct, missing};
};
const renderLines = (line: string) => {
return (
<span>
<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 userSolution = answers.find((x) => x.id === id);
const setUserSolution = (solution: string) => {
setUserSolutions((prev) => [...prev.filter((x) => x.id !== id), {id, solution}]);
setAnswers((prev) => [...prev.filter((x) => x.id !== id), {id, solution}]);
};
return <Blank userSolution={userSolution?.solution} maxWords={maxWords} id={id} setUserSolution={setUserSolution} />;
@@ -76,33 +88,40 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, text
return (
<>
<div className="flex flex-col">
<span className="text-lg font-medium text-center px-48">{prompt}</span>
<span>
{text.split("\\n").map((line) => (
<>
<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) => (
<span key={index}>
{line}
<br />
</span>
))}
</span>
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
{text.split("\\n").map((line, index) => (
<p key={index}>
{renderLines(line)}
<br />
</>
</p>
))}
</span>
</div>
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
<div className="absolute left-4">
<Icon path={mdiArrowLeft} color="white" size={1} />
</div>
<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
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}>
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</Button>
</div>
</>
);

View File

@@ -1,17 +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, onNext, onBack}: WritingExercise & CommonProps) {
const [inputText, setInputText] = useState("");
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 !== "");
@@ -27,59 +59,103 @@ export default function Writing({id, prompt, info, type, wordCounter, attachment
}, [inputText, wordCounter]);
return (
<div className="flex flex-col h-full w-2/3 items-center justify-center gap-8">
<div className="flex flex-col max-w-2xl gap-2">
<span>{info}</span>
<span className="font-bold ml-8">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
<span>{line}</span>
<br />
</Fragment>
))}
</span>
<span>
You should write {wordCounter.type === "min" ? "at least" : "at most"} {wordCounter.limit} words.
</span>
{attachment && <img src={attachment} alt="Exercise attachment" />}
<>
{attachment && (
<Transition show={isModalOpen} as={Fragment}>
<Dialog onClose={() => setIsModalOpen(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-fit h-fit rounded-xl bg-white">
<img src={attachment.url} alt={attachment.description} className="max-w-4xl w-full self-center rounded-xl p-4" />
</Dialog.Panel>
</div>
</Transition.Child>
</Dialog>
</Transition>
)}
<div className="flex flex-col h-full w-full gap-9 mb-20">
<div className="flex flex-col w-full gap-7 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
<span>
{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}>
<p>{line}</p>
<br />
</Fragment>
))}
</span>
{attachment && (
<img
onClick={() => setIsModalOpen(true)}
src={attachment.url}
alt={attachment.description}
className="max-w-md self-center rounded-xl cursor-pointer"
/>
)}
</div>
<div className="w-full h-full flex flex-col gap-4">
<span>
{suffix.split("\\n").map((line, index) => (
<React.Fragment key={index}>
{line}
<br />
</React.Fragment>
))}
</span>
<textarea
onContextMenu={(e) => e.preventDefault()}
className="w-full h-full min-h-[300px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
onChange={(e) => setInputText(e.target.value)}
value={inputText}
placeholder="Write your text here..."
spellCheck={false}
/>
<span className="text-base self-end text-mti-gray-cool">Word Count: {inputText.split(" ").filter((x) => x !== "").length}</span>
</div>
</div>
<textarea
className="w-full h-1/3 cursor-text p-2 input input-bordered bg-white"
onChange={(e) => setInputText(e.target.value)}
value={inputText}
placeholder="Write your text here..."
/>
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
<div className="absolute left-4">
<Icon path={mdiArrowLeft} color="white" size={1} />
</div>
<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: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type})}
className="max-w-[200px] self-end w-full">
Back
</button>
{!isSubmitEnabled && (
<div className="tooltip" data-tip={`You have not yet reached your minimum word count of ${wordCounter.limit} words!`}>
<button
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
disabled={!isSubmitEnabled}
onClick={() => onNext({exercise: id, solutions: [inputText], score: {correct: 1, total: 1}, type})}>
Next
</button>
</div>
)}
{isSubmitEnabled && (
<button
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
disabled={!isSubmitEnabled}
onClick={() => onNext({exercise: id, solutions: [inputText], score: {correct: 1, total: 1}, type})}>
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
)}
</Button>
<Button
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">
Next
</Button>
</div>
</div>
</>
);
}

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: () => void;
onBack: (userSolutions: UserSolution) => void;
}
export const renderExercise = (exercise: Exercise, onNext: (userSolutions: UserSolution) => void, onBack: () => 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

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

View File

@@ -0,0 +1,96 @@
import {Module} from "@/interfaces";
import {formatTimeInMinutes} from "@/utils/string";
import clsx from "clsx";
import {useEffect, useRef, useState} from "react";
import {BsPauseFill, BsPlayFill} from "react-icons/bs";
import ProgressBar from "./ProgressBar";
interface Props {
src: string;
color: "red" | "rose" | "purple" | Module;
autoPlay?: boolean;
disabled?: boolean;
onEnd?: () => void;
disablePause?: boolean;
}
export default function AudioPlayer({src, color, autoPlay = false, disabled = false, onEnd, disablePause = false}: Props) {
const [isPlaying, setIsPlaying] = useState(false);
const [duration, setDuration] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
const durationInterval = setInterval(() => {
if (duration > 0) clearInterval(durationInterval);
const seconds = Math.floor(audioPlayerRef?.current?.duration || 0);
if (seconds > 0) setDuration(seconds);
}, 300);
if (duration > 0) clearInterval(durationInterval);
return () => {
clearInterval(durationInterval);
};
}, [duration]);
useEffect(() => {
let playingInterval: NodeJS.Timer | undefined = undefined;
if (isPlaying) {
playingInterval = setInterval(() => setCurrentTime((prev) => prev + 1), 1000);
} else if (playingInterval) {
clearInterval(playingInterval);
}
return () => {
if (playingInterval) clearInterval(playingInterval);
};
}, [isPlaying]);
const togglePlayPause = () => {
const prevValue = isPlaying;
setIsPlaying(!prevValue);
if (!prevValue) {
audioPlayerRef?.current?.play();
} else {
audioPlayerRef?.current?.pause();
}
};
return (
<div className="w-full h-fit flex gap-4 items-center mt-2">
{isPlaying && (
<BsPauseFill
className={clsx("text-mti-gray-cool cursor-pointer w-5 h-5", (disabled || disablePause) && "opacity-60 cursor-not-allowed")}
onClick={disabled || disablePause ? undefined : togglePlayPause}
/>
)}
{!isPlaying && (
<BsPlayFill
className={clsx("text-mti-gray-cool cursor-pointer w-5 h-5", disabled && "opacity-60 cursor-not-allowed")}
onClick={disabled ? undefined : togglePlayPause}
/>
)}
<audio
src={src}
autoPlay={autoPlay}
ref={audioPlayerRef}
preload="metadata"
onEnded={() => {
setIsPlaying(false);
setCurrentTime(0);
if (onEnd) onEnd();
}}
/>
<div className="flex flex-col gap-2 w-full relative">
<div className="absolute w-full flex justify-between -top-5 text-xs px-1">
<span>{formatTimeInMinutes(currentTime)}</span>
<span>{formatTimeInMinutes(duration)}</span>
</div>
<ProgressBar label="" color={color} useColor percentage={(currentTime * 100) / duration} className="h-3 w-full" />
</div>
</div>
);
}

View File

@@ -0,0 +1,67 @@
import clsx from "clsx";
import {ReactNode} from "react";
import {BsArrowRepeat} from "react-icons/bs";
interface Props {
children: ReactNode;
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 = "purple",
variant = "solid",
disabled = false,
isLoading = false,
className,
children,
type,
onClick,
}: Props) {
const colorClassNames: {[key in typeof color]: {[key in typeof variant]: string}} = {
green: {
solid: "bg-mti-green-light text-white border border-mti-green-light hover:bg-mti-green disabled:text-mti-green disabled:bg-mti-green-ultralight selection:bg-mti-green-dark",
outline:
"bg-transparent text-mti-green-light border border-mti-green-light hover:bg-mti-green-light disabled:text-mti-green disabled:bg-mti-green-ultralight disabled:border-none selection:bg-mti-green-dark hover:text-white selection:text-white",
},
purple: {
solid: "bg-mti-purple-light text-white border border-mti-purple-light hover:bg-mti-purple disabled:text-mti-purple disabled:bg-mti-purple-ultralight selection:bg-mti-purple-dark",
outline:
"bg-transparent text-mti-purple-light border border-mti-purple-light hover:bg-mti-purple-light disabled:text-mti-purple disabled:bg-mti-purple-ultralight disabled:border-none selection:bg-mti-purple-dark hover:text-white selection:text-white",
},
red: {
solid: "bg-mti-red-light text-white border border-mti-red-light hover:bg-mti-red disabled:text-mti-red disabled:bg-mti-red-ultralight selection:bg-mti-red-dark",
outline:
"bg-transparent text-mti-red-light border border-mti-red-light hover:bg-mti-red-light disabled:text-mti-red disabled:bg-mti-red-ultralight disabled:border-none selection:bg-mti-red-dark hover:text-white selection:text-white",
},
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 cursor-pointer",
className,
colorClassNames[color][variant],
)}
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

@@ -0,0 +1,88 @@
import clsx from "clsx";
import {useState} from "react";
interface Props {
type: "email" | "text" | "password" | "tel" | "number";
roundness?: "full" | "xl";
required?: boolean;
label?: string;
placeholder?: string;
defaultValue?: string | number;
value?: string | number;
className?: string;
disabled?: boolean;
name: string;
onChange: (value: string) => void;
}
export default function Input({
type,
label,
placeholder,
name,
required = false,
value,
defaultValue,
className,
roundness = "full",
disabled = false,
onChange,
}: Props) {
const [showPassword, setShowPassword] = useState(false);
if (type === "password") {
return (
<div className="relative flex flex-col gap-3 w-full">
{label && (
<label className="font-normal text-base text-mti-gray-dim">
{label}
{required ? " *" : ""}
</label>
)}
<div className="w-full h-fit relative">
<input
type={showPassword ? "text" : "password"}
name={name}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
defaultValue={defaultValue}
className="w-full px-8 py-6 text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
/>
<p
role="button"
onClick={() => setShowPassword((prev) => !prev)}
className="text-xs cursor-pointer absolute bottom-1/2 translate-y-1/2 right-8">
{showPassword ? "Hide" : "Show"}
</p>
</div>
</div>
);
}
return (
<div className={clsx("flex flex-col gap-3 w-full", className)}>
{label && (
<label className="font-normal text-base text-mti-gray-dim">
{label}
{required ? " *" : ""}
</label>
)}
<input
type={type}
name={name}
disabled={disabled}
value={value}
onChange={(e) => onChange(e.target.value)}
min={type === "number" ? 0 : undefined}
placeholder={placeholder}
className={clsx(
"px-8 py-6 text-sm font-normal bg-white border border-mti-gray-platinum focus:outline-none",
"placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed",
roundness === "full" ? "rounded-full" : "rounded-xl",
)}
required={required}
defaultValue={defaultValue}
/>
</div>
);
}

View File

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

View File

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

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,77 +1,82 @@
import {Type} from "@/interfaces/user";
import axios from "axios";
import {User} from "@/interfaces/user";
import Link from "next/link";
import FocusLayer from "@/components/FocusLayer";
import {preventNavigation} from "@/utils/navigation.disabled";
import {useRouter} from "next/router";
import {Button} from "primereact/button";
import {Menubar} from "primereact/menubar";
import {MenuItem} from "primereact/menuitem";
import {BsList} from "react-icons/bs";
import clsx from "clsx";
import moment from "moment";
import MobileMenu from "./MobileMenu";
import {useState} from "react";
interface Props {
profilePicture: string;
userType: Type;
timer?: number;
showExamEnd?: boolean;
user: User;
navDisabled?: boolean;
focusMode?: boolean;
onFocusLayerMouseEnter?: () => void;
path: string;
}
/* eslint-disable @next/next/no-img-element */
export default function Navbar({profilePicture, userType, timer, showExamEnd = false}: 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 logout = async () => {
axios.post("/api/logout").finally(() => {
router.push("/login");
});
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 items: MenuItem[] = [
{
label: "Home",
icon: "pi pi-fw pi-home",
url: "/",
},
{
label: "Account",
icon: "pi pi-fw pi-user",
url: "/profile",
},
{
label: "Exam",
icon: "pi pi-fw pi-plus-circle",
url: "/exam",
},
{
label: "Users",
icon: "pi pi-fw pi-users",
items: [
...(userType === "student" ? [] : [{label: "List", icon: "pi pi-fw pi-users", url: "/users"}]),
{label: "Stats", icon: "pi pi-fw pi-chart-pie", url: "/stats"},
{label: "History", icon: "pi pi-fw pi-history", url: "/history"},
],
},
{
label: "Logout",
icon: "pi pi-fw pi-power-off",
command: logout,
},
];
const showExpirationDate = () => {
if (!user.subscriptionExpirationDate) return false;
const endTimer = timer && (
<span className="pr-2 font-semibold">
{Math.floor(timer / 60) < 10 ? "0" : ""}
{Math.floor(timer / 60)}:{timer % 60 < 10 ? "0" : ""}
{timer % 60}
</span>
);
const momentDate = moment(user.subscriptionExpirationDate);
const today = moment(new Date());
const endNewExam = (
<Link href="/exam" className="pr-2">
<Button text label="Exam" severity="secondary" size="small" />
</Link>
);
return today.add(7, "days").isAfter(momentDate);
};
return (
<div className="bg-neutral-100 z-10 w-full p-2">
<Menubar model={items} end={showExamEnd ? endNewExam : endTimer} />
</div>
<>
{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>
);
}

174
src/components/Sidebar.tsx Normal file
View File

@@ -0,0 +1,174 @@
import clsx from "clsx";
import {IconType} from "react-icons";
import {MdSpaceDashboard} from "react-icons/md";
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 {
Icon: IconType;
label: string;
path: string;
keyPath: string;
disabled?: boolean;
isMinimized?: boolean;
}
const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = 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-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={24} />
{!isMinimized && <span className="text-lg font-semibold">{label}</span>}
</Link>
);
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(() => {
setTimeout(() => router.reload(), 500);
});
};
const disableNavigation = preventNavigation(navDisabled, focusMode);
return (
<section
className={clsx(
"h-full flex bg-transparent flex-col justify-between px-4 py-4 pb-8 relative",
isMinimized ? "w-fit" : "w-1/6 -xl:w-fit",
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={toggleMinimize}
className={clsx(
"p-4 rounded-full flex gap-4 items-center cursor-pointer text-black hover:text-mti-rose -xl:hidden transition duration-300 ease-in-out",
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
)}>
{isMinimized ? <BsChevronBarRight size={24} /> : <BsChevronBarLeft size={24} />}
{!isMinimized && <span className="text-lg font-medium">Minimize</span>}
</div>
<div
role="button"
tabIndex={1}
onClick={focusMode ? () => {} : logout}
className={clsx(
"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

@@ -1,40 +1,70 @@
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import {FillBlanksExercise} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
import {Fragment} from "react";
import Button from "../Low/Button";
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};
};
export default function FillBlanksSolutions({prompt, solutions, text, userSolutions, onNext, onBack}: FillBlanksExercise & CommonProps) {
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)!;
if (!userSolution) {
return (
<>
<button className={clsx("border-2 rounded-xl px-4 text-gray-500 border-gray-500 my-2")}>{solution.solution}</button>
</>
<button
className={clsx(
"rounded-full hover:text-white hover:bg-mti-red transition duration-300 ease-in-out my-1 px-5 py-2 text-center text-white bg-mti-red-light",
)}>
{solution.solution}
</button>
);
}
if (userSolution.solution === solution.solution) {
return <button className={clsx("border-2 rounded-xl px-4 text-green-500 border-green-500 my-2")}>{solution.solution}</button>;
return (
<button
className={clsx(
"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>
);
}
if (userSolution.solution !== solution.solution) {
return (
<>
<button className={clsx("border-2 rounded-xl px-4 text-red-500 border-red-500 mr-1 my-2")}>
<button
className={clsx(
"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("border-2 rounded-xl px-4 text-green-400 border-green-400 my-2")}>{solution.solution}</button>
<button
className={clsx(
"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>
</>
);
}
@@ -45,8 +75,8 @@ export default function FillBlanksSolutions({prompt, solutions, text, userSoluti
return (
<>
<div className="flex flex-col gap-4">
<span className="text-base md:text-lg font-medium text-center px-2 md:px-4 lg:px-48">
<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}
@@ -54,29 +84,46 @@ export default function FillBlanksSolutions({prompt, solutions, text, userSoluti
</Fragment>
))}
</span>
<span>
{text.split("\\n").map((line, index) => (
<Fragment key={index}>
{renderLines(line)}
<br />
</Fragment>
))}
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
{userSolutions &&
text.split("\\n").map((line, index) => (
<p key={index}>
{renderLines(line)}
<br />
</p>
))}
</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-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 flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
<div className="absolute left-4">
<Icon path={mdiArrowLeft} color="white" size={1} />
</div>
<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 className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={onNext}>
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</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

@@ -6,12 +6,33 @@ import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import {Fragment} from "react";
import Button from "../Low/Button";
import Xarrow from "react-xarrows";
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};
};
export default function MatchSentencesSolutions({options, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) {
return (
<>
<div className="flex flex-col items-center gap-8">
<span className="text-base md:text-lg font-medium text-center px-2 md:px-4 lg:px-48">
<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}
@@ -19,63 +40,85 @@ export default function MatchSentencesSolutions({options, prompt, sentences, use
</Fragment>
))}
</span>
<div className="grid grid-cols-2 gap-16 place-items-center">
<div className="flex flex-col gap-1">
{sentences.map(({sentence, id, color, solution}) => (
<div
key={`question_${id}`}
className={clsx(
"flex items-center justify-end gap-2 cursor-pointer",
userSolutions.find((x) => x.question === id)?.option === solution ? "text-green-500" : "text-red-500",
)}>
<span>
<span className="font-semibold">{id}.</span> {sentence}{" "}
</span>
<div style={{borderColor: color}} className={clsx("border-2 border-blue-500 w-4 h-4 rounded-full", id)} />
<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, solution}) => (
<div key={`question_${id}`} className="flex items-center justify-end gap-2 cursor-pointer">
<span>{sentence} </span>
<button
id={id}
className={clsx(
"w-8 h-8 rounded-full z-10 text-white",
"transition duration-300 ease-in-out",
!userSolutions.find((x) => x.question.toString() === id.toString()) && "!bg-mti-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>
</div>
))}
</div>
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-4">
{options.map(({sentence, id}) => (
<div key={`answer_${id}`} className={clsx("flex items-center justify-start gap-2 cursor-pointer")}>
<div
style={
sentences.find((x) => x.solution === id)
? {
border: `2px solid ${sentences.find((x) => x.solution === id)!.color}`,
}
: {}
}
className={clsx("border-2 border-green-500 bg-transparent w-4 h-4 rounded-full", id)}
/>
<span>
<span className="font-semibold">{id}.</span> {sentence}{" "}
</span>
<button
id={id}
className={clsx(
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
"transition duration-300 ease-in-out",
)}>
{id}
</button>
<span>{sentence}</span>
</div>
))}
</div>
{sentences.map((sentence, index) => (
<div key={`solution_${index}`} className="absolute">
<LineTo className="rounded-full" from={sentence.id} to={sentence.solution} borderColor={sentence.color} borderWidth={5} />
</div>
))}
{userSolutions &&
sentences.map((sentence, index) => (
<Xarrow
key={index}
start={sentence.id}
end={sentence.solution}
lineColor={
!userSolutions.find((x) => x.question === sentence.id)
? "#CC5454"
: userSolutions.find((x) => x.question === sentence.id)?.option === sentence.solution
? "#7872BF"
: "#CC5454"
}
showHead={false}
/>
))}
</div>
<div 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 flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
<div className="absolute left-4">
<Icon path={mdiArrowLeft} color="white" size={1} />
</div>
<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 className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={onNext}>
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</Button>
</div>
</>
);

View File

@@ -1,11 +1,9 @@
/* eslint-disable @next/next/no-img-element */
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight, mdiCheck, mdiClose} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {useState} from "react";
import {useEffect, useState} from "react";
import {CommonProps} from ".";
import Button from "../Low/Button";
function Question({
variant,
@@ -13,41 +11,17 @@ function Question({
solution,
options,
userSolution,
onSelectOption,
showSolution = false,
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
const optionColor = (option: string) => {
if (!showSolution) {
return userSolution === option ? "border-blue-400" : "";
if (option === solution && !userSolution) {
return "!border-mti-red-light !text-mti-red-light";
}
if (option === solution) {
return "border-green-500 text-green-500";
return "!border-mti-purple-light !text-mti-purple-light";
}
return userSolution === option ? "border-red-500 text-red-500" : "";
};
const optionBadge = (option: string) => {
if (option === userSolution) {
if (solution === option) {
return (
<div className="badge badge-lg bg-green-500 border-green-500 absolute -top-2 -right-4">
<div className="tooltip" data-tip="You have correctly answered!">
<Icon path={mdiCheck} color="white" size={0.8} />
</div>
</div>
);
}
return (
<div className="badge badge-lg bg-red-500 border-red-500 absolute -top-2 -right-4">
<div className="tooltip" data-tip="You have wrongly answered!">
<Icon path={mdiClose} color="white" size={0.8} />
</div>
</div>
);
}
return userSolution === option ? "!border-mti-rose-light !text-mti-rose-light" : "";
};
return (
@@ -58,24 +32,20 @@ function Question({
options.map((option) => (
<div
key={option.id}
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
className={clsx(
"flex flex-col items-center border-2 p-4 rounded-xl gap-4 cursor-pointer bg-white relative",
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative",
optionColor(option.id),
)}>
{showSolution && optionBadge(option.id)}
<span className={clsx("text-sm", solution !== option.id && userSolution !== option.id && "opacity-50")}>{option.id}</span>
<img src={option.src!} alt={`Option ${option.id}`} />
<span>{option.id}</span>
</div>
))}
{variant === "text" &&
options.map((option) => (
<div
key={option.id}
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
className={clsx("flex border-2 p-4 rounded-xl gap-2 cursor-pointer bg-white", optionColor(option.id))}>
{showSolution && optionBadge(option.id)}
<span className="font-bold">{option.id}.</span>
className={clsx("flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm", optionColor(option.id))}>
<span className="font-semibold">{option.id}.</span>
<span>{option.text}</span>
</div>
))}
@@ -84,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);
}
@@ -97,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);
}
@@ -105,30 +98,40 @@ export default function MultipleChoice({prompt, questions, userSolutions, onNext
return (
<>
<div className="flex flex-col items-center gap-4">
<span className="text-base md:text-lg font-medium text-center px-2 md:px-4 lg:px-48">{prompt}</span>
{questionIndex < questions.length && (
<Question
{...questions[questionIndex]}
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option}
showSolution
/>
)}
<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>
{userSolutions && questionIndex < questions.length && (
<Question
{...questions[questionIndex]}
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option}
/>
)}
</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 flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={back}>
<div className="absolute left-4">
<Icon path={mdiArrowLeft} color="white" size={1} />
</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={back} className="max-w-[200px] w-full">
Back
</button>
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={next}>
</Button>
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</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

@@ -3,10 +3,11 @@ import {WriteBlanksExercise} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {useEffect, useState} from "react";
import {Fragment, useEffect, useState} from "react";
import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
import {toast} from "react-toastify";
import Button from "../Low/Button";
function Blank({
id,
@@ -17,7 +18,7 @@ function Blank({
setUserSolution,
}: {
id: string;
solutions?: string[];
solutions: string[];
userSolution?: string;
maxWords: number;
disabled?: boolean;
@@ -28,33 +29,44 @@ 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());
}
}, [maxWords, userInput, setUserSolution]);
const isUserSolutionCorrect = () => userSolution && solutions.map((x) => x.trim().toLowerCase()).includes(userSolution.trim().toLowerCase());
const getSolutionStyling = () => {
if (solutions && userSolution) {
if (solutions.map((x) => x.trim().toLowerCase()).includes(userSolution.trim().toLowerCase())) return "text-green-500 border-green-500";
if (!userSolution) {
return "bg-mti-red-ultralight text-mti-red-light";
}
return "text-red-500 border-red-500";
return "bg-mti-purple-ultralight text-mti-purple-light";
};
return (
<input
className={clsx("input border rounded-xl px-2 py-1 bg-white my-2", !solutions && "text-blue-400 border-blue-400", getSolutionStyling())}
placeholder={id}
onChange={(e) => setUserInput(e.target.value)}
value={!solutions ? userInput : solutions.join(" / ")}
contentEditable={disabled}
/>
<span className="inline-flex gap-2 ml-2">
{userSolution && !isUserSolutionCorrect() && (
<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}
contentEditable={disabled}>
{userSolution}
</div>
)}
<div
className={clsx("py-2 px-3 rounded-2xl w-fit focus:outline-none my-2", getSolutionStyling())}
placeholder={id}
contentEditable={disabled}>
{!solutions ? userInput : solutions.join(" / ")}
</div>
</span>
);
}
export default function WriteBlanksSolutions({
id,
type,
prompt,
maxWords,
solutions,
@@ -63,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>
<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>
);
@@ -79,31 +107,55 @@ export default function WriteBlanksSolutions({
return (
<>
<div className="flex flex-col">
<span className="text-lg font-medium text-center px-48">{prompt}</span>
<span>
{text.split("\\n").map((line) => (
<>
{renderLines(line)}
<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>
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
{userSolutions &&
text.split("\\n").map((line, index) => (
<p key={index}>
{renderLines(line)}
<br />
</p>
))}
</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-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 flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
<div className="absolute left-4">
<Icon path={mdiArrowLeft} color="white" size={1} />
</div>
<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 className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={onNext}>
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</Button>
</div>
</>
);

View File

@@ -0,0 +1,130 @@
/* 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 {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, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<>
{attachment && (
<Transition show={isModalOpen} as={Fragment}>
<Dialog onClose={() => setIsModalOpen(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-fit h-fit rounded-xl bg-white">
<img src={attachment.url} alt={attachment.description} className="max-w-4xl w-full self-center rounded-xl p-4" />
</Dialog.Panel>
</div>
</Transition.Child>
</Dialog>
</Transition>
)}
<div className="flex flex-col h-full w-full gap-8 mb-20">
<div className="flex w-full gap-7 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
<span className="font-semibold">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
<p>{line}</p>
<br />
</Fragment>
))}
</span>
{attachment && (
<img
onClick={() => setIsModalOpen(true)}
src={attachment.url}
alt={attachment.description}
className="max-w-[200px] self-center rounded-xl cursor-pointer"
/>
)}
</div>
<div className="w-full h-full flex flex-col gap-8">
{userSolutions && (
<div className="flex flex-col gap-4 w-full">
<span>Your answer:</span>
<textarea
className="w-full h-full min-h-[320px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
contentEditable={false}
readOnly
value={userSolutions[0]!.solution}
/>
</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-writing text-ielts-writing-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: writingReverseMarking[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: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</>
);
}

View File

@@ -1,25 +1,49 @@
import {Exercise, FillBlanksExercise, MatchSentencesExercise, MultipleChoiceExercise, WriteBlanksExercise} 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}}}} />;
}

View File

@@ -0,0 +1,72 @@
import React, {useEffect, useRef, useState} from "react";
import {BsPauseFill, BsPlayFill} from "react-icons/bs";
import WaveSurfer from "wavesurfer.js";
interface Props {
audio: string;
waveColor: string;
progressColor: string;
}
const Waveform = ({audio, waveColor, progressColor}: Props) => {
const containerRef = useRef(null);
const waveSurferRef = useRef<WaveSurfer | null>();
const [isPlaying, setIsPlaying] = useState(false);
useEffect(() => {
const waveSurfer = WaveSurfer.create({
container: containerRef?.current || "",
responsive: true,
cursorWidth: 0,
height: 24,
waveColor,
progressColor,
barGap: 5,
barWidth: 8,
barRadius: 4,
fillParent: true,
hideScrollbar: true,
normalize: true,
autoCenter: true,
ignoreSilenceMode: true,
barMinHeight: 4,
});
waveSurfer.load(audio);
waveSurfer.on("ready", () => {
waveSurferRef.current = waveSurfer;
});
waveSurfer.on("finish", () => setIsPlaying(false));
return () => {
waveSurfer.destroy();
};
}, [audio, progressColor, waveColor]);
return (
<>
{isPlaying && (
<BsPauseFill
className="text-mti-gray-cool cursor-pointer w-5 h-5"
onClick={() => {
setIsPlaying((prev) => !prev);
waveSurferRef.current?.playPause();
}}
/>
)}
{!isPlaying && (
<BsPlayFill
className="text-mti-gray-cool cursor-pointer w-5 h-5"
onClick={() => {
setIsPlaying((prev) => !prev);
waveSurferRef.current?.playPause();
}}
/>
)}
<div className="w-full max-w-4xl h-fit" ref={containerRef} />
</>
);
};
export default Waveform;

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,8 +0,0 @@
import {Module} from "@/interfaces";
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],
};

147
src/constants/ielts.tsx Normal file
View File

@@ -0,0 +1,147 @@
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 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 (
<>
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.
</>
);
};

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,92 +1,226 @@
import ProfileLevel from "@/components/ProfileLevel";
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import Button from "@/components/Low/Button";
import ModuleTitle from "@/components/Medium/ModuleTitle";
import {moduleResultText} from "@/constants/ielts";
import {Module} from "@/interfaces";
import {User} from "@/interfaces/user";
import {ICONS} from "@/resources/modules";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import useExamStore from "@/stores/examStore";
import {calculateBandScore} from "@/utils/score";
import clsx from "clsx";
import Link from "next/link";
import router from "next/router";
import {useRouter} from "next/router";
import {Fragment, useEffect, useState} from "react";
import {BsArrowCounterclockwise, BsBook, BsClipboard, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs";
interface Score {
module: Module;
correct: number;
total: number;
missing: number;
}
interface Props {
user: User;
modules: Module[];
scores: {
module?: Module;
correct: number;
total: number;
}[];
scores: Score[];
isLoading: boolean;
onViewResults: () => void;
}
export default function Finish({user, scores, modules, onViewResults}: Props) {
const renderModuleScore = (module: Module) => {
const moduleScores = scores.filter((x) => x.module === module);
if (moduleScores.length === 0) {
return <>0</>;
}
export default function Finish({user, scores, modules, isLoading, onViewResults}: Props) {
const [selectedModule, setSelectedModule] = useState(modules[0]);
const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!);
return moduleScores.map((x, index) => (
<span key={index}>
{x.correct} / {x.total}
</span>
));
const exams = useExamStore((state) => state.exams);
useEffect(() => setSelectedScore(scores.find((x) => x.module === selectedModule)!), [scores, selectedModule]);
const moduleColors: {[key in Module]: {progress: string; inner: string}} = {
reading: {
progress: "text-ielts-reading",
inner: "bg-ielts-reading-light",
},
listening: {
progress: "text-ielts-listening",
inner: "bg-ielts-listening-light",
},
writing: {
progress: "text-ielts-writing",
inner: "bg-ielts-writing-light",
},
speaking: {
progress: "text-ielts-speaking",
inner: "bg-ielts-speaking-light",
},
level: {
progress: "text-ielts-level",
inner: "bg-ielts-level-light",
},
};
const renderModuleTotal = (module: Module) => {
const moduleScores = scores.filter((x) => x.module === module);
if (moduleScores.length === 0) {
return <>0</>;
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;
}
const total = moduleScores.reduce((accumulator, current) => accumulator + current.total, 0);
const correct = moduleScores.reduce((accumulator, current) => accumulator + current.correct, 0);
return (
<span>
{correct} / {total} | {Math.floor((correct / total) * 100)}%
</span>
);
return exam.exercises.length;
};
return (
<div className="w-full h-full relative">
<section className="h-full w-full flex flex-col items-center justify-center gap-4">
<ProfileLevel user={user} className="h-1/2" />
<div className="h-2/3 w-2/3 flex flex-col items-center gap-4">
<div className="rounded-xl p-2 md:p-4 items-center flex lg:justify-center gap-2 md:gap-4 max-w-[100%] overflow-auto">
{modules.map((module) => (
<div
className={`flex flex-col gap-12 min-w-[176px] items-center justify-center border-2 border-ielts-${module} bg-ielts-${module}-transparent py-4 px-2 rounded-xl text-white font-semibold`}
key={module}>
<div className="flex flex-col items-center">
<Icon path={ICONS[module]} color="white" size={2} />
<span>{module.toUpperCase()}</span>
</div>
<div className="flex flex-col">{renderModuleScore(module)}</div>
<div>TOTAL: {renderModuleTotal(module)}</div>
</div>
))}
</div>
<div className="w-full flex flex-col gap-4 lg:gap-0 md:flex-row justify-center items-center lg:justify-between">
<Link href="/">
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)}>
<div className="absolute left-4">
<Icon path={mdiArrowLeft} color="white" size={1} />
</div>
Go Home
</button>
</Link>
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={onViewResults}>
View Solutions
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</div>
<>
<div className="w-full min-h-full h-fit flex flex-col items-center justify-between gap-8">
<ModuleTitle
module={selectedModule}
totalExercises={getTotalExercises()}
exerciseIndex={getTotalExercises()}
minTimer={exams.find((x) => x.module === selectedModule)!.minTimer}
disableTimer
/>
<div className="flex gap-4 self-start">
{modules.includes("reading") && (
<div
onClick={() => setSelectedModule("reading")}
className={clsx(
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-reading hover:text-white",
selectedModule === "reading" ? "bg-ielts-reading text-white" : "bg-mti-gray-smoke text-ielts-reading",
)}>
<BsBook className="w-6 h-6" />
<span className="font-semibold">Reading</span>
</div>
)}
{modules.includes("listening") && (
<div
onClick={() => setSelectedModule("listening")}
className={clsx(
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-listening hover:text-white",
selectedModule === "listening" ? "bg-ielts-listening text-white" : "bg-mti-gray-smoke text-ielts-listening",
)}>
<BsHeadphones className="w-6 h-6" />
<span className="font-semibold">Listening</span>
</div>
)}
{modules.includes("writing") && (
<div
onClick={() => setSelectedModule("writing")}
className={clsx(
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-writing hover:text-white",
selectedModule === "writing" ? "bg-ielts-writing text-white" : "bg-mti-gray-smoke text-ielts-writing",
)}>
<BsPen className="w-6 h-6" />
<span className="font-semibold">Writing</span>
</div>
)}
{modules.includes("speaking") && (
<div
onClick={() => setSelectedModule("speaking")}
className={clsx(
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-speaking hover:text-white",
selectedModule === "speaking" ? "bg-ielts-speaking text-white" : "bg-mti-gray-smoke text-ielts-speaking",
)}>
<BsMegaphone className="w-6 h-6" />
<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>
</section>
</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">
<span className={clsx("loading loading-infinity w-32", moduleColors[selectedModule].progress)} />
<span className={clsx("font-bold text-2xl", moduleColors[selectedModule].progress)}>Evaluating your answers...</span>
</div>
)}
{!isLoading && (
<div className="w-full flex gap-9 mt-32 items-center justify-between mb-20">
<span className="max-w-3xl">
{moduleResultText(calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus))}
</span>
<div className="flex gap-9 px-16">
<div
className={clsx("radial-progress overflow-hidden", moduleColors[selectedModule].progress)}
style={
{"--value": (selectedScore.correct / selectedScore.total) * 100, "--thickness": "12px", "--size": "13rem"} as any
}>
<div
className={clsx(
"w-48 h-48 rounded-full flex flex-col items-center justify-center",
moduleColors[selectedModule].inner,
)}>
<span className="text-xl">Level</span>
<span className="text-3xl font-bold">
{calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus)}
</span>
</div>
</div>
<div className="flex flex-col gap-5">
<div className="flex gap-2">
<div className="w-3 h-3 bg-mti-red-light rounded-full mt-1" />
<div className="flex flex-col">
<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-purple-light rounded-full mt-1" />
<div className="flex flex-col">
<span className="text-mti-purple-light">{selectedScore.correct.toString().padStart(2, "0")}</span>
<span className="text-lg">Correct</span>
</div>
</div>
<div className="flex gap-2">
<div className="w-3 h-3 bg-mti-rose-light rounded-full mt-1" />
<div className="flex flex-col">
<span className="text-mti-rose-light">
{(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")}
</span>
<span className="text-lg">Wrong</span>
</div>
</div>
</div>
</div>
</div>
)}
</div>
{!isLoading && (
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<div className="flex gap-8">
<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-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>
</div>
<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-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>
</div>
</div>
<Link href="/" className="max-w-[200px] w-full self-end">
<Button color="purple" className="max-w-[200px] self-end w-full">
Dashboard
</Button>
</Link>
</div>
)}
</>
);
}

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,11 +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;
@@ -14,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})),
@@ -37,61 +88,98 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
}
};
const previousExercise = () => {
const previousExercise = (solution?: UserSolution) => {
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
}
setExerciseIndex((prev) => prev - 1);
};
const getExercise = () => {
const exercise = exam.parts[partIndex].exercises[exerciseIndex];
return {
...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
};
};
const renderAudioPlayer = () => (
<>
{exerciseIndex === -1 && (
<div className="flex flex-col">
<span className="text-lg font-semibold">Please listen to the following audio attentively.</span>
{exam.audio.repeatableTimes > 0 ? (
<span className="self-center text-sm">
You will only be allowed to listen to the audio {exam.audio.repeatableTimes} time(s).
</span>
) : (
<span className="self-center text-sm">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 overflow-auto">
{exam.audio.repeatableTimes > 0 && (
<>{exam.audio.repeatableTimes <= timesListened && <span>You are no longer allowed to listen to the audio again.</span>}</>
)}
{exam.audio.repeatableTimes > 0 && timesListened < exam.audio.repeatableTimes && (
<audio preload="auto" controls autoPlay onPlay={() => setTimesListened((prev) => prev + 1)}>
<source src={exam.audio.source} type="audio/mpeg" />
Your browser does not support the audio element
</audio>
)}
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
<div className="flex flex-col w-full gap-2">
<h4 className="text-xl font-semibold">Please listen to the following audio attentively.</h4>
<span className="text-base">
{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
key={partIndex}
src={exam.parts[partIndex].audio.source}
color="listening"
onEnd={() => setTimesListened((prev) => prev + 1)}
disabled={timesListened === exam.parts[partIndex].audio.repeatableTimes}
disablePause
/>
</div>
</div>
);
return (
<>
<div className="w-full relative flex flex-col gap-8 items-center justify-center p-8 px-16 overflow-hidden">
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
<div className="flex flex-col h-full w-full gap-8 justify-between">
<ModuleTitle
exerciseIndex={
(exam.parts
.flatMap((x) => x.exercises)
.findIndex(
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
) || 0) +
(exerciseIndex === -1 ? 0 : 1) +
questionIndex +
currentQuestionIndex
}
minTimer={exam.minTimer}
module="listening"
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
disableTimer={showSolutions}
/>
{renderAudioPlayer()}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions &&
renderExercise(exam.exercises[exerciseIndex], 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)}
{exerciseIndex === -1 && (
<button
className={clsx("btn md:btn-wide w-full gap-4 relative text-white self-end", infoButtonStyle)}
onClick={() => nextExercise()}>
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
)}
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="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end">
Start now
</Button>
)}
</>
);
}

View File

@@ -4,11 +4,21 @@ import Icon from "@mdi/react";
import {mdiArrowRight, mdiNotebook} from "@mdi/js";
import clsx from "clsx";
import {infoButtonStyle} from "@/constants/buttonStyles";
import {convertCamelCaseToReadable} from "@/utils/string";
import {Dialog, Transition} from "@headlessui/react";
import {renderExercise} from "@/components/Exercises";
import {renderSolution} from "@/components/Solutions";
import {Panel} from "primereact/panel";
import {Steps} from "primereact/steps";
import {BsAlarm, BsBook, BsClock, BsStopwatch} from "react-icons/bs";
import 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;
@@ -41,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}
@@ -56,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>
@@ -74,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})),
@@ -97,68 +166,108 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
}
};
const previousExercise = () => {
const previousExercise = (solution?: UserSolution) => {
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
}
setExerciseIndex((prev) => prev - 1);
};
const getExercise = () => {
const exercise = exam.parts[partIndex].exercises[exerciseIndex];
return {
...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
};
};
const renderText = () => (
<div className="flex flex-col gap-4 w-full">
<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">
<span className="text-base md:text-lg font-semibold">
<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.
</span>
<span className="self-end text-sm">You will be allowed to read the text while doing the exercises</span>
</h4>
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
</div>
<Panel header={exam.text.title}>
<p className="overflow-auto">
{exam.text.content.split("\\n").map((line, index) => (
<div className="flex flex-col gap-2 w-full">
<h3 className="text-xl font-semibold">{exam.parts[partIndex].text.title}</h3>
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
<span className="overflow-auto">
{exam.parts[partIndex].text.content.split("\\n").map((line, index) => (
<p key={index}>{line}</p>
))}
</p>
</Panel>
</span>
</div>
</div>
);
return (
<>
<TextModal {...exam.text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />
<div className="w-full h-full relative flex flex-col gap-8 items-center justify-center p-2 md:p-8 px-4 md:px-16 overflow-hidden">
{exerciseIndex === -1 && renderText()}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
!showSolutions &&
renderExercise(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
showSolutions &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
<div
className={clsx("flex gap-8", exerciseIndex > -1 ? "w-full justify-center md:justify-between" : "self-end w-full md:w-fit flex")}>
{exerciseIndex > -1 && (
<button
className={clsx(
"btn btn-wide gap-4 relative text-white",
"border-2 border-ielts-reading hover:bg-ielts-reading hover:border-ielts-reading bg-ielts-reading-transparent",
)}
onClick={() => setShowTextModal(true)}>
Read Text
<div className="absolute right-4">
<Icon path={mdiNotebook} color="white" size={1} />
</div>
</button>
)}
{exerciseIndex === -1 && (
<button
className={clsx("btn w-full md:btn-wide gap-4 relative text-white self-end", infoButtonStyle)}
onClick={() => nextExercise()}>
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
)}
<div className="flex flex-col h-full w-full gap-8">
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
<TextModal {...exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />
<ModuleTitle
minTimer={exam.minTimer}
exerciseIndex={
(exam.parts
.flatMap((x) => x.exercises)
.findIndex(
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
) || 0) +
(exerciseIndex === -1 ? 0 : 1) +
questionIndex +
currentQuestionIndex
}
module="reading"
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
disableTimer={showSolutions}
label={exerciseIndex === -1 ? undefined : convertCamelCaseToReadable(exam.parts[partIndex].exercises[exerciseIndex].type)}
/>
<div className={clsx("mb-20 w-full", exerciseIndex > -1 && "grid grid-cols-2 gap-4")}>
{renderText()}
{exerciseIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
{exerciseIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
showSolutions &&
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
</div>
{exerciseIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
<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

@@ -1,24 +1,29 @@
/* eslint-disable @next/next/no-img-element */
import Icon from "@mdi/react";
import {mdiAccountVoice, mdiArrowLeft, mdiArrowRight, mdiBookOpen, mdiHeadphones, mdiPen} from "@mdi/js";
import {useEffect, useState} from "react";
import {useState} from "react";
import {Module} from "@/interfaces";
import clsx from "clsx";
import {useRouter} from "next/router";
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import ProfileLevel from "@/components/ProfileLevel";
import {User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore";
import ProgressBar from "@/components/Low/ProgressBar";
import {BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
import {totalExamsByModule} from "@/utils/stats";
import 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 router = useRouter();
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
const {stats} = useStats(user?.id);
const toggleModule = (module: Module) => {
const modules = selectedModules.filter((x) => x !== module);
@@ -27,82 +32,209 @@ export default function Selection({user, onStart}: Props) {
return (
<>
<div className="w-full h-full relative">
<section className="h-full w-full flex flex-col items-center justify-center gap-8">
<ProfileLevel user={user} className="h-1/2" />
<div className="h-1/2 flex flex-col gap-8">
<div className="h-1/2 items-center flex flex-col lg:flex-row gap-8">
<div
role="button"
tabIndex={0}
onClick={() => toggleModule("reading")}
className={clsx(
"flex flex-col gap-2 items-center justify-center",
"border-ielts-reading hover:bg-ielts-reading text-white",
"border-2 rounded-xl p-4 h-fit w-48 cursor-pointer",
selectedModules.includes("reading") ? "bg-ielts-reading " : "bg-ielts-reading-transparent ",
)}>
<Icon path={mdiBookOpen} color="white" size={3} />
<span>Reading</span>
</div>
<div
role="button"
tabIndex={0}
onClick={() => toggleModule("listening")}
className={clsx(
"flex flex-col gap-2 items-center justify-center",
"border-ielts-listening hover:bg-ielts-listening text-white",
"border-2 rounded-xl p-4 h-fit w-48 cursor-pointer",
selectedModules.includes("listening") ? "bg-ielts-listening " : "bg-ielts-listening-transparent ",
)}>
<Icon path={mdiHeadphones} color="white" size={3} />
<span>Listening</span>
</div>
<div
role="button"
tabIndex={0}
onClick={() => toggleModule("writing")}
className={clsx(
"flex flex-col gap-2 items-center justify-center",
"border-ielts-writing hover:bg-ielts-writing text-white",
"border-2 rounded-xl p-4 h-fit w-48 cursor-pointer",
selectedModules.includes("writing") ? "bg-ielts-writing " : "bg-ielts-writing-transparent ",
)}>
<Icon path={mdiPen} color="white" size={3} />
<span>Writing</span>
</div>
<div
role="button"
tabIndex={0}
onClick={() => toggleModule("speaking")}
className={clsx(
"flex flex-col gap-2 items-center justify-center",
"border-ielts-speaking hover:bg-ielts-speaking text-white",
"border-2 rounded-xl p-4 h-fit w-48 cursor-pointer",
selectedModules.includes("speaking") ? "bg-ielts-speaking " : "bg-ielts-speaking-transparent ",
)}>
<Icon path={mdiAccountVoice} color="white" size={3} />
<span>Speaking</span>
</div>
</div>
<div className="w-full flex flex-col gap-4 md:flex-row justify-between">
<button onClick={() => router.push("/")} className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)}>
<div className="absolute left-4">
<Icon path={mdiArrowLeft} color="white" size={1} />
</div>
Back
</button>
<button
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
onClick={() => onStart(selectedModules)}>
Start
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</div>
</div>
<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"),
},
]}
/>
)}
<section className="flex flex-col gap-3">
<span className="font-bold text-lg">About {capitalize(page)}</span>
<span className="text-mti-gray-taupe">
{page === "exercises" && (
<>
In the realm of language acquisition, practice makes perfect, and our exercises are the key to unlocking your full
potential. Dive into a world of interactive and engaging exercises that cater to diverse learning styles. From grammar
drills that build a strong foundation to vocabulary challenges that broaden your lexicon, our exercises are carefully
designed to make learning English both enjoyable and effective. Whether you&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 -lg:flex-col -lg:items-center -lg:gap-12 justify-between gap-8 mt-8">
<div
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
className={clsx(
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
selectedModules.includes("reading") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-0 -translate-y-1/2">
<BsBook className="text-white w-7 h-7" />
</div>
<span className="font-semibold">Reading:</span>
<p className="text-center text-xs">
Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
</p>
{!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={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
className={clsx(
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
selectedModules.includes("listening") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-0 -translate-y-1/2">
<BsHeadphones className="text-white w-7 h-7" />
</div>
<span className="font-semibold">Listening:</span>
<p className="text-center text-xs">
Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
</p>
{!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={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
className={clsx(
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
selectedModules.includes("writing") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-0 -translate-y-1/2">
<BsPen className="text-white w-7 h-7" />
</div>
<span className="font-semibold">Writing:</span>
<p className="text-center text-xs">
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
</p>
{!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={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
className={clsx(
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
selectedModules.includes("speaking") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-0 -translate-y-1/2">
<BsMegaphone className="text-white w-7 h-7" />
</div>
<span className="font-semibold">Speaking:</span>
<p className="text-center text-xs">
You&apos;ll have access to interactive dialogs, pronunciation exercises and speech recordings.
</p>
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && !disableSelection && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
)}
{(selectedModules.includes("speaking") || 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>
<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

@@ -1,7 +1,12 @@
import {renderExercise} from "@/components/Exercises";
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";
@@ -15,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})),
@@ -37,28 +54,44 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
}
};
const previousExercise = () => {
setExerciseIndex((prev) => prev - 1);
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="w-full h-full relative flex flex-col gap-8 items-center justify-center p-8 px-16 overflow-hidden">
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
!showSolutions &&
renderExercise(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
showSolutions &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
{exerciseIndex === -1 && (
<button className={clsx("btn btn-wide gap-4 relative text-white self-end", infoButtonStyle)} onClick={() => nextExercise()}>
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
)}
</div>
<>
<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 + questionIndex + currentQuestionIndex}
module="speaking"
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,7 +1,11 @@
import {renderExercise} from "@/components/Exercises";
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";
@@ -16,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) {
@@ -28,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})),
@@ -37,28 +47,43 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
}
};
const previousExercise = () => {
setExerciseIndex((prev) => prev - 1);
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="w-full h-full relative flex flex-col gap-8 items-center justify-center p-8 px-16 overflow-hidden">
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
!showSolutions &&
renderExercise(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
showSolutions &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
{exerciseIndex === -1 && (
<button className={clsx("btn btn-wide gap-4 relative text-white self-end", infoButtonStyle)} onClick={() => nextExercise()}>
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
)}
</div>
<>
<div className="flex flex-col h-full w-full gap-8 items-center">
<ModuleTitle
minTimer={exam.minTimer}
exerciseIndex={exerciseIndex + 1}
module="writing"
totalExercises={countExercises(exam.exercises)}
disableTimer={showSolutions}
/>
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
!showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
showSolutions &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
</div>
</>
);
}

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 {
@@ -35,6 +51,7 @@ export interface UserSolution {
score: {
correct: number;
total: number;
missing: number;
};
exercise: string;
}
@@ -62,22 +79,34 @@ export interface SpeakingExam {
export type Exercise =
| FillBlanksExercise
| TrueFalseExercise
| MatchSentencesExercise
| MultipleChoiceExercise
| WriteBlanksExercise
| WritingExercise
| SpeakingExercise;
| SpeakingExercise
| InteractiveSpeakingExercise;
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?: string; //* The url for an image to work as an attachment to show the user
attachment?: {
url: string;
description: string;
}; //* The url for an image to work as an attachment to show the user
userSolutions: {
id: string;
solution: string;
evaluation?: Evaluation;
}[];
}
@@ -87,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;
}[];
}
@@ -110,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,17 +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;
@@ -21,11 +105,22 @@ export interface Stat {
module: Module;
solutions: any[];
type: string;
timeSpent?: number;
assignment?: string;
score: {
correct: number;
total: number;
missing: number;
};
}
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"];

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