Compare commits

..

179 Commits

Author SHA1 Message Date
Tiago Ribeiro
a65b72adad Updated the payment integration to be dynamic 2024-05-16 13:30:38 +01:00
Tiago Ribeiro
e13aea9f7d Updated the table 2024-05-15 23:41:45 +01:00
Tiago Ribeiro
2920fa7f3a Updated the payment to work with Paymob 2024-05-15 22:59:51 +01:00
Tiago Ribeiro
7af96ecccc Created a webhook to allow the transaction to be completed 2024-05-15 00:25:44 +01:00
Tiago Ribeiro
70716b3483 Merge branch 'develop' into feature/ENCOA-42/update-payment-system-paymob 2024-05-13 11:02:35 +01:00
Tiago Ribeiro
d7bb64e7e0 Merge branch 'main' into develop 2024-05-13 11:02:16 +01:00
Tiago Ribeiro
dd19b5746c Updated the times listened to not be global 2024-05-13 11:01:37 +01:00
Tiago Ribeiro
f967282f71 Started implementing the Paymob integration 2024-05-13 10:38:05 +01:00
Tiago Ribeiro
8b2459c304 ENCOA-37: Added the ability for users to download a list of the shown users 2024-05-08 15:46:24 +01:00
Tiago Ribeiro
72fb934d4f Updated the propagated changes to also affect expiry date changes for corporates 2024-05-07 23:53:15 +01:00
Tiago Ribeiro
ed0b8bcb99 ENCOA-36: Allow Corporate Users to select invitation expiry date lower than theirs 2024-05-07 11:42:05 +01:00
Tiago Ribeiro
6f211d8435 Added the corporate user balance to the User Card 2024-05-07 09:48:49 +01:00
Tiago Ribeiro
b59589b855 ENCOA-26: Student profile count stats was invalid 2024-05-07 09:07:17 +01:00
Tiago Ribeiro
db20feaa00 Added the ID of the multiple choice question 2024-05-07 08:48:16 +01:00
Tiago Ribeiro
8fc2cf571e Disabled the Play Again for admins 2024-05-07 08:37:09 +01:00
Tiago Ribeiro
3128fea8c9 Merge branch 'develop' 2024-05-05 12:03:36 +01:00
Tiago Ribeiro
0e53b4a454 Added the ability to view archived assignments and unarchive them 2024-05-05 12:02:53 +01:00
Tiago Ribeiro
cbb61d18fe Made sure to only send the e-mail for previously invited users instead of also creating a new code 2024-04-30 14:59:55 +01:00
Tiago Ribeiro
dff51cf6ea Merged from develop 2024-04-28 20:20:25 +01:00
Tiago Ribeiro
15dbadcc53 Solved a small bug 2024-04-28 20:19:21 +01:00
Tiago Ribeiro
624a3fb88e Created a discount system related to the user's e-mail address and applied to the packages 2024-04-26 20:41:46 +01:00
Tiago Ribeiro
00feee2179 Disabled the short length exams 2024-04-24 08:53:53 +01:00
Tiago Ribeiro
0f8f9bc05b Added a button to review the exam from the selected module forward 2024-04-21 21:29:43 +01:00
Tiago Ribeiro
f76b7578a6 Disabled the editing of the country manager of a corporate from the payment record 2024-04-21 12:22:02 +01:00
Tiago Ribeiro
1a17689cd2 Updated the code to name the field companyArabName and made it so it returns it when arabic 2024-04-21 00:37:08 +01:00
Tiago Ribeiro
a958e2ff0d Added a field for the agent where they can put their arab name 2024-04-20 16:01:35 +01:00
Tiago Ribeiro
36b861266f ENCOA-18: Improve the loading of the company names on the Group and Users lists 2024-04-18 16:03:09 +01:00
Tiago Ribeiro
771262fc18 ENCOA-16: Added a creation date to the Code List 2024-04-18 14:18:29 +01:00
Tiago Ribeiro
0f03ce95e7 Remove a console.log 2024-04-18 11:27:40 +01:00
Tiago Ribeiro
6a6e010daa ENCOA-13: Add filter for "In Use" and "Unused" for the Code List
ENCOA-15: Checkbox to select/unselect all for the Code List
2024-04-18 09:40:47 +01:00
Tiago Ribeiro
13496387c4 ENCOA-6: Updated the Linked Corporate column in the Group List 2024-04-11 11:29:08 +01:00
Tiago Ribeiro
4ecb21e0ae ENCOA-4: Added the ability to filter by Creator on the Code List 2024-04-11 11:23:13 +01:00
Tiago Ribeiro
8663fe13bd Prevented users from deleted in use codes 2024-04-11 10:56:40 +01:00
Tiago Ribeiro
de4638bc46 - ENCOA-3: Added the ability to delete multiple codes at once;
- ENCOA-5 Added a column for the Creator on the code list;
2024-04-11 10:22:02 +01:00
Tiago Ribeiro
c9740fe8ee ENCOA-1: Added expired teachers on the Admin dashboard 2024-04-11 09:53:34 +01:00
Tiago Ribeiro
9b9b67c6cd Added a "Linked Corporate" column to the Groups list 2024-04-05 09:04:40 +01:00
Tiago Ribeiro
fe2abaacae Added a list for codes, for users to delete unused ones 2024-04-04 23:05:12 +01:00
João Ramos
11e2ea3249 Merged in bug-fixing-2-Abril (pull request #51)
Minor change regarding user id on the pdf footer

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -1,4 +1,5 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const websiteUrl = process.env.NODE_ENV === 'production' ? "https://encoach.com" : "http://localhost:3000";
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
output: "standalone", output: "standalone",
@@ -8,7 +9,7 @@ const nextConfig = {
source: "/api/packages", source: "/api/packages",
headers: [ headers: [
{key: "Access-Control-Allow-Credentials", value: "false"}, {key: "Access-Control-Allow-Credentials", value: "false"},
{key: "Access-Control-Allow-Origin", value: "https://encoach.com"}, {key: "Access-Control-Allow-Origin", value: websiteUrl},
{ {
key: "Access-Control-Allow-Methods", key: "Access-Control-Allow-Methods",
value: "GET", value: "GET",
@@ -19,6 +20,36 @@ const nextConfig = {
}, },
], ],
}, },
{
source: "/api/tickets",
headers: [
{key: "Access-Control-Allow-Credentials", value: "false"},
{key: "Access-Control-Allow-Origin", value: websiteUrl},
{
key: "Access-Control-Allow-Methods",
value: "POST,OPTIONS",
},
{
key: "Access-Control-Allow-Headers",
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
},
],
},
{
source: "/api/users/agents",
headers: [
{key: "Access-Control-Allow-Credentials", value: "false"},
{key: "Access-Control-Allow-Origin", value: websiteUrl},
{
key: "Access-Control-Allow-Methods",
value: "POST,OPTIONS",
},
{
key: "Access-Control-Allow-Headers",
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
},
],
},
]; ];
}, },
}; };

View File

@@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@beam-australia/react-env": "^3.1.1", "@beam-australia/react-env": "^3.1.1",
"@dnd-kit/core": "^6.1.0",
"@headlessui/react": "^1.7.13", "@headlessui/react": "^1.7.13",
"@mdi/js": "^7.1.96", "@mdi/js": "^7.1.96",
"@mdi/react": "^1.6.1", "@mdi/react": "^1.6.1",
@@ -46,6 +47,7 @@
"next": "13.1.6", "next": "13.1.6",
"nodemailer": "^6.9.5", "nodemailer": "^6.9.5",
"nodemailer-express-handlebars": "^6.1.0", "nodemailer-express-handlebars": "^6.1.0",
"paymob-react": "git+https://github.com/tiago-ecrop/paymob-react-oman.git",
"primeicons": "^6.0.1", "primeicons": "^6.0.1",
"primereact": "^9.2.3", "primereact": "^9.2.3",
"qrcode": "^1.5.3", "qrcode": "^1.5.3",

Binary file not shown.

BIN
public/manuals/student.pdf Normal file

Binary file not shown.

BIN
public/manuals/teacher.pdf Normal file

Binary file not shown.

View File

@@ -128,7 +128,7 @@ export default function FillBlanks({
return ( return (
<> <>
<div className="flex flex-col gap-4 mt-4 h-full mb-20"> <div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
{(!!currentBlankId || isDrawerShowing) && ( {(!!currentBlankId || isDrawerShowing) && (
<WordsDrawer <WordsDrawer
key={currentBlankId} key={currentBlankId}

View File

@@ -76,7 +76,7 @@ export default function InteractiveSpeaking({
onBack({ onBack({
exercise: id, exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer], solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 1, total: 1, missing: 0}, score: {correct: 100, total: 100, missing: 0},
type, type,
}); });
}; };
@@ -96,14 +96,13 @@ export default function InteractiveSpeaking({
onNext({ onNext({
exercise: id, exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer], solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 1, total: 1, missing: 0}, score: {correct: 100, total: 100, missing: 0},
type, type,
}); });
}; };
useEffect(() => { useEffect(() => {
if (userSolutions.length > 0 && answers.length === 0) { if (userSolutions.length > 0 && answers.length === 0) {
console.log(userSolutions);
const solutions = userSolutions as unknown as typeof answers; const solutions = userSolutions as unknown as typeof answers;
setAnswers(solutions); setAnswers(solutions);
@@ -112,10 +111,6 @@ export default function InteractiveSpeaking({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [userSolutions, mediaBlob, answers]); }, [userSolutions, mediaBlob, answers]);
useEffect(() => {
console.log({answers});
}, [answers]);
useEffect(() => { useEffect(() => {
if (updateIndex) updateIndex(questionIndex); if (updateIndex) updateIndex(questionIndex);
}, [questionIndex, updateIndex]); }, [questionIndex, updateIndex]);
@@ -131,7 +126,7 @@ export default function InteractiveSpeaking({
onNext({ onNext({
exercise: id, exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer], solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 1, total: 1, missing: 0}, score: {correct: 100, total: 100, missing: 0},
type, type,
}); });
} }
@@ -176,7 +171,7 @@ export default function InteractiveSpeaking({
{ {
exercise: id, exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer], solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 1, total: 1, missing: 0}, score: {correct: 100, total: 100, missing: 0},
module: "speaking", module: "speaking",
exam: examID, exam: examID,
type, type,

View File

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

View File

@@ -7,6 +7,7 @@ import {CommonProps} from ".";
import Button from "../Low/Button"; import Button from "../Low/Button";
function Question({ function Question({
id,
variant, variant,
prompt, prompt,
options, options,
@@ -15,7 +16,9 @@ function Question({
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) { }: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
return ( return (
<div className="flex flex-col gap-10"> <div className="flex flex-col gap-10">
<span className="">{prompt}</span> <span className="">
{id} - {prompt}
</span>
<div className="flex flex-wrap gap-4 justify-between"> <div className="flex flex-wrap gap-4 justify-between">
{variant === "image" && {variant === "image" &&
options.map((option) => ( options.map((option) => (
@@ -117,7 +120,7 @@ export default function MultipleChoice({
return ( return (
<> <>
<div className="flex flex-col gap-2 mt-4 h-fit mb-20 bg-mti-gray-smoke rounded-xl px-16 py-8"> <div className="flex flex-col gap-2 mt-4 h-fit w-full mb-20 bg-mti-gray-smoke rounded-xl px-16 py-8">
<span className="text-xl font-semibold">{prompt}</span> <span className="text-xl font-semibold">{prompt}</span>
{questionIndex < questions.length && ( {questionIndex < questions.length && (
<Question <Question

View File

@@ -81,7 +81,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
onNext({ onNext({
exercise: id, exercise: id,
solutions: storagePath ? [{id, solution: storagePath}] : [], solutions: storagePath ? [{id, solution: storagePath}] : [],
score: {correct: 1, total: 1, missing: 0}, score: {correct: 0, total: 100, missing: 0},
type, type,
}); });
}; };
@@ -94,7 +94,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
onBack({ onBack({
exercise: id, exercise: id,
solutions: storagePath ? [{id, solution: storagePath}] : [], solutions: storagePath ? [{id, solution: storagePath}] : [],
score: {correct: 1, total: 1, missing: 0}, score: {correct: 0, total: 100, missing: 0},
type, type,
}); });
}; };

View File

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

View File

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

View File

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

View File

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

View File

@@ -137,7 +137,7 @@ export default function TicketDisplay({ user, ticket, onClose }: Props) {
options={[ options={[
{ value: "me", label: "Assign to me" }, { value: "me", label: "Assign to me" },
...users ...users
.filter((x) => ["admin", "developer"].includes(x.type)) .filter((x) => ["admin", "developer", "agent"].includes(x.type))
.map((u) => ({ .map((u) => ({
value: u.id, value: u.id,
label: `${u.name} - ${u.email}`, label: `${u.name} - ${u.email}`,

View File

@@ -1,5 +1,6 @@
import {Ticket, TicketType, TicketTypeLabel} from "@/interfaces/ticket"; import {Ticket, TicketType, TicketTypeLabel} from "@/interfaces/ticket";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore";
import axios from "axios"; import axios from "axios";
import {useState} from "react"; import {useState} from "react";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
@@ -20,6 +21,8 @@ export default function TicketSubmission({user, page, onClose}: Props) {
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const examState = useExamStore((state) => state);
const submit = () => { const submit = () => {
if (!type) return toast.error("Please choose a type!", {toastId: "missing-type"}); if (!type) return toast.error("Please choose a type!", {toastId: "missing-type"});
if (subject.trim() === "") if (subject.trim() === "")
@@ -48,6 +51,18 @@ export default function TicketSubmission({user, page, onClose}: Props) {
type, type,
reportedFrom: page, reportedFrom: page,
description, description,
examInformation:
page.includes("exam") || page.includes("exercises")
? {
exam: examState.exam?.id || "",
exams: examState.exams.map((x) => x.id),
exerciseIndex: examState.exerciseIndex,
moduleIndex: examState.moduleIndex,
partIndex: examState.partIndex,
questionIndex: examState.questionIndex,
selectedModules: examState.selectedModules,
}
: undefined,
}; };
axios axios

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,70 @@
import topics from "@/resources/topics";
import {useState} from "react";
import {BsArrowLeft, BsArrowRight} from "react-icons/bs";
import Button from "../Low/Button";
import Modal from "../Modal";
interface Props {
isOpen: boolean;
initialTopics: string[];
onClose: VoidFunction;
selectTopics: (topics: string[]) => void;
}
export default function TopicModal({isOpen, initialTopics, onClose, selectTopics}: Props) {
const [selectedTopics, setSelectedTopics] = useState([...initialTopics]);
return (
<Modal isOpen={isOpen} onClose={onClose} title="Preferred Topics">
<div className="flex flex-col w-full h-full gap-4 mt-4">
<div className="w-full h-full grid grid-cols-2 -md:gap-1 gap-4">
<div className="flex flex-col gap-2">
<span className="border-b border-b-neutral-400/30">Available Topics</span>
<div className=" max-h-[500px] overflow-y-scroll scrollbar-hide">
{topics
.filter((x) => !selectedTopics.includes(x))
.map((x) => (
<div key={x} className="odd:bg-mti-purple-ultralight/40 p-2 flex justify-between items-center">
<span>{x}</span>
<button
onClick={() => setSelectedTopics((prev) => [...prev, x])}
className="border border-mti-purple-light cursor-pointer p-2 rounded-lg bg-white drop-shadow transition ease-in-out duration-300 hover:bg-mti-purple hover:text-white">
<BsArrowRight />
</button>
</div>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<span className="border-b border-b-neutral-400/30">Preferred Topics ({selectedTopics.length || "All"})</span>
<div className=" max-h-[500px] overflow-y-scroll scrollbar-hide">
{selectedTopics.map((x) => (
<div key={x} className="odd:bg-mti-purple-ultralight/40 p-2 flex justify-between items-center text-right">
<button
onClick={() => setSelectedTopics((prev) => [...prev.filter((y) => y !== x)])}
className="border border-mti-purple-light cursor-pointer p-2 rounded-lg bg-white drop-shadow transition ease-in-out duration-300 hover:bg-mti-purple hover:text-white">
<BsArrowLeft />
</button>
<span>{x}</span>
</div>
))}
</div>
</div>
</div>
<div className="w-full flex gap-4 items-center justify-end">
<Button variant="outline" color="rose" className="w-full max-w-[200px]" onClick={onClose}>
Close
</Button>
<Button
className="w-full max-w-[200px]"
onClick={() => {
selectTopics(selectedTopics);
onClose();
}}>
Select
</Button>
</div>
</div>
</Modal>
);
}

View File

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

View File

@@ -31,6 +31,8 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
const [disablePaymentPage, setDisablePaymentPage] = useState(true); const [disablePaymentPage, setDisablePaymentPage] = useState(true);
const [isTicketOpen, setIsTicketOpen] = useState(false); const [isTicketOpen, setIsTicketOpen] = useState(false);
const router = useRouter();
const disableNavigation = preventNavigation(navDisabled, focusMode); const disableNavigation = preventNavigation(navDisabled, focusMode);
const expirationDateColor = (date: Date) => { const expirationDateColor = (date: Date) => {
@@ -59,10 +61,12 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
return ( return (
<> <>
<Modal isOpen={isTicketOpen} onClose={() => setIsTicketOpen(false)} title="Submit a ticket"> <Modal isOpen={isTicketOpen} onClose={() => setIsTicketOpen(false)} title="Submit a ticket">
<TicketSubmission user={user} page={window.location.href} onClose={() => setIsTicketOpen(false)} /> <TicketSubmission user={user} page={router.asPath} onClose={() => setIsTicketOpen(false)} />
</Modal> </Modal>
{user && <MobileMenu path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />} {user && (
<MobileMenu disableNavigation={disableNavigation} path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />
)}
<header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4"> <header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4">
<Link href={disableNavigation ? "" : "/"} className=" flex items-center gap-8 md:px-8"> <Link href={disableNavigation ? "" : "/"} className=" flex items-center gap-8 md:px-8">
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" /> <img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" />
@@ -73,7 +77,7 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
<button <button
className={clsx( className={clsx(
"border-mti-purple-light tooltip tooltip-bottom flex h-8 w-8 flex-col items-center justify-center rounded-full border p-1", "border-mti-purple-light tooltip tooltip-bottom flex h-8 w-8 flex-col items-center justify-center rounded-full border p-1",
"hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white", "hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white z-20",
)} )}
data-tip="Submit a help/feedback ticket" data-tip="Submit a help/feedback ticket"
onClick={() => setIsTicketOpen(true)}> onClick={() => setIsTicketOpen(true)}>
@@ -82,7 +86,7 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
{showExpirationDate() && ( {showExpirationDate() && (
<Link <Link
href={disablePaymentPage ? "/payment" : ""} href={!!user.subscriptionExpirationDate && !disablePaymentPage ? "/payment" : ""}
data-tip="Expiry date" data-tip="Expiry date"
className={clsx( className={clsx(
"flex w-fit cursor-pointer justify-center rounded-full border px-6 py-2 text-sm font-normal focus:outline-none", "flex w-fit cursor-pointer justify-center rounded-full border px-6 py-2 text-sm font-normal focus:outline-none",

View File

@@ -1,9 +1,20 @@
import {DurationUnit} from "@/interfaces/paypal"; import { DurationUnit } from "@/interfaces/paypal";
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OnCancelledActions, OrderResponseBody} from "@paypal/paypal-js"; import {
import {PayPalButtons, PayPalScriptProvider, usePayPalScriptReducer} from "@paypal/react-paypal-js"; CreateOrderActions,
CreateOrderData,
OnApproveActions,
OnApproveData,
OnCancelledActions,
OrderResponseBody,
} from "@paypal/paypal-js";
import {
PayPalButtons,
PayPalScriptProvider,
usePayPalScriptReducer,
} from "@paypal/react-paypal-js";
import axios from "axios"; import axios from "axios";
import {useEffect, useState} from "react"; import { useState, useEffect } from "react";
import {toast} from "react-toastify"; import { toast } from "react-toastify";
interface Props { interface Props {
clientID: string; clientID: string;
@@ -14,21 +25,59 @@ interface Props {
loadScript?: boolean; loadScript?: boolean;
setIsLoading: (isLoading: boolean) => void; setIsLoading: (isLoading: boolean) => void;
onSuccess: (duration: number, duration_unit: DurationUnit) => void; onSuccess: (duration: number, duration_unit: DurationUnit) => void;
trackingId?: string;
} }
export default function PayPalPayment({clientID, price, currency, duration, duration_unit, loadScript, setIsLoading, onSuccess}: Props) { export default function PayPalPayment({
const createOrder = async (data: CreateOrderData, actions: CreateOrderActions): Promise<string> => { clientID,
price,
currency,
duration,
duration_unit,
loadScript,
setIsLoading,
onSuccess,
trackingId,
}: Props) {
const createOrder = async (
data: CreateOrderData,
actions: CreateOrderActions
): Promise<string> => {
if (!trackingId) {
throw new Error("trackingId is not set");
}
setIsLoading(true); setIsLoading(true);
return axios return axios
.post<OrderResponseBody>("/api/paypal", {currencyCode: currency, price}) .post<OrderResponseBody>("/api/paypal", {
currencyCode: currency,
price,
trackingId,
})
.then((response) => response.data) .then((response) => response.data)
.then((data) => data.id); .then((data) => {
setIsLoading(false);
return data.id;
})
.catch((err) => {
setIsLoading(false);
return err;
});
}; };
const onApprove = async (data: OnApproveData, actions: OnApproveActions) => { 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 (!trackingId) {
throw new Error("trackingId is not set");
}
axios
.post<{ ok: boolean; reason?: string }>("/api/paypal/approve", {
id: data.orderID,
duration,
duration_unit,
trackingId,
})
.then((request) => {
if (request.status !== 200) { if (request.status !== 200) {
toast.error("Something went wrong, please try again later"); toast.error("Something went wrong, please try again later");
return; return;
@@ -36,16 +85,25 @@ export default function PayPalPayment({clientID, price, currency, duration, dura
toast.success("Your account has been credited more time!"); toast.success("Your account has been credited more time!");
return onSuccess(duration, duration_unit); return onSuccess(duration, duration_unit);
})
.catch((err) => {
console.error(err);
toast.error("Something went wrong, please try again later");
});
}; };
const onError = async (data: Record<string, unknown>) => { const onError = async (data: Record<string, unknown>) => {
setIsLoading(false); setIsLoading(false);
}; };
const onCancel = async (data: Record<string, unknown>, actions: OnCancelledActions) => { const onCancel = async (
data: Record<string, unknown>,
actions: OnCancelledActions
) => {
setIsLoading(false); setIsLoading(false);
}; };
if (trackingId) {
return loadScript ? ( return loadScript ? (
<PayPalScriptProvider <PayPalScriptProvider
options={{ options={{
@@ -53,11 +111,11 @@ export default function PayPalPayment({clientID, price, currency, duration, dura
currency, currency,
intent: "capture", intent: "capture",
commit: true, commit: true,
vault: true, }}
}}> >
<PayPalButtons <PayPalButtons
className="w-full" className="w-full"
style={{layout: "vertical"}} style={{ layout: "vertical" }}
createOrder={createOrder} createOrder={createOrder}
onApprove={onApprove} onApprove={onApprove}
onCancel={onCancel} onCancel={onCancel}
@@ -67,11 +125,14 @@ export default function PayPalPayment({clientID, price, currency, duration, dura
) : ( ) : (
<PayPalButtons <PayPalButtons
className="w-full" className="w-full"
style={{layout: "vertical"}} style={{ layout: "vertical" }}
createOrder={createOrder} createOrder={createOrder}
onApprove={onApprove} onApprove={onApprove}
onCancel={onCancel} onCancel={onCancel}
onError={onError} onError={onError}
/> />
); );
}
return null;
} }

View File

@@ -0,0 +1,107 @@
import {PaymentIntention} from "@/interfaces/paymob";
import {DurationUnit} from "@/interfaces/paypal";
import {User} from "@/interfaces/user";
import axios from "axios";
import {useRouter} from "next/router";
import {useState} from "react";
import Button from "./Low/Button";
import Input from "./Low/Input";
import Modal from "./Modal";
interface Props {
user: User;
currency: string;
price: number;
setIsPaymentLoading: (v: boolean) => void;
duration: number;
duration_unit: DurationUnit;
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
}
export default function PaymobPayment({user, price, setIsPaymentLoading, currency, duration, duration_unit, onSuccess}: Props) {
const [isLoading, setIsLoading] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [firstName, setFirstName] = useState(user.name.split(" ")[0]);
const [lastName, setLastName] = useState([...user.name.split(" ")].pop());
const [street, setStreet] = useState("");
const [apartment, setApartment] = useState("");
const [building, setBuilding] = useState("");
const [state, setState] = useState("");
const [floor, setFloor] = useState("");
const router = useRouter();
const handleCardPayment = async () => {
try {
setIsPaymentLoading(true);
const paymentIntention: PaymentIntention = {
amount: price * 1000,
currency: "OMR",
items: [],
payment_methods: [],
customer: {
email: user.email,
first_name: user.name.split(" ")[0],
last_name: [...user.name.split(" ")].pop() || "N/A",
extras: {
re: user.id,
},
},
billing_data: {
apartment: apartment || "N/A",
building: building || "N/A",
country: user.demographicInformation?.country || "N/A",
email: user.email,
first_name: user.name.split(" ")[0],
last_name: [...user.name.split(" ")].pop() || "N/A",
floor: floor || "N/A",
phone_number: user.demographicInformation?.phone || "N/A",
state: state || "N/A",
street: street || "N/A",
},
extras: {
userID: user.id,
duration,
duration_unit,
},
};
const response = await axios.post<{iframeURL: string}>(`/api/paymob`, paymentIntention);
router.push(response.data.iframeURL);
setIsModalOpen(false);
} catch (error) {
console.error("Error starting card payment process:", error);
}
};
return (
<>
<Modal isOpen={isModalOpen} title="Billing Data" onClose={() => setIsModalOpen(false)}>
<div className="flex flex-col gap-4 mt-4">
<div className="grid grid-cols-2 gap-4">
<Input label="First Name" value={firstName} onChange={setFirstName} type="text" name="firstName" />
<Input label="Last Name" value={lastName} onChange={setLastName} type="text" name="lastName" />
</div>
<div className="grid grid-cols-3 -md:grid-cols-1 gap-4">
<Input label="State" value={state} onChange={setState} type="text" name="state" />
<Input label="Street" value={street} onChange={setStreet} type="text" name="street" />
<Input label="Building" value={building} onChange={setBuilding} type="text" name="building" />
</div>
<div className="grid grid-cols-2 gap-4">
<Input label="Floor" value={floor} onChange={setFloor} type="text" name="floor" />
<Input label="Apartment" value={apartment} onChange={setApartment} type="text" name="apartment" />
</div>
<Button className="w-full max-w-[200px] self-end mt-4" disabled={!firstName || !lastName} onClick={handleCardPayment}>
Complete Payment
</Button>
</div>
</Modal>
<Button isLoading={isLoading} onClick={() => setIsModalOpen(true)}>
Select
</Button>
</>
);
}

View File

@@ -1,6 +1,6 @@
import clsx from "clsx"; import clsx from "clsx";
import { IconType } from "react-icons"; import {IconType} from "react-icons";
import { MdSpaceDashboard } from "react-icons/md"; import {MdSpaceDashboard} from "react-icons/md";
import { import {
BsFileEarmarkText, BsFileEarmarkText,
BsClockHistory, BsClockHistory,
@@ -13,17 +13,18 @@ import {
BsCurrencyDollar, BsCurrencyDollar,
BsClipboardData, BsClipboardData,
} from "react-icons/bs"; } from "react-icons/bs";
import { RiLogoutBoxFill } from "react-icons/ri"; import {RiLogoutBoxFill} from "react-icons/ri";
import { SlPencil } from "react-icons/sl"; import {SlPencil} from "react-icons/sl";
import { FaAward } from "react-icons/fa"; import {FaAward} from "react-icons/fa";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import {useRouter} from "next/router";
import axios from "axios"; import axios from "axios";
import FocusLayer from "@/components/FocusLayer"; import FocusLayer from "@/components/FocusLayer";
import { preventNavigation } from "@/utils/navigation.disabled"; import {preventNavigation} from "@/utils/navigation.disabled";
import { useState } from "react"; import {useEffect, useState} from "react";
import usePreferencesStore from "@/stores/preferencesStore"; import usePreferencesStore from "@/stores/preferencesStore";
import { Type } from "@/interfaces/user"; import {Type} from "@/interfaces/user";
import useTicketsListener from "@/hooks/useTicketsListener";
interface Props { interface Props {
path: string; path: string;
navDisabled?: boolean; navDisabled?: boolean;
@@ -31,6 +32,7 @@ interface Props {
onFocusLayerMouseEnter?: () => void; onFocusLayerMouseEnter?: () => void;
className?: string; className?: string;
userType?: Type; userType?: Type;
userId?: string;
} }
interface NavProps { interface NavProps {
@@ -40,47 +42,42 @@ interface NavProps {
keyPath: string; keyPath: string;
disabled?: boolean; disabled?: boolean;
isMinimized?: boolean; isMinimized?: boolean;
badge?: number;
} }
const Nav = ({ const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false, badge}: NavProps) => {
Icon, return (
label,
path,
keyPath,
disabled = false,
isMinimized = false,
}: NavProps) => (
<Link <Link
href={!disabled ? keyPath : ""} href={!disabled ? keyPath : ""}
className={clsx( className={clsx(
"flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white", "flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white",
"transition-all duration-300 ease-in-out", "transition-all duration-300 ease-in-out relative",
disabled disabled ? "hover:bg-mti-gray-dim cursor-not-allowed" : "hover:bg-mti-purple-light cursor-pointer",
? "hover:bg-mti-gray-dim cursor-not-allowed"
: "hover:bg-mti-purple-light cursor-pointer",
path === keyPath && "bg-mti-purple-light text-white", path === keyPath && "bg-mti-purple-light text-white",
isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]", isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]",
)} )}>
>
<Icon size={24} /> <Icon size={24} />
{!isMinimized && <span className="text-lg font-semibold">{label}</span>} {!isMinimized && <span className="text-lg font-semibold">{label}</span>}
{!!badge && badge > 0 && (
<div
className={clsx(
"bg-mti-purple-light h-5 w-5 text-xs rounded-full flex items-center justify-center text-white",
"transition ease-in-out duration-300",
isMinimized && "absolute right-0 top-0",
)}>
{badge}
</div>
)}
</Link> </Link>
); );
};
export default function Sidebar({ export default function Sidebar({path, navDisabled = false, focusMode = false, userType, onFocusLayerMouseEnter, className, userId}: Props) {
path,
navDisabled = false,
focusMode = false,
userType,
onFocusLayerMouseEnter,
className,
}: Props) {
const router = useRouter(); const router = useRouter();
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [ const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
state.isSidebarMinimized,
state.toggleSidebarMinimized, const {totalAssignedTickets} = useTicketsListener(userId);
]);
const logout = async () => { const logout = async () => {
axios.post("/api/logout").finally(() => { axios.post("/api/logout").finally(() => {
@@ -96,20 +93,10 @@ export default function Sidebar({
"relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8", "relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8",
isMinimized ? "w-fit" : "-xl:w-fit w-1/6", isMinimized ? "w-fit" : "-xl:w-fit w-1/6",
className, className,
)} )}>
>
<div className="-xl:hidden flex-col gap-3 xl:flex"> <div className="-xl:hidden flex-col gap-3 xl:flex">
<Nav <Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
disabled={disableNavigation} {(userType === "student" || userType === "teacher" || userType === "developer") && (
Icon={MdSpaceDashboard}
label="Dashboard"
path={path}
keyPath="/"
isMinimized={isMinimized}
/>
{(userType === "student" ||
userType === "teacher" ||
userType === "developer") && (
<> <>
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
@@ -129,25 +116,13 @@ export default function Sidebar({
/> />
</> </>
)} )}
<Nav {(userType || "") !== 'agent' && (
disabled={disableNavigation} <>
Icon={BsGraphUp} <Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
label="Stats" <Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
path={path} </>
keyPath="/stats" )}
isMinimized={isMinimized} {["admin", "developer", "agent", "corporate"].includes(userType || "") && (
/>
<Nav
disabled={disableNavigation}
Icon={BsClockHistory}
label="Record"
path={path}
keyPath="/record"
isMinimized={isMinimized}
/>
{["admin", "developer", "agent", "corporate"].includes(
userType || "",
) && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
Icon={BsCurrencyDollar} Icon={BsCurrencyDollar}
@@ -157,9 +132,7 @@ export default function Sidebar({
isMinimized={isMinimized} isMinimized={isMinimized}
/> />
)} )}
{["admin", "developer", "corporate", "teacher"].includes( {["admin", "developer", "corporate", "teacher"].includes(userType || "") && (
userType || "",
) && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
Icon={BsShieldFill} Icon={BsShieldFill}
@@ -177,6 +150,7 @@ export default function Sidebar({
path={path} path={path}
keyPath="/tickets" keyPath="/tickets"
isMinimized={isMinimized} isMinimized={isMinimized}
badge={totalAssignedTickets}
/> />
)} )}
{userType === "developer" && ( {userType === "developer" && (
@@ -191,65 +165,20 @@ export default function Sidebar({
)} )}
</div> </div>
<div className="-xl:flex flex-col gap-3 xl:hidden"> <div className="-xl:flex flex-col gap-3 xl:hidden">
<Nav <Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} />
disabled={disableNavigation} <Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} />
Icon={MdSpaceDashboard} <Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
label="Dashboard" {(userType || "") !== 'agent' && (
path={path} <>
keyPath="/" <Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
isMinimized={true} <Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
/> </>
<Nav )}
disabled={disableNavigation}
Icon={BsFileEarmarkText}
label="Exams"
path={path}
keyPath="/exam"
isMinimized={true}
/>
<Nav
disabled={disableNavigation}
Icon={BsPencil}
label="Exercises"
path={path}
keyPath="/exercises"
isMinimized={true}
/>
<Nav
disabled={disableNavigation}
Icon={BsGraphUp}
label="Stats"
path={path}
keyPath="/stats"
isMinimized={true}
/>
<Nav
disabled={disableNavigation}
Icon={BsClockHistory}
label="Record"
path={path}
keyPath="/record"
isMinimized={true}
/>
{userType !== "student" && ( {userType !== "student" && (
<Nav <Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized={true} />
disabled={disableNavigation}
Icon={BsShieldFill}
label="Settings"
path={path}
keyPath="/settings"
isMinimized={true}
/>
)} )}
{userType === "developer" && ( {userType === "developer" && (
<Nav <Nav disabled={disableNavigation} Icon={BsCloudFill} label="Generation" path={path} keyPath="/generation" isMinimized={true} />
disabled={disableNavigation}
Icon={BsCloudFill}
label="Generation"
path={path}
keyPath="/generation"
isMinimized={true}
/>
)} )}
</div> </div>
@@ -261,16 +190,9 @@ export default function Sidebar({
className={clsx( className={clsx(
"hover:text-mti-rose -xl:hidden flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out", "hover:text-mti-rose -xl:hidden flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8", isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
)} )}>
> {isMinimized ? <BsChevronBarRight size={24} /> : <BsChevronBarLeft size={24} />}
{isMinimized ? ( {!isMinimized && <span className="text-lg font-medium">Minimize</span>}
<BsChevronBarRight size={24} />
) : (
<BsChevronBarLeft size={24} />
)}
{!isMinimized && (
<span className="text-lg font-medium">Minimize</span>
)}
</div> </div>
<div <div
role="button" role="button"
@@ -279,17 +201,12 @@ export default function Sidebar({
className={clsx( className={clsx(
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out", "hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8", isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
)} )}>
>
<RiLogoutBoxFill size={24} /> <RiLogoutBoxFill size={24} />
{!isMinimized && ( {!isMinimized && <span className="-xl:hidden text-lg font-medium">Log Out</span>}
<span className="-xl:hidden text-lg font-medium">Log Out</span>
)}
</div> </div>
</div> </div>
{focusMode && ( {focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
<FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
)}
</section> </section>
); );
} }

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import {CommonProps} from ".";
import Button from "../Low/Button"; import Button from "../Low/Button";
function Question({ function Question({
id,
variant, variant,
prompt, prompt,
solution, solution,
@@ -26,7 +27,9 @@ function Question({
return ( return (
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<span>{prompt}</span> <span>
{id} - {prompt}
</span>
<div className="grid grid-cols-4 gap-4 place-items-center"> <div className="grid grid-cols-4 gap-4 place-items-center">
{variant === "image" && {variant === "image" &&
options.map((option) => ( options.map((option) => (

View File

@@ -20,6 +20,9 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
useEffect(() => { useEffect(() => {
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) { if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
const solution = userSolutions[0].solution;
if (solution.startsWith("https://")) return setSolutionURL(solution);
axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => { axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => {
const blob = new Blob([data], {type: "audio/wav"}); const blob = new Blob([data], {type: "audio/wav"});
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import useStats from "@/hooks/useStats"; import useStats from "@/hooks/useStats";
import {EMPLOYMENT_STATUS, User} from "@/interfaces/user"; import {CorporateInformation, CorporateUser, EMPLOYMENT_STATUS, User} from "@/interfaces/user";
import {groupBySession, averageScore} from "@/utils/stats"; import {groupBySession, averageScore} from "@/utils/stats";
import {RadioGroup} from "@headlessui/react"; import {RadioGroup} from "@headlessui/react";
import axios from "axios"; import axios from "axios";
@@ -8,7 +8,7 @@ import moment from "moment";
import {Divider} from "primereact/divider"; import {Divider} from "primereact/divider";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import ReactDatePicker from "react-datepicker"; import ReactDatePicker from "react-datepicker";
import {BsFileEarmarkText, BsPencil, BsStar} from "react-icons/bs"; import {BsFileEarmarkText, BsPencil, BsPerson, BsPersonAdd, BsStar} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import Button from "./Low/Button"; import Button from "./Low/Button";
import Checkbox from "./Low/Checkbox"; import Checkbox from "./Low/Checkbox";
@@ -19,6 +19,7 @@ import Select from "react-select";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import {USER_TYPE_LABELS} from "@/resources/user"; import {USER_TYPE_LABELS} from "@/resources/user";
import {CURRENCIES} from "@/resources/paypal"; import {CURRENCIES} from "@/resources/paypal";
import useCodes from "@/hooks/useCodes";
const expirationDateColor = (date: Date) => { const expirationDateColor = (date: Date) => {
const momentDate = moment(date); const momentDate = moment(date);
@@ -37,6 +38,9 @@ interface Props {
onViewTeachers?: () => void; onViewTeachers?: () => void;
onViewCorporate?: () => void; onViewCorporate?: () => void;
disabled?: boolean; disabled?: boolean;
disabledFields?: {
countryManager?: boolean;
};
} }
const USER_STATUS_OPTIONS = [ const USER_STATUS_OPTIONS = [
@@ -59,9 +63,12 @@ const USER_TYPE_OPTIONS = Object.keys(USER_TYPE_LABELS).map((type) => ({
label: USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS], label: USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS],
})); }));
const CURRENCIES_OPTIONS = CURRENCIES.map(({label, currency}) => ({value: currency, label})); const CURRENCIES_OPTIONS = CURRENCIES.map(({label, currency}) => ({
value: currency,
label,
}));
const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers, onViewCorporate, disabled = false}: Props) => { const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers, onViewCorporate, disabled = false, disabledFields = {}}: Props) => {
const [expiryDate, setExpiryDate] = useState<Date | null | undefined>(user.subscriptionExpirationDate); const [expiryDate, setExpiryDate] = useState<Date | null | undefined>(user.subscriptionExpirationDate);
const [type, setType] = useState(user.type); const [type, setType] = useState(user.type);
const [status, setStatus] = useState(user.status); const [status, setStatus] = useState(user.status);
@@ -77,6 +84,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
? user.agentInformation?.companyName ? user.agentInformation?.companyName
: undefined, : undefined,
); );
const [arabName, setArabName] = useState(user.type === "agent" ? user.agentInformation?.companyArabName : undefined);
const [commercialRegistration, setCommercialRegistration] = useState( const [commercialRegistration, setCommercialRegistration] = useState(
user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined, user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined,
); );
@@ -87,6 +95,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
const [commissionValue, setCommission] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.commission : undefined); const [commissionValue, setCommission] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.commission : undefined);
const {stats} = useStats(user.id); const {stats} = useStats(user.id);
const {users} = useUsers(); const {users} = useUsers();
const {codes} = useCodes(user.id);
useEffect(() => { useEffect(() => {
if (users && users.length > 0) { if (users && users.length > 0) {
@@ -114,8 +123,9 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
agentInformation: agentInformation:
type === "agent" type === "agent"
? { ? {
name: companyName, companyName,
commercialRegistration, commercialRegistration,
arabName,
} }
: undefined, : undefined,
corporateInformation: corporateInformation:
@@ -144,11 +154,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
}); });
}; };
return ( const generalProfileItems = [
<>
<ProfileSummary
user={user}
items={[
{ {
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />, icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: Object.keys(groupBySession(stats)).length, value: Object.keys(groupBySession(stats)).length,
@@ -157,25 +163,54 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
{ {
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />, icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: stats.length, value: stats.length,
label: "Exercises", label: "Modules",
}, },
{ {
icon: <BsStar className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />, icon: <BsStar className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: `${stats.length > 0 ? averageScore(stats) : 0}%`, value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
label: "Average Score", label: "Average Score",
}, },
]} ];
/>
const corporateProfileItems =
user.type === "corporate"
? [
{
icon: <BsPerson className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: codes.length,
label: "Users Used",
},
{
icon: <BsPersonAdd className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: user.corporateInformation.companyInformation.userAmount,
label: "Number of Users",
},
]
: [];
return (
<>
<ProfileSummary user={user} items={user.type === "corporate" ? corporateProfileItems : generalProfileItems} />
{user.type === "agent" && ( {user.type === "agent" && (
<> <>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full"> <div className="grid grid-cols-1 md:grid-cols-3 gap-8 w-full">
<Input <Input
label="Corporate Name" label="Company Name (Arabic)"
type="text"
name="arabName"
onChange={setArabName}
placeholder="Enter their company's name in arabic"
defaultValue={arabName}
required
disabled={disabled}
/>
<Input
label="Company Name (English)"
type="text" type="text"
name="companyName" name="companyName"
onChange={setCompanyName} onChange={setCompanyName}
placeholder="Enter corporate name" placeholder="Enter their company's name in english"
defaultValue={companyName} defaultValue={companyName}
required required
disabled={disabled} disabled={disabled}
@@ -224,9 +259,9 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
defaultValue={monthlyDuration} defaultValue={monthlyDuration}
disabled={disabled} disabled={disabled}
/> />
<div className="flex flex-col gap-3 w-full lg:col-span-2"> <div className="flex flex-col gap-3 w-full lg:col-span-3">
<label className="font-normal text-base text-mti-gray-dim">Pricing</label> <label className="font-normal text-base text-mti-gray-dim">Pricing</label>
<div className="w-full grid grid-cols-5 gap-2"> <div className="w-full grid grid-cols-6 gap-2">
<Input <Input
name="paymentValue" name="paymentValue"
onChange={(e) => setPaymentValue(e ? parseInt(e) : undefined)} onChange={(e) => setPaymentValue(e ? parseInt(e) : undefined)}
@@ -237,7 +272,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
/> />
<Select <Select
className={clsx( className={clsx(
"px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none", "px-4 py-4 col-span-3 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed", disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
)} )}
options={CURRENCIES_OPTIONS} options={CURRENCIES_OPTIONS}
@@ -273,12 +308,17 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<Select <Select
className={clsx( className={clsx(
"px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none", "px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
!["developer", "admin"].includes(loggedInUser.type) && (!["developer", "admin"].includes(loggedInUser.type) || disabledFields.countryManager) &&
"!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed", "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
)} )}
options={[ options={[
{value: "", label: "No referral"}, {value: "", label: "No referral"},
...users.filter((u) => u.type === "agent").map((x) => ({value: x.id, label: `${x.name} - ${x.email}`})), ...users
.filter((u) => u.type === "agent")
.map((x) => ({
value: x.id,
label: `${x.name} - ${x.email}`,
})),
]} ]}
defaultValue={{ defaultValue={{
value: referralAgent, value: referralAgent,
@@ -304,7 +344,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
}), }),
}} }}
// editing country manager should only be available for dev/admin // editing country manager should only be available for dev/admin
isDisabled={!["developer", "admin"].includes(loggedInUser.type)} isDisabled={!["developer", "admin"].includes(loggedInUser.type) || disabledFields.countryManager}
/> />
)} )}
</div> </div>

View File

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

View File

@@ -16,6 +16,8 @@ import {
BsPencilSquare, BsPencilSquare,
BsBank, BsBank,
BsCurrencyDollar, BsCurrencyDollar,
BsLayoutWtf,
BsLayoutSidebar,
} from "react-icons/bs"; } from "react-icons/bs";
import UserCard from "@/components/UserCard"; import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
@@ -309,6 +311,12 @@ export default function AdminDashboard({user}: Props) {
value={pending.length} value={pending.length}
color="rose" color="rose"
/> />
<IconCard
onClick={() => router.push("https://cms.encoach.com/admin")}
Icon={BsLayoutSidebar}
label="Content Management System (CMS)"
color="green"
/>
</section> </section>
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between"> <section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between">
@@ -323,6 +331,19 @@ export default function AdminDashboard({user}: Props) {
))} ))}
</div> </div>
</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((x) => x.type === "teacher")
.sort((a, b) => {
return 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"> <div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest corporate</span> <span className="p-4">Latest corporate</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
@@ -363,7 +384,7 @@ export default function AdminDashboard({user}: Props) {
</div> </div>
</div> </div>
<div className="bg-white shadow flex flex-col rounded-xl w-full"> <div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Country Manager expiring in 1 month</span> <span className="p-4">Teachers expiring in 1 month</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users {users
.filter( .filter(
@@ -378,6 +399,22 @@ export default function AdminDashboard({user}: Props) {
))} ))}
</div> </div>
</div> </div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Country Manager expiring in 1 month</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(
(x) =>
x.type === "agent" &&
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"> <div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Corporate expiring in 1 month</span> <span className="p-4">Corporate expiring in 1 month</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
@@ -407,7 +444,7 @@ export default function AdminDashboard({user}: Props) {
</div> </div>
</div> </div>
<div className="bg-white shadow flex flex-col rounded-xl w-full"> <div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Expired Country Manager</span> <span className="p-4">Expired Teachers</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users {users
.filter( .filter(
@@ -418,6 +455,18 @@ export default function AdminDashboard({user}: Props) {
))} ))}
</div> </div>
</div> </div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Expired Country Manager</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(
(x) => x.type === "agent" && 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"> <div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Expired Corporate</span> <span className="p-4">Expired Corporate</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">

View File

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

View File

@@ -19,7 +19,8 @@ import {toast} from "react-toastify";
import {uuidv4} from "@firebase/util"; import {uuidv4} from "@firebase/util";
import {Assignment} from "@/interfaces/results"; import {Assignment} from "@/interfaces/results";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import {Variant} from "@/interfaces/exam"; import {InstructorGender, Variant} from "@/interfaces/exam";
import Select from "@/components/Low/Select";
interface Props { interface Props {
isCreating: boolean; isCreating: boolean;
@@ -40,6 +41,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(), assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(),
); );
const [variant, setVariant] = useState<Variant>("full"); const [variant, setVariant] = useState<Variant>("full");
const [instructorGender, setInstructorGender] = useState<InstructorGender>(assignment?.instructorGender || "varied");
// creates a new exam for each assignee or just one exam for all assignees // creates a new exam for each assignee or just one exam for all assignees
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false); const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
@@ -63,6 +65,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
selectedModules, selectedModules,
generateMultiple, generateMultiple,
variant, variant,
instructorGender,
}) })
.then(() => { .then(() => {
toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`); toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`);
@@ -226,6 +229,20 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
</div> </div>
</div> </div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor&apos;s Gender</label>
<Select
value={{value: instructorGender, label: capitalize(instructorGender)}}
onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)}
disabled={!selectedModules.includes("speaking") || !!assignment}
options={[
{value: "male", label: "Male"},
{value: "female", label: "Female"},
{value: "varied", label: "Varied"},
]}
/>
</div>
<section className="w-full flex flex-col gap-3"> <section className="w-full flex flex-col gap-3">
<span className="font-semibold">Assignees ({assignees.length} selected)</span> <span className="font-semibold">Assignees ({assignees.length} selected)</span>
<div className="flex gap-4 overflow-x-scroll scrollbar-hide"> <div className="flex gap-4 overflow-x-scroll scrollbar-hide">

View File

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

View File

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

View File

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

View File

@@ -153,7 +153,8 @@ export default function TeacherDashboard({user}: Props) {
const AssignmentsPage = () => { const AssignmentsPage = () => {
const activeFilter = (a: Assignment) => const activeFilter = (a: Assignment) =>
moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()) && a.assignees.length > a.results.length; moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()) && a.assignees.length > a.results.length;
const pastFilter = (a: Assignment) => moment(a.endDate).isBefore(moment()) || a.assignees.length === a.results.length; const pastFilter = (a: Assignment) => (moment(a.endDate).isBefore(moment()) || a.assignees.length === a.results.length) && !a.archived;
const archivedFilter = (a: Assignment) => a.archived;
const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment()); const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
return ( return (
@@ -163,6 +164,7 @@ export default function TeacherDashboard({user}: Props) {
onClose={() => { onClose={() => {
setSelectedAssignment(undefined); setSelectedAssignment(undefined);
setIsCreatingAssignment(false); setIsCreatingAssignment(false);
reloadAssignments();
}} }}
assignment={selectedAssignment} assignment={selectedAssignment}
/> />
@@ -234,7 +236,29 @@ export default function TeacherDashboard({user}: Props) {
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2> <h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{assignments.filter(pastFilter).map((a) => ( {assignments.filter(pastFilter).map((a) => (
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} allowDownload /> <AssignmentCard
{...a}
onClick={() => setSelectedAssignment(a)}
key={a.id}
allowDownload
reload={reloadAssignments}
allowArchive
/>
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Archived Assignments ({assignments.filter(archivedFilter).length})</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(archivedFilter).map((a) => (
<AssignmentCard
{...a}
onClick={() => setSelectedAssignment(a)}
key={a.id}
allowDownload
reload={reloadAssignments}
allowUnarchive
/>
))} ))}
</div> </div>
</section> </section>
@@ -280,7 +304,7 @@ export default function TeacherDashboard({user}: Props) {
<BsEnvelopePaper className="text-6xl text-mti-purple-light" /> <BsEnvelopePaper className="text-6xl text-mti-purple-light" />
<span className="flex flex-col gap-1 items-center text-xl"> <span className="flex flex-col gap-1 items-center text-xl">
<span className="text-lg">Assignments</span> <span className="text-lg">Assignments</span>
<span className="font-semibold text-mti-purple-light">{assignments.length}</span> <span className="font-semibold text-mti-purple-light">{assignments.filter((a) => !a.archived).length}</span>
</span> </span>
</div> </div>
</section> </section>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ import {Fragment, useEffect, useState} from "react";
import {BsArrowCounterclockwise, BsBook, BsClipboard, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs"; import {BsArrowCounterclockwise, BsBook, BsClipboard, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs";
import {LevelScore} from "@/constants/ielts"; import {LevelScore} from "@/constants/ielts";
import {getLevelScore} from "@/utils/score"; import {getLevelScore} from "@/utils/score";
import {capitalize} from "lodash";
interface Score { interface Score {
module: Module; module: Module;
@@ -25,7 +26,7 @@ interface Props {
modules: Module[]; modules: Module[];
scores: Score[]; scores: Score[];
isLoading: boolean; isLoading: boolean;
onViewResults: () => void; onViewResults: (moduleIndex?: number) => void;
} }
export default function Finish({user, scores, modules, isLoading, onViewResults}: Props) { export default function Finish({user, scores, modules, isLoading, onViewResults}: Props) {
@@ -182,7 +183,8 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
{showLevel(bandScore)} {showLevel(bandScore)}
</div> </div>
</div> </div>
<div className="flex flex-col gap-5"> {!["writing", "speaking"].includes(selectedModule) ? (
<div className="flex flex-col gap-5 w-28">
<div className="flex gap-2"> <div className="flex gap-2">
<div className="bg-mti-red-light mt-1 h-3 w-3 rounded-full" /> <div className="bg-mti-red-light mt-1 h-3 w-3 rounded-full" />
<div className="flex flex-col"> <div className="flex flex-col">
@@ -209,6 +211,9 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
</div> </div>
</div> </div>
</div> </div>
) : (
<div className="w-28 h-full" />
)}
</div> </div>
</div> </div>
)} )}
@@ -220,6 +225,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
<div className="flex w-fit cursor-pointer flex-col items-center gap-1"> <div className="flex w-fit cursor-pointer flex-col items-center gap-1">
<button <button
onClick={() => window.location.reload()} onClick={() => window.location.reload()}
disabled={user.type === "admin"}
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out"> className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
<BsArrowCounterclockwise className="h-7 w-7 text-white" /> <BsArrowCounterclockwise className="h-7 w-7 text-white" />
</button> </button>
@@ -227,11 +233,19 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
</div> </div>
<div className="flex w-fit cursor-pointer flex-col items-center gap-1"> <div className="flex w-fit cursor-pointer flex-col items-center gap-1">
<button <button
onClick={onViewResults} onClick={() => onViewResults()}
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out"> className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
<BsEyeFill className="h-7 w-7 text-white" /> <BsEyeFill className="h-7 w-7 text-white" />
</button> </button>
<span>Review Answers</span> <span>Review All</span>
</div>
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
<button
onClick={() => onViewResults(modules.findIndex((x) => x === selectedModule))}
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
<BsEyeFill className="h-7 w-7 text-white" />
</button>
<span>Review {capitalize(selectedModule)}</span>
</div> </div>
</div> </div>

View File

@@ -38,7 +38,7 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
const nextExercise = (solution?: UserSolution) => { const nextExercise = (solution?: UserSolution) => {
if (solution) { if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]); setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]);
} }
setQuestionIndex((prev) => prev + currentQuestionIndex); setQuestionIndex((prev) => prev + currentQuestionIndex);
@@ -52,17 +52,15 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
setHasExamEnded(false); setHasExamEnded(false);
if (solution) { if (solution) {
onFinish( onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]);
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "level", exam: exam.id})),
);
} else { } else {
onFinish(userSolutions.map((x) => ({...x, module: "level", exam: exam.id}))); onFinish(userSolutions);
} }
}; };
const previousExercise = (solution?: UserSolution) => { const previousExercise = (solution?: UserSolution) => {
if (solution) { if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]); setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]);
} }
if (exerciseIndex > 0) { if (exerciseIndex > 0) {

View File

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

View File

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

View File

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

View File

@@ -36,7 +36,7 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
const nextExercise = (solution?: UserSolution) => { const nextExercise = (solution?: UserSolution) => {
scrollToTop(); scrollToTop();
if (solution) { if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]); setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "speaking", exam: exam.id}]);
} }
setQuestionIndex((prev) => prev + currentQuestionIndex); setQuestionIndex((prev) => prev + currentQuestionIndex);
@@ -50,18 +50,16 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
setHasExamEnded(false); setHasExamEnded(false);
if (solution) { if (solution) {
onFinish( onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "speaking", exam: exam.id}]);
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "speaking", exam: exam.id})),
);
} else { } else {
onFinish(userSolutions.map((x) => ({...x, module: "speaking", exam: exam.id}))); onFinish(userSolutions);
} }
}; };
const previousExercise = (solution?: UserSolution) => { const previousExercise = (solution?: UserSolution) => {
scrollToTop(); scrollToTop();
if (solution) { if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]); setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "speaking", exam: exam.id}]);
} }
if (exerciseIndex > 0) { if (exerciseIndex > 0) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,8 @@ import {Module} from ".";
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam; export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
export type Variant = "full" | "partial"; export type Variant = "full" | "partial";
export type InstructorGender = "male" | "female" | "varied";
export type Difficulty = "easy" | "medium" | "hard";
export interface ReadingExam { export interface ReadingExam {
parts: ReadingPart[]; parts: ReadingPart[];
@@ -11,6 +13,7 @@ export interface ReadingExam {
type: "academic" | "general"; type: "academic" | "general";
isDiagnostic: boolean; isDiagnostic: boolean;
variant?: Variant; variant?: Variant;
difficulty?: Difficulty;
} }
export interface ReadingPart { export interface ReadingPart {
@@ -28,6 +31,7 @@ export interface LevelExam {
minTimer: number; minTimer: number;
isDiagnostic: boolean; isDiagnostic: boolean;
variant?: Variant; variant?: Variant;
difficulty?: Difficulty;
} }
export interface ListeningExam { export interface ListeningExam {
@@ -37,6 +41,7 @@ export interface ListeningExam {
minTimer: number; minTimer: number;
isDiagnostic: boolean; isDiagnostic: boolean;
variant?: Variant; variant?: Variant;
difficulty?: Difficulty;
} }
export interface ListeningPart { export interface ListeningPart {
@@ -59,15 +64,17 @@ export interface UserSolution {
missing: number; missing: number;
}; };
exercise: string; exercise: string;
isDisabled?: boolean;
} }
export interface WritingExam { export interface WritingExam {
module: "writing"; module: "writing";
id: string; id: string;
exercises: Exercise[]; exercises: WritingExercise[];
minTimer: number; minTimer: number;
isDiagnostic: boolean; isDiagnostic: boolean;
variant?: Variant; variant?: Variant;
difficulty?: Difficulty;
} }
interface WordCounter { interface WordCounter {
@@ -78,10 +85,12 @@ interface WordCounter {
export interface SpeakingExam { export interface SpeakingExam {
id: string; id: string;
module: "speaking"; module: "speaking";
exercises: Exercise[]; exercises: (SpeakingExercise | InteractiveSpeakingExercise)[];
minTimer: number; minTimer: number;
isDiagnostic: boolean; isDiagnostic: boolean;
variant?: Variant; variant?: Variant;
instructorGender: InstructorGender;
difficulty?: Difficulty;
} }
export type Exercise = export type Exercise =
@@ -140,6 +149,7 @@ export interface WritingExercise {
solution: string; solution: string;
evaluation?: CommonEvaluation; evaluation?: CommonEvaluation;
}[]; }[];
topic?: string;
} }
export interface SpeakingExercise { export interface SpeakingExercise {
@@ -154,6 +164,7 @@ export interface SpeakingExercise {
solution: string; solution: string;
evaluation?: SpeakingEvaluation; evaluation?: SpeakingEvaluation;
}[]; }[];
topic?: string;
} }
export interface InteractiveSpeakingExercise { export interface InteractiveSpeakingExercise {
@@ -167,6 +178,7 @@ export interface InteractiveSpeakingExercise {
solution: {questionIndex: number; question: string; answer: string}[]; solution: {questionIndex: number; question: string; answer: string}[];
evaluation?: InteractiveSpeakingEvaluation; evaluation?: InteractiveSpeakingEvaluation;
}[]; }[];
topic?: string;
} }
export interface FillBlanksExercise { export interface FillBlanksExercise {
@@ -221,17 +233,21 @@ export interface MatchSentencesExercise {
id: string; id: string;
prompt: string; prompt: string;
userSolutions: {question: string; option: string}[]; userSolutions: {question: string; option: string}[];
sentences: { sentences: MatchSentenceExerciseSentence[];
allowRepetition: boolean;
options: MatchSentenceExerciseOption[];
}
export interface MatchSentenceExerciseSentence {
id: string; id: string;
sentence: string; sentence: string;
solution: string; solution: string;
color: string; color: string;
}[]; }
allowRepetition: boolean;
options: { export interface MatchSentenceExerciseOption {
id: string; id: string;
sentence: string; sentence: string;
}[];
} }
export interface MultipleChoiceExercise { export interface MultipleChoiceExercise {

View File

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

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

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

View File

@@ -20,6 +20,12 @@ export interface Package {
price: number; price: number;
} }
export interface Discount {
id: string;
percentage: number;
domain: string;
}
export type DurationUnit = "weeks" | "days" | "months" | "years"; export type DurationUnit = "weeks" | "days" | "months" | "years";
export interface Payment { export interface Payment {
@@ -35,3 +41,15 @@ export interface Payment {
corporateTransfer?: string; corporateTransfer?: string;
commissionTransfer?: string; commissionTransfer?: string;
} }
export interface PaypalPayment {
orderId: string;
userId: string;
status: string;
createdAt: Date;
value: number;
currency: string;
subscriptionDuration: number;
subscriptionDurationUnit: DurationUnit;
subscriptionExpirationDate: Date;
}

View File

@@ -1,4 +1,5 @@
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import {InstructorGender} from "./exam";
import {Stat} from "./user"; import {Stat} from "./user";
export type UserResults = {[key in Module]: ModuleResult}; export type UserResults = {[key in Module]: ModuleResult};
@@ -19,7 +20,9 @@ export interface Assignment {
type: "academic" | "general"; type: "academic" | "general";
stats: Stat[]; stats: Stat[];
}[]; }[];
exams: {id: string; module: Module, assignee: string}[]; exams: {id: string; module: Module; assignee: string}[];
instructorGender?: InstructorGender;
startDate: Date; startDate: Date;
endDate: Date; endDate: Date;
archived?: boolean;
} }

View File

@@ -1,4 +1,5 @@
import { Type } from "./user"; import {Module} from ".";
import {Type} from "./user";
export interface Ticket { export interface Ticket {
id: string; id: string;
@@ -10,6 +11,15 @@ export interface Ticket {
description: string; description: string;
subject: string; subject: string;
assignedTo?: string; assignedTo?: string;
examInformation?: {
exams: string[];
exam: string;
selectedModules: Module[];
moduleIndex: number;
partIndex: number;
exerciseIndex: number;
questionIndex: number;
};
} }
export interface TicketReporter { export interface TicketReporter {
@@ -20,15 +30,19 @@ export interface TicketReporter {
} }
export type TicketType = "feedback" | "bug" | "help"; export type TicketType = "feedback" | "bug" | "help";
export const TicketTypeLabel: { [key in TicketType]: string } = { export const TicketTypeLabel: {[key in TicketType]: string} = {
feedback: "Feedback", feedback: "Feedback",
bug: "Bug", bug: "Bug",
help: "Help", help: "Help",
}; };
export type TicketStatus = "submitted" | "in-progress" | "completed"; export type TicketStatus = "submitted" | "in-progress" | "completed";
export const TicketStatusLabel: { [key in TicketStatus]: string } = { export const TicketStatusLabel: {[key in TicketStatus]: string} = {
submitted: "Submitted", submitted: "Submitted",
"in-progress": "In Progress", "in-progress": "In Progress",
completed: "Completed", completed: "Completed",
}; };
export interface TicketWithCorporate extends Ticket {
corporate?: string;
}

View File

@@ -1,6 +1,14 @@
import {Module} from "."; import { Module } from ".";
import { InstructorGender } from "./exam";
export type User = StudentUser | TeacherUser | CorporateUser | AgentUser | AdminUser | DeveloperUser; export type User =
| StudentUser
| TeacherUser
| CorporateUser
| AgentUser
| AdminUser
| DeveloperUser;
export type UserStatus = "active" | "disabled" | "paymentDue";
export interface BasicUser { export interface BasicUser {
email: string; email: string;
@@ -9,19 +17,21 @@ export interface BasicUser {
id: string; id: string;
isFirstLogin: boolean; isFirstLogin: boolean;
focus: "academic" | "general"; focus: "academic" | "general";
levels: {[key in Module]: number}; levels: { [key in Module]: number };
desiredLevels: {[key in Module]: number}; desiredLevels: { [key in Module]: number };
type: Type; type: Type;
bio: string; bio: string;
isVerified: boolean; isVerified: boolean;
subscriptionExpirationDate?: null | Date; subscriptionExpirationDate?: null | Date;
registrationDate?: Date; registrationDate?: Date;
status: "active" | "disabled" | "paymentDue"; status: UserStatus;
} }
export interface StudentUser extends BasicUser { export interface StudentUser extends BasicUser {
type: "student"; type: "student";
preferredGender?: InstructorGender;
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
preferredTopics?: string[];
} }
export interface TeacherUser extends BasicUser { export interface TeacherUser extends BasicUser {
@@ -48,7 +58,9 @@ export interface AdminUser extends BasicUser {
export interface DeveloperUser extends BasicUser { export interface DeveloperUser extends BasicUser {
type: "developer"; type: "developer";
preferredGender?: InstructorGender;
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
preferredTopics?: string[];
} }
export interface CorporateInformation { export interface CorporateInformation {
@@ -65,6 +77,7 @@ export interface CorporateInformation {
export interface AgentInformation { export interface AgentInformation {
companyName: string; companyName: string;
commercialRegistration: string; commercialRegistration: string;
companyArabName?: string;
} }
export interface CompanyInformation { export interface CompanyInformation {
@@ -90,15 +103,22 @@ export interface DemographicCorporateInformation {
} }
export type Gender = "male" | "female" | "other"; export type Gender = "male" | "female" | "other";
export type EmploymentStatus = "employed" | "student" | "self-employed" | "unemployed" | "retired" | "other"; export type EmploymentStatus =
export const EMPLOYMENT_STATUS: {status: EmploymentStatus; label: string}[] = [ | "employed"
{status: "student", label: "Student"}, | "student"
{status: "employed", label: "Employed"}, | "self-employed"
{status: "unemployed", label: "Unemployed"}, | "unemployed"
{status: "self-employed", label: "Self-employed"}, | "retired"
{status: "retired", label: "Retired"}, | "other";
{status: "other", label: "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 { export interface Stat {
id: string; id: string;
@@ -117,6 +137,7 @@ export interface Stat {
total: number; total: number;
missing: number; missing: number;
}; };
isDisabled?: boolean;
} }
export interface Group { export interface Group {
@@ -132,11 +153,25 @@ export interface Code {
creator: string; creator: string;
expiryDate: Date; expiryDate: Date;
type: Type; type: Type;
creationDate?: string;
userId?: string; userId?: string;
email?: string; email?: string;
name?: string; name?: string;
passport_id?: string; passport_id?: string;
} }
export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent"; export type Type =
export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent"]; | "student"
| "teacher"
| "corporate"
| "admin"
| "developer"
| "agent";
export const userTypes: Type[] = [
"student",
"teacher",
"corporate",
"admin",
"developer",
"agent",
];

View File

@@ -1,27 +1,25 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import { PERMISSIONS } from "@/constants/userPermissions"; import {PERMISSIONS} from "@/constants/userPermissions";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import { Type, User } from "@/interfaces/user"; import {Type, User} from "@/interfaces/user";
import { USER_TYPE_LABELS } from "@/resources/user"; import {USER_TYPE_LABELS} from "@/resources/user";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import { capitalize, uniqBy } from "lodash"; import {capitalize, uniqBy} from "lodash";
import moment from "moment"; import moment from "moment";
import { useEffect, useState } from "react"; import {useEffect, useState} from "react";
import ReactDatePicker from "react-datepicker"; import ReactDatePicker from "react-datepicker";
import { toast } from "react-toastify"; import {toast} from "react-toastify";
import ShortUniqueId from "short-unique-id"; import ShortUniqueId from "short-unique-id";
import { useFilePicker } from "use-file-picker"; import {useFilePicker} from "use-file-picker";
import readXlsxFile from "read-excel-file"; import readXlsxFile from "read-excel-file";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import { BsFileEarmarkEaselFill, BsQuestionCircleFill } from "react-icons/bs"; import {BsFileEarmarkEaselFill, BsQuestionCircleFill} from "react-icons/bs";
const EMAIL_REGEX = new RegExp( const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/,
);
const USER_TYPE_PERMISSIONS: { [key in Type]: Type[] } = { const USER_TYPE_PERMISSIONS: {[key in Type]: Type[]} = {
student: [], student: [],
teacher: [], teacher: [],
agent: [], agent: [],
@@ -30,30 +28,25 @@ const USER_TYPE_PERMISSIONS: { [key in Type]: Type[] } = {
developer: ["student", "teacher", "agent", "corporate", "admin", "developer"], developer: ["student", "teacher", "agent", "corporate", "admin", "developer"],
}; };
export default function BatchCodeGenerator({ user }: { user: User }) { export default function BatchCodeGenerator({user}: {user: User}) {
const [infos, setInfos] = useState< const [infos, setInfos] = useState<{email: string; name: string; passport_id: string}[]>([]);
{ email: string; name: string; passport_id: string }[]
>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [expiryDate, setExpiryDate] = useState<Date | null>(null); const [expiryDate, setExpiryDate] = useState<Date | null>(
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
);
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [type, setType] = useState<Type>("student"); const [type, setType] = useState<Type>("student");
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const { users } = useUsers(); const {users} = useUsers();
const { openFilePicker, filesContent, clear } = useFilePicker({ const {openFilePicker, filesContent, clear} = useFilePicker({
accept: ".xlsx", accept: ".xlsx",
multiple: false, multiple: false,
readAs: "ArrayBuffer", readAs: "ArrayBuffer",
}); });
useEffect(() => { useEffect(() => console.log(expiryDate), [expiryDate]);
if (user && (user.type === "corporate" || user.type === "teacher")) {
setExpiryDate(user.subscriptionExpirationDate || null);
setIsExpiryDateEnabled(!!user.subscriptionExpirationDate);
}
}, [user]);
useEffect(() => { useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null); if (!isExpiryDateEnabled) setExpiryDate(null);
@@ -67,14 +60,7 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
const information = uniqBy( const information = uniqBy(
rows rows
.map((row) => { .map((row) => {
const [ const [firstName, lastName, country, passport_id, email, ...phone] = row as string[];
firstName,
lastName,
country,
passport_id,
email,
...phone
] = row as string[];
return EMAIL_REGEX.test(email.toString().trim()) return EMAIL_REGEX.test(email.toString().trim())
? { ? {
email: email.toString().trim().toLowerCase(), email: email.toString().trim().toLowerCase(),
@@ -107,20 +93,14 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
}, [filesContent]); }, [filesContent]);
const generateAndInvite = async () => { const generateAndInvite = async () => {
const newUsers = infos.filter( const newUsers = infos.filter((x) => !users.map((u) => u.email).includes(x.email));
(x) => !users.map((u) => u.email).includes(x.email),
);
const existingUsers = infos const existingUsers = infos
.filter((x) => users.map((u) => u.email).includes(x.email)) .filter((x) => users.map((u) => u.email).includes(x.email))
.map((i) => users.find((u) => u.email === i.email)) .map((i) => users.find((u) => u.email === i.email))
.filter((x) => !!x && x.type === "student") as User[]; .filter((x) => !!x && x.type === "student") as User[];
const newUsersSentence = const newUsersSentence = newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined;
newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined; const existingUsersSentence = existingUsers.length > 0 ? `invite ${existingUsers.length} registered student(s)` : undefined;
const existingUsersSentence =
existingUsers.length > 0
? `invite ${existingUsers.length} registered student(s)`
: undefined;
if ( if (
!confirm( !confirm(
`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`, `You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`,
@@ -129,17 +109,8 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
return; return;
setIsLoading(true); setIsLoading(true);
Promise.all( Promise.all(existingUsers.map(async (u) => await axios.post(`/api/invites`, {to: u.id, from: user.id})))
existingUsers.map( .then(() => toast.success(`Successfully invited ${existingUsers.length} registered student(s)!`))
async (u) =>
await axios.post(`/api/invites`, { to: u.id, from: user.id }),
),
)
.then(() =>
toast.success(
`Successfully invited ${existingUsers.length} registered student(s)!`,
),
)
.finally(() => { .finally(() => {
if (newUsers.length === 0) setIsLoading(false); if (newUsers.length === 0) setIsLoading(false);
}); });
@@ -154,30 +125,30 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
setIsLoading(true); setIsLoading(true);
axios axios
.post<{ ok: boolean; valid?: number; reason?: string }>("/api/code", { .post<{ok: boolean; valid?: number; reason?: string}>("/api/code", {
type, type,
codes, codes,
infos: informations, infos: informations,
expiryDate, expiryDate,
}) })
.then(({ data, status }) => { .then(({data, status}) => {
if (data.ok) { if (data.ok) {
toast.success( toast.success(
`Successfully generated${data.valid ? ` ${data.valid}/${informations.length}` : ""} ${capitalize( `Successfully generated${data.valid ? ` ${data.valid}/${informations.length}` : ""} ${capitalize(
type, type,
)} codes and they have been notified by e-mail!`, )} codes and they have been notified by e-mail!`,
{ toastId: "success" }, {toastId: "success"},
); );
return; return;
} }
if (status === 403) { if (status === 403) {
toast.error(data.reason, { toastId: "forbidden" }); toast.error(data.reason, {toastId: "forbidden"});
} }
}) })
.catch(({ response: { status, data } }) => { .catch(({response: {status, data}}) => {
if (status === 403) { if (status === 403) {
toast.error(data.reason, { toastId: "forbidden" }); toast.error(data.reason, {toastId: "forbidden"});
return; return;
} }
@@ -193,30 +164,18 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
return ( return (
<> <>
<Modal <Modal isOpen={showHelp} onClose={() => setShowHelp(false)} title="Excel File Format">
isOpen={showHelp}
onClose={() => setShowHelp(false)}
title="Excel File Format"
>
<div className="mt-4 flex flex-col gap-2"> <div className="mt-4 flex flex-col gap-2">
<span>Please upload an Excel file with the following format:</span> <span>Please upload an Excel file with the following format:</span>
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr> <tr>
<th className="border border-neutral-200 px-2 py-1"> <th className="border border-neutral-200 px-2 py-1">First Name</th>
First Name <th className="border border-neutral-200 px-2 py-1">Last Name</th>
</th>
<th className="border border-neutral-200 px-2 py-1">
Last Name
</th>
<th className="border border-neutral-200 px-2 py-1">Country</th> <th className="border border-neutral-200 px-2 py-1">Country</th>
<th className="border border-neutral-200 px-2 py-1"> <th className="border border-neutral-200 px-2 py-1">Passport/National ID</th>
Passport/National ID
</th>
<th className="border border-neutral-200 px-2 py-1">E-mail</th> <th className="border border-neutral-200 px-2 py-1">E-mail</th>
<th className="border border-neutral-200 px-2 py-1"> <th className="border border-neutral-200 px-2 py-1">Phone Number</th>
Phone Number
</th>
</tr> </tr>
</thead> </thead>
</table> </table>
@@ -225,48 +184,27 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
<ul> <ul>
<li>- All incorrect e-mails will be ignored;</li> <li>- All incorrect e-mails will be ignored;</li>
<li>- All already registered e-mails will be ignored;</li> <li>- All already registered e-mails will be ignored;</li>
<li> <li>- You may have a header row with the format above, however, it is not necessary;</li>
- You may have a header row with the format above, however, it <li>- All of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below.</li>
is not necessary;
</li>
<li>
- All of the e-mails in the file will receive an e-mail to join
EnCoach with the role selected below.
</li>
</ul> </ul>
</span> </span>
</div> </div>
</Modal> </Modal>
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4"> <div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
<div className="flex items-end justify-between"> <div className="flex items-end justify-between">
<label className="text-mti-gray-dim text-base font-normal"> <label className="text-mti-gray-dim text-base font-normal">Choose an Excel file</label>
Choose an Excel file <div className="tooltip cursor-pointer" data-tip="Excel File Format" onClick={() => setShowHelp(true)}>
</label>
<div
className="tooltip cursor-pointer"
data-tip="Excel File Format"
onClick={() => setShowHelp(true)}
>
<BsQuestionCircleFill /> <BsQuestionCircleFill />
</div> </div>
</div> </div>
<Button <Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
onClick={openFilePicker}
isLoading={isLoading}
disabled={isLoading}
>
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"} {filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
</Button> </Button>
{user && (user.type === "developer" || user.type === "admin") && ( {user && (user.type === "developer" || user.type === "admin" || user.type === "corporate") && (
<> <>
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center"> <div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
<label className="text-mti-gray-dim text-base font-normal"> <label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
Expiry Date <Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}>
</label>
<Checkbox
isChecked={isExpiryDateEnabled}
onChange={setIsExpiryDateEnabled}
>
Enabled Enabled
</Checkbox> </Checkbox>
</div> </div>
@@ -277,7 +215,10 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
"hover:border-mti-purple tooltip", "hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
)} )}
filterDate={(date) => moment(date).isAfter(new Date())} filterDate={(date) =>
moment(date).isAfter(new Date()) &&
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
}
dateFormat="dd/MM/yyyy" dateFormat="dd/MM/yyyy"
selected={expiryDate} selected={expiryDate}
onChange={(date) => setExpiryDate(date)} onChange={(date) => setExpiryDate(date)}
@@ -285,19 +226,14 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
)} )}
</> </>
)} )}
<label className="text-mti-gray-dim text-base font-normal"> <label className="text-mti-gray-dim text-base font-normal">Select the type of user they should be</label>
Select the type of user they should be
</label>
{user && ( {user && (
<select <select
defaultValue="student" defaultValue="student"
onChange={(e) => setType(e.target.value as typeof user.type)} onChange={(e) => setType(e.target.value as typeof user.type)}
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none" className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none">
>
{Object.keys(USER_TYPE_LABELS) {Object.keys(USER_TYPE_LABELS)
.filter((x) => .filter((x) => USER_TYPE_PERMISSIONS[user.type].includes(x as Type))
USER_TYPE_PERMISSIONS[user.type].includes(x as Type),
)
.map((type) => ( .map((type) => (
<option key={type} value={type}> <option key={type} value={type}>
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]} {USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
@@ -305,12 +241,7 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
))} ))}
</select> </select>
)} )}
<Button <Button onClick={generateAndInvite} disabled={infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}>
onClick={generateAndInvite}
disabled={
infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)
}
>
Generate & Send Generate & Send
</Button> </Button>
</div> </div>

View File

@@ -23,16 +23,12 @@ const USER_TYPE_PERMISSIONS: {[key in Type]: Type[]} = {
export default function CodeGenerator({user}: {user: User}) { export default function CodeGenerator({user}: {user: User}) {
const [generatedCode, setGeneratedCode] = useState<string>(); const [generatedCode, setGeneratedCode] = useState<string>();
const [expiryDate, setExpiryDate] = useState<Date | null>(null); const [expiryDate, setExpiryDate] = useState<Date | null>(
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
);
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [type, setType] = useState<Type>("student"); const [type, setType] = useState<Type>("student");
useEffect(() => {
if (user && (user.type === "corporate" || user.type === "teacher")) {
setExpiryDate(user.subscriptionExpirationDate || null);
}
}, [user]);
useEffect(() => { useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null); if (!isExpiryDateEnabled) setExpiryDate(null);
}, [isExpiryDateEnabled]); }, [isExpiryDateEnabled]);
@@ -81,22 +77,25 @@ export default function CodeGenerator({user}: {user: User}) {
))} ))}
</select> </select>
)} )}
{user && (user.type === "developer" || user.type === "admin") && ( {user && (user.type === "developer" || user.type === "admin" || user.type === "corporate") && (
<> <>
<div className="flex -md:flex-row md:flex-col -md:items-center 2xl:flex-row 2xl:items-center justify-between gap-2"> <div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label> <label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled}> <Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}>
Enabled Enabled
</Checkbox> </Checkbox>
</div> </div>
{isExpiryDateEnabled && ( {isExpiryDateEnabled && (
<ReactDatePicker <ReactDatePicker
className={clsx( className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", "flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
"hover:border-mti-purple tooltip", "hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
)} )}
filterDate={(date) => moment(date).isAfter(new Date())} filterDate={(date) =>
moment(date).isAfter(new Date()) &&
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
}
dateFormat="dd/MM/yyyy" dateFormat="dd/MM/yyyy"
selected={expiryDate} selected={expiryDate}
onChange={(date) => setExpiryDate(date)} onChange={(date) => setExpiryDate(date)}

View File

@@ -0,0 +1,322 @@
import Button from "@/components/Low/Button";
import Checkbox from "@/components/Low/Checkbox";
import Select from "@/components/Low/Select";
import useCodes from "@/hooks/useCodes";
import useUser from "@/hooks/useUser";
import useUsers from "@/hooks/useUsers";
import { Code, User } from "@/interfaces/user";
import { USER_TYPE_LABELS } from "@/resources/user";
import {
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import axios from "axios";
import moment from "moment";
import { useEffect, useState } from "react";
import { BsTrash } from "react-icons/bs";
import { toast } from "react-toastify";
const columnHelper = createColumnHelper<Code>();
const CreatorCell = ({ id, users }: { id: string; users: User[] }) => {
const [creatorUser, setCreatorUser] = useState<User>();
useEffect(() => {
setCreatorUser(users.find((x) => x.id === id));
}, [id, users]);
return (
<>
{(creatorUser?.type === "corporate"
? creatorUser?.corporateInformation?.companyInformation?.name
: creatorUser?.name || "N/A") || "N/A"}{" "}
{creatorUser && `(${USER_TYPE_LABELS[creatorUser.type]})`}
</>
);
};
export default function CodeList({ user }: { user: User }) {
const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
const [filteredCorporate, setFilteredCorporate] = useState<User | undefined>(
user?.type === "corporate" ? user : undefined,
);
const [filterAvailability, setFilterAvailability] = useState<
"in-use" | "unused"
>();
const [filteredCodes, setFilteredCodes] = useState<Code[]>([]);
const { users } = useUsers();
const { codes, reload } = useCodes(
user?.type === "corporate" ? user?.id : undefined,
);
useEffect(() => {
let result = [...codes];
if (filteredCorporate)
result = result.filter((x) => x.creator === filteredCorporate.id);
if (filterAvailability)
result = result.filter((x) =>
filterAvailability === "in-use" ? !!x.userId : !x.userId,
);
setFilteredCodes(result);
}, [codes, filteredCorporate, filterAvailability]);
const toggleCode = (id: string) => {
setSelectedCodes((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
};
const toggleAllCodes = (checked: boolean) => {
if (checked)
return setSelectedCodes(
filteredCodes.filter((x) => !x.userId).map((x) => x.code),
);
return setSelectedCodes([]);
};
const deleteCodes = async (codes: string[]) => {
if (
!confirm(`Are you sure you want to delete these ${codes.length} code(s)?`)
)
return;
const params = new URLSearchParams();
codes.forEach((code) => params.append("code", code));
axios
.delete(`/api/code?${params.toString()}`)
.then(() => {
toast.success(`Deleted the codes!`);
setSelectedCodes([]);
})
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Code not found!");
return;
}
if (reason.response.status === 403) {
toast.error("You do not have permission to delete this code!");
return;
}
toast.error("Something went wrong, please try again later.");
})
.finally(reload);
};
const deleteCode = async (code: Code) => {
if (!confirm(`Are you sure you want to delete this "${code.code}" code?`))
return;
axios
.delete(`/api/code/${code.code}`)
.then(() => toast.success(`Deleted the "${code.code}" exam`))
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Code not found!");
return;
}
if (reason.response.status === 403) {
toast.error("You do not have permission to delete this code!");
return;
}
toast.error("Something went wrong, please try again later.");
})
.finally(reload);
};
const defaultColumns = [
columnHelper.accessor("code", {
id: "code",
header: () => (
<Checkbox
disabled={filteredCodes.filter((x) => !x.userId).length === 0}
isChecked={
selectedCodes.length ===
filteredCodes.filter((x) => !x.userId).length &&
filteredCodes.filter((x) => !x.userId).length > 0
}
onChange={(checked) => toggleAllCodes(checked)}
>
{""}
</Checkbox>
),
cell: (info) =>
!info.row.original.userId ? (
<Checkbox
isChecked={selectedCodes.includes(info.getValue())}
onChange={() => toggleCode(info.getValue())}
>
{""}
</Checkbox>
) : null,
}),
columnHelper.accessor("code", {
header: "Code",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("creationDate", {
header: "Creation Date",
cell: (info) =>
info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : "N/A",
}),
columnHelper.accessor("email", {
header: "Invited E-mail",
cell: (info) => info.getValue() || "N/A",
}),
columnHelper.accessor("creator", {
header: "Creator",
cell: (info) => <CreatorCell id={info.getValue()} users={users} />,
}),
columnHelper.accessor("userId", {
header: "Availability",
cell: (info) =>
info.getValue() ? (
<span className="flex gap-1 items-center text-mti-green">
<div className="w-2 h-2 rounded-full bg-mti-green" /> In Use
</span>
) : (
<span className="flex gap-1 items-center text-mti-red">
<div className="w-2 h-2 rounded-full bg-mti-red" /> Unused
</span>
),
}),
{
header: "",
id: "actions",
cell: ({ row }: { row: { original: Code } }) => {
return (
<div className="flex gap-4">
{!row.original.userId && (
<div
data-tip="Delete"
className="cursor-pointer tooltip"
onClick={() => deleteCode(row.original)}
>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
)}
</div>
);
},
},
];
const table = useReactTable({
data: filteredCodes,
columns: defaultColumns,
getCoreRowModel: getCoreRowModel(),
});
return (
<>
<div className="flex items-center justify-between pb-4 pt-1">
<div className="flex items-center gap-4">
<Select
className="!w-96 !py-1"
disabled={user?.type === "corporate"}
isClearable
placeholder="Corporate"
value={
filteredCorporate
? {
label: `${
filteredCorporate.type === "corporate"
? filteredCorporate.corporateInformation
?.companyInformation?.name || filteredCorporate.name
: filteredCorporate.name
} (${USER_TYPE_LABELS[filteredCorporate.type]})`,
value: filteredCorporate.id,
}
: null
}
options={users
.filter((x) =>
["admin", "developer", "corporate"].includes(x.type),
)
.map((x) => ({
label: `${x.type === "corporate" ? x.corporateInformation?.companyInformation?.name || x.name : x.name} (${
USER_TYPE_LABELS[x.type]
})`,
value: x.id,
user: x,
}))}
onChange={(value) =>
setFilteredCorporate(
value ? users.find((x) => x.id === value?.value) : undefined,
)
}
/>
<Select
className="!w-96 !py-1"
placeholder="Availability"
isClearable
options={[
{ label: "In Use", value: "in-use" },
{ label: "Unused", value: "unused" },
]}
onChange={(value) =>
setFilterAvailability(
value ? (value.value as typeof filterAvailability) : undefined,
)
}
/>
</div>
<div className="flex gap-4 items-center">
<span>{selectedCodes.length} code(s) selected</span>
<Button
disabled={selectedCodes.length === 0}
variant="outline"
color="red"
className="!py-1 px-10"
onClick={() => deleteCodes(selectedCodes)}
>
Delete
</Button>
</div>
</div>
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="p-4 text-left" key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
key={row.id}
>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</>
);
}

View File

@@ -0,0 +1,342 @@
import Button from "@/components/Low/Button";
import Checkbox from "@/components/Low/Checkbox";
import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select";
import Modal from "@/components/Modal";
import useCodes from "@/hooks/useCodes";
import useDiscounts from "@/hooks/useDiscounts";
import useUser from "@/hooks/useUser";
import useUsers from "@/hooks/useUsers";
import { Discount } from "@/interfaces/paypal";
import { Code, User } from "@/interfaces/user";
import { USER_TYPE_LABELS } from "@/resources/user";
import {
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import axios from "axios";
import moment from "moment";
import { useEffect, useState } from "react";
import { BsPencil, BsTrash } from "react-icons/bs";
import { toast } from "react-toastify";
const columnHelper = createColumnHelper<Discount>();
const DiscountCreator = ({
discount,
onClose,
}: {
discount?: Discount;
onClose: () => void;
}) => {
const [percentage, setPercentage] = useState(discount?.percentage);
const [domain, setDomain] = useState(discount?.domain);
const submit = async () => {
const body = { percentage, domain };
if (discount) {
return axios
.patch(`/api/discounts/${discount.id}`, body)
.then(() => {
toast.success("Discount has been edited successfully!");
onClose();
})
.catch(() => {
toast.error("Something went wrong, please try again later!");
});
}
return axios
.post(`/api/discounts`, body)
.then(() => {
toast.success("New discount has been created successfully!");
onClose();
})
.catch(() => {
toast.error("Something went wrong, please try again later!");
});
};
return (
<div className="flex flex-col gap-8 py-8">
<div className="w-full grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">
Domain *
</label>
<div className="flex gap-4 items-center">
<Input
defaultValue={domain}
placeholder="encoach.com"
name="domain"
type="text"
onChange={(e) => setDomain(e.replaceAll("@", ""))}
/>
</div>
</div>
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">
Percentage (in %) *
</label>
<div className="flex gap-4 items-center">
<Input
defaultValue={percentage}
placeholder="20"
name="percentage"
type="number"
onChange={(e) => setPercentage(parseFloat(e))}
/>
</div>
</div>
</div>
<div className="flex w-full justify-end items-center gap-8 mt-8">
<Button
variant="outline"
color="red"
className="w-full max-w-[200px]"
onClick={onClose}
>
Cancel
</Button>
<Button
className="w-full max-w-[200px]"
onClick={submit}
disabled={!percentage || !domain}
>
Submit
</Button>
</div>
</div>
);
};
export default function DiscountList({ user }: { user: User }) {
const [selectedDiscounts, setSelectedDiscounts] = useState<string[]>([]);
const [isCreating, setIsCreating] = useState(false);
const [editingDiscount, setEditingDiscount] = useState<Discount>();
const [filteredDiscounts, setFilteredDiscounts] = useState<Discount[]>([]);
const { users } = useUsers();
const { discounts, reload } = useDiscounts();
useEffect(() => {
setFilteredDiscounts(discounts);
}, [discounts]);
const toggleDiscount = (id: string) => {
setSelectedDiscounts((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
};
const toggleAllDiscounts = (checked: boolean) => {
if (checked)
return setSelectedDiscounts(filteredDiscounts.map((x) => x.id));
return setSelectedDiscounts([]);
};
const deleteDiscounts = async (discounts: string[]) => {
if (
!confirm(
`Are you sure you want to delete these ${discounts.length} discount(s)?`,
)
)
return;
const params = new URLSearchParams();
discounts.forEach((code) => params.append("discount", code));
axios
.delete(`/api/discounts?${params.toString()}`)
.then(() => toast.success(`Deleted the discount(s)!`))
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Discount not found!");
return;
}
if (reason.response.status === 403) {
toast.error("You do not have permission to delete this discount!");
return;
}
toast.error("Something went wrong, please try again later.");
})
.finally(reload);
};
const deleteDiscount = async (discount: Discount) => {
if (
!confirm(
`Are you sure you want to delete this "${discount.id}" discount?`,
)
)
return;
axios
.delete(`/api/discounts/${discount.id}`)
.then(() => toast.success(`Deleted the "${discount.id}" discount`))
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Code not found!");
return;
}
if (reason.response.status === 403) {
toast.error("You do not have permission to delete this discount!");
return;
}
toast.error("Something went wrong, please try again later.");
})
.finally(reload);
};
const defaultColumns = [
columnHelper.accessor("id", {
id: "id",
header: () => (
<Checkbox
disabled={filteredDiscounts.length === 0}
isChecked={
selectedDiscounts.length === filteredDiscounts.length &&
filteredDiscounts.length > 0
}
onChange={(checked) => toggleAllDiscounts(checked)}
>
{""}
</Checkbox>
),
cell: (info) => (
<Checkbox
isChecked={selectedDiscounts.includes(info.getValue())}
onChange={() => toggleDiscount(info.getValue())}
>
{""}
</Checkbox>
),
}),
columnHelper.accessor("id", {
header: "ID",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("domain", {
header: "Domain",
cell: (info) => `@${info.getValue()}`,
}),
columnHelper.accessor("percentage", {
header: "Percentage",
cell: (info) => `${info.getValue()}%`,
}),
{
header: "",
id: "actions",
cell: ({ row }: { row: { original: Discount } }) => {
return (
<div className="flex gap-4">
<div
data-tip="Delete"
className="cursor-pointer tooltip"
onClick={() => {
setEditingDiscount(row.original);
}}
>
<BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
<div
data-tip="Delete"
className="cursor-pointer tooltip"
onClick={() => deleteDiscount(row.original)}
>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
</div>
);
},
},
];
const table = useReactTable({
data: filteredDiscounts,
columns: defaultColumns,
getCoreRowModel: getCoreRowModel(),
});
const closeModal = () => {
setIsCreating(false);
setEditingDiscount(undefined);
reload();
};
return (
<>
<Modal
isOpen={isCreating || !!editingDiscount}
onClose={closeModal}
title={
editingDiscount ? `Editing ${editingDiscount.id}` : "New Discount"
}
>
<DiscountCreator onClose={closeModal} discount={editingDiscount} />
</Modal>
<div className="flex items-center justify-end pb-4 pt-1">
<div className="flex gap-4 items-center">
<span>{selectedDiscounts.length} code(s) selected</span>
<Button
disabled={selectedDiscounts.length === 0}
variant="outline"
color="red"
className="!py-1 px-10"
onClick={() => deleteDiscounts(selectedDiscounts)}
>
Delete
</Button>
</div>
</div>
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="p-4 text-left" key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
key={row.id}
>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
<button
onClick={() => setIsCreating(true)}
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white"
>
New Discount
</button>
</>
);
}

View File

@@ -3,26 +3,44 @@ import Input from "@/components/Low/Input";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import { Group, User } from "@/interfaces/user"; import {CorporateUser, Group, User} from "@/interfaces/user";
import { import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import axios from "axios"; import axios from "axios";
import { capitalize, uniq } from "lodash"; import {capitalize, uniq} from "lodash";
import { useEffect, useState } from "react"; import {useEffect, useState} from "react";
import { BsPencil, BsQuestionCircleFill, BsTrash } from "react-icons/bs"; import {BsPencil, BsQuestionCircleFill, BsTrash} from "react-icons/bs";
import Select from "react-select"; import Select from "react-select";
import { toast } from "react-toastify"; import {toast} from "react-toastify";
import readXlsxFile from "read-excel-file"; import readXlsxFile from "read-excel-file";
import { useFilePicker } from "use-file-picker"; import {useFilePicker} from "use-file-picker";
import {getUserCorporate} from "@/utils/groups";
import { isAgentUser, isCorporateUser } from "@/resources/user";
const columnHelper = createColumnHelper<Group>(); const columnHelper = createColumnHelper<Group>();
const EMAIL_REGEX = new RegExp( const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/,
); const LinkedCorporate = ({userId, users, groups}: {userId: string, users: User[], groups: Group[]}) => {
const [companyName, setCompanyName] = useState("");
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const user = users.find((u) => u.id === userId)
if (!user) return setCompanyName("")
if (isCorporateUser(user)) return setCompanyName(user.corporateInformation?.companyInformation?.name || user.name)
if (isAgentUser(user)) return setCompanyName(user.agentInformation?.companyName || user.name)
const belongingGroups = groups.filter((x) => x.participants.includes(userId))
const belongingGroupsAdmins = belongingGroups.map((x) => users.find((u) => u.id === x.admin)).filter((x) => !!x && isCorporateUser(x))
if (belongingGroupsAdmins.length === 0) return setCompanyName("")
const admin = (belongingGroupsAdmins[0] as CorporateUser)
setCompanyName(admin.corporateInformation?.companyInformation.name || admin.name)
}, [userId, users, groups]);
return isLoading ? <span className="animate-pulse">Loading...</span> : <>{companyName}</>;
};
interface CreateDialogProps { interface CreateDialogProps {
user: User; user: User;
@@ -31,17 +49,13 @@ interface CreateDialogProps {
onClose: () => void; onClose: () => void;
} }
const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => { const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
const [name, setName] = useState<string | undefined>( const [name, setName] = useState<string | undefined>(group?.name || undefined);
group?.name || undefined,
);
const [admin, setAdmin] = useState<string>(group?.admin || user.id); const [admin, setAdmin] = useState<string>(group?.admin || user.id);
const [participants, setParticipants] = useState<string[]>( const [participants, setParticipants] = useState<string[]>(group?.participants || []);
group?.participants || [],
);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { openFilePicker, filesContent, clear } = useFilePicker({ const {openFilePicker, filesContent, clear} = useFilePicker({
accept: ".xlsx", accept: ".xlsx",
multiple: false, multiple: false,
readAs: "ArrayBuffer", readAs: "ArrayBuffer",
@@ -57,10 +71,7 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
rows rows
.map((row) => { .map((row) => {
const [email] = row as string[]; const [email] = row as string[];
return EMAIL_REGEX.test(email) && return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email) ? email.toString().trim() : undefined;
!users.map((u) => u.email).includes(email)
? email.toString().trim()
: undefined;
}) })
.filter((x) => !!x), .filter((x) => !!x),
); );
@@ -72,14 +83,10 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
return; return;
} }
const emailUsers = [...new Set(emails)] const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined);
.map((x) => users.find((y) => y.email.toLowerCase() === x))
.filter((x) => x !== undefined);
const filteredUsers = emailUsers.filter( const filteredUsers = emailUsers.filter(
(x) => (x) =>
((user.type === "developer" || ((user.type === "developer" || user.type === "admin" || user.type === "corporate") &&
user.type === "admin" ||
user.type === "corporate") &&
(x?.type === "student" || x?.type === "teacher")) || (x?.type === "student" || x?.type === "teacher")) ||
(user.type === "teacher" && x?.type === "student"), (user.type === "teacher" && x?.type === "student"),
); );
@@ -89,7 +96,7 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
user.type !== "teacher" user.type !== "teacher"
? "Added all teachers and students found in the file you've provided!" ? "Added all teachers and students found in the file you've provided!"
: "Added all students found in the file you've provided!", : "Added all students found in the file you've provided!",
{ toastId: "upload-success" }, {toastId: "upload-success"},
); );
setIsLoading(false); setIsLoading(false);
}); });
@@ -101,21 +108,14 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
setIsLoading(true); setIsLoading(true);
if (name !== group?.name && (name === "Students" || name === "Teachers")) { if (name !== group?.name && (name === "Students" || name === "Teachers")) {
toast.error( toast.error("That group name is reserved and cannot be used, please enter another one.");
"That group name is reserved and cannot be used, please enter another one.",
);
setIsLoading(false); setIsLoading(false);
return; return;
} }
(group ? axios.patch : axios.post)( (group ? axios.patch : axios.post)(group ? `/api/groups/${group.id}` : "/api/groups", {name, admin, participants})
group ? `/api/groups/${group.id}` : "/api/groups",
{ name, admin, participants },
)
.then(() => { .then(() => {
toast.success( toast.success(`Group "${name}" ${group ? "edited" : "created"} successfully`);
`Group "${name}" ${group ? "edited" : "created"} successfully`,
);
return true; return true;
}) })
.catch(() => { .catch(() => {
@@ -131,24 +131,11 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
return ( return (
<div className="mt-4 flex w-full flex-col gap-12 px-4 py-2"> <div className="mt-4 flex w-full flex-col gap-12 px-4 py-2">
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<Input <Input name="name" type="text" label="Name" defaultValue={name} onChange={setName} required disabled={group?.disableEditing} />
name="name"
type="text"
label="Name"
defaultValue={name}
onChange={setName}
required
disabled={group?.disableEditing}
/>
<div className="flex w-full flex-col gap-3"> <div className="flex w-full flex-col gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-mti-gray-dim text-base font-normal"> <label className="text-mti-gray-dim text-base font-normal">Participants</label>
Participants <div className="tooltip" data-tip="The Excel file should only include a column with the desired e-mails.">
</label>
<div
className="tooltip"
data-tip="The Excel file should only include a column with the desired e-mails."
>
<BsQuestionCircleFill /> <BsQuestionCircleFill />
</div> </div>
</div> </div>
@@ -165,18 +152,14 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`, label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
}))} }))}
options={users options={users
.filter((x) => .filter((x) => (user.type === "teacher" ? x.type === "student" : x.type === "student" || x.type === "teacher"))
user.type === "teacher" .map((x) => ({value: x.id, label: `${x.email} - ${x.name}`}))}
? x.type === "student"
: x.type === "student" || x.type === "teacher",
)
.map((x) => ({ value: x.id, label: `${x.email} - ${x.name}` }))}
onChange={(value) => setParticipants(value.map((x) => x.value))} onChange={(value) => setParticipants(value.map((x) => x.value))}
isMulti isMulti
isSearchable isSearchable
menuPortalTarget={document?.body} menuPortalTarget={document?.body}
styles={{ styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }), menuPortal: (base) => ({...base, zIndex: 9999}),
control: (styles) => ({ control: (styles) => ({
...styles, ...styles,
backgroundColor: "white", backgroundColor: "white",
@@ -187,36 +170,18 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
}} }}
/> />
{user.type !== "teacher" && ( {user.type !== "teacher" && (
<Button <Button className="w-full max-w-[300px]" onClick={openFilePicker} isLoading={isLoading} variant="outline">
className="w-full max-w-[300px]" {filesContent.length === 0 ? "Upload participants Excel file" : filesContent[0].name}
onClick={openFilePicker}
isLoading={isLoading}
variant="outline"
>
{filesContent.length === 0
? "Upload participants Excel file"
: filesContent[0].name}
</Button> </Button>
)} )}
</div> </div>
</div> </div>
</div> </div>
<div className="mt-8 flex w-full items-center justify-end gap-8"> <div className="mt-8 flex w-full items-center justify-end gap-8">
<Button <Button variant="outline" color="red" className="w-full max-w-[200px]" isLoading={isLoading} onClick={onClose}>
variant="outline"
color="red"
className="w-full max-w-[200px]"
isLoading={isLoading}
onClick={onClose}
>
Cancel Cancel
</Button> </Button>
<Button <Button className="w-full max-w-[200px]" onClick={submit} isLoading={isLoading} disabled={!name}>
className="w-full max-w-[200px]"
onClick={submit}
isLoading={isLoading}
disabled={!name}
>
Submit Submit
</Button> </Button>
</div> </div>
@@ -226,15 +191,13 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
const filterTypes = ["corporate", "teacher"]; const filterTypes = ["corporate", "teacher"];
export default function GroupList({ user }: { user: User }) { export default function GroupList({user}: {user: User}) {
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [editingGroup, setEditingGroup] = useState<Group>(); const [editingGroup, setEditingGroup] = useState<Group>();
const [filterByUser, setFilterByUser] = useState(false); const [filterByUser, setFilterByUser] = useState(false);
const { users } = useUsers(); const {users} = useUsers();
const { groups, reload } = useGroups( const {groups, reload} = useGroups(user && filterTypes.includes(user?.type) ? user.id : undefined);
user && filterTypes.includes(user?.type) ? user.id : undefined,
);
useEffect(() => { useEffect(() => {
if (user && (user.type === "corporate" || user.type === "teacher")) { if (user && (user.type === "corporate" || user.type === "teacher")) {
@@ -246,7 +209,7 @@ export default function GroupList({ user }: { user: User }) {
if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return; if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return;
axios axios
.delete<{ ok: boolean }>(`/api/groups/${group.id}`) .delete<{ok: boolean}>(`/api/groups/${group.id}`)
.then(() => toast.success(`Group "${group.name}" deleted successfully`)) .then(() => toast.success(`Group "${group.name}" deleted successfully`))
.catch(() => toast.error("Something went wrong, please try again later!")) .catch(() => toast.error("Something went wrong, please try again later!"))
.finally(reload); .finally(reload);
@@ -264,16 +227,15 @@ export default function GroupList({ user }: { user: User }) {
columnHelper.accessor("admin", { columnHelper.accessor("admin", {
header: "Admin", header: "Admin",
cell: (info) => ( cell: (info) => (
<div <div className="tooltip" data-tip={capitalize(users.find((x) => x.id === info.getValue())?.type)}>
className="tooltip"
data-tip={capitalize(
users.find((x) => x.id === info.getValue())?.type,
)}
>
{users.find((x) => x.id === info.getValue())?.name} {users.find((x) => x.id === info.getValue())?.name}
</div> </div>
), ),
}), }),
columnHelper.accessor("admin", {
header: "Linked Corporate",
cell: (info) => <LinkedCorporate userId={info.getValue()} users={users} groups={groups} />,
}),
columnHelper.accessor("participants", { columnHelper.accessor("participants", {
header: "Participants", header: "Participants",
cell: (info) => cell: (info) =>
@@ -285,31 +247,18 @@ export default function GroupList({ user }: { user: User }) {
{ {
header: "", header: "",
id: "actions", id: "actions",
cell: ({ row }: { row: { original: Group } }) => { cell: ({row}: {row: {original: Group}}) => {
return ( return (
<> <>
{user && {user && (user.type === "developer" || user.type === "admin" || user.id === row.original.admin) && (
(user.type === "developer" ||
user.type === "admin" ||
user.id === row.original.admin) && (
<div className="flex gap-2"> <div className="flex gap-2">
{(!row.original.disableEditing || {(!row.original.disableEditing || ["developer", "admin"].includes(user.type)) && (
["developer", "admin"].includes(user.type)) && ( <div data-tip="Edit" className="tooltip cursor-pointer" onClick={() => setEditingGroup(row.original)}>
<div
data-tip="Edit"
className="tooltip cursor-pointer"
onClick={() => setEditingGroup(row.original)}
>
<BsPencil className="hover:text-mti-purple-light transition duration-300 ease-in-out" /> <BsPencil className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
</div> </div>
)} )}
{(!row.original.disableEditing || {(!row.original.disableEditing || ["developer", "admin"].includes(user.type)) && (
["developer", "admin"].includes(user.type)) && ( <div data-tip="Delete" className="tooltip cursor-pointer" onClick={() => deleteGroup(row.original)}>
<div
data-tip="Delete"
className="tooltip cursor-pointer"
onClick={() => deleteGroup(row.original)}
>
<BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" /> <BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
</div> </div>
)} )}
@@ -335,11 +284,7 @@ export default function GroupList({ user }: { user: User }) {
return ( return (
<div className="h-full w-full rounded-xl"> <div className="h-full w-full rounded-xl">
<Modal <Modal isOpen={isCreating || !!editingGroup} onClose={closeModal} title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}>
isOpen={isCreating || !!editingGroup}
onClose={closeModal}
title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}
>
<CreatePanel <CreatePanel
group={editingGroup} group={editingGroup}
user={user} user={user}
@@ -351,8 +296,7 @@ export default function GroupList({ user }: { user: User }) {
groups groups
.filter((g) => g.admin === user.id) .filter((g) => g.admin === user.id)
.flatMap((g) => g.participants) .flatMap((g) => g.participants)
.includes(u.id) || .includes(u.id) || groups.flatMap((g) => g.participants).includes(u.id),
groups.flatMap((g) => g.participants).includes(u.id),
) )
: users : users
} }
@@ -364,12 +308,7 @@ export default function GroupList({ user }: { user: User }) {
<tr key={headerGroup.id}> <tr key={headerGroup.id}>
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => (
<th className="py-4" key={header.id}> <th className="py-4" key={header.id}>
{header.isPlaceholder {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th> </th>
))} ))}
</tr> </tr>
@@ -377,10 +316,7 @@ export default function GroupList({ user }: { user: User }) {
</thead> </thead>
<tbody className="px-2"> <tbody className="px-2">
{table.getRowModel().rows.map((row) => ( {table.getRowModel().rows.map((row) => (
<tr <tr className="even:bg-mti-purple-ultralight/40 rounded-lg py-2 odd:bg-white" key={row.id}>
className="even:bg-mti-purple-ultralight/40 rounded-lg py-2 odd:bg-white"
key={row.id}
>
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}> <td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
@@ -393,8 +329,7 @@ export default function GroupList({ user }: { user: User }) {
<button <button
onClick={() => setIsCreating(true)} onClick={() => setIsCreating(true)}
className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out" className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out">
>
New Group New Group
</button> </button>
</div> </div>

View File

@@ -2,7 +2,7 @@ import Button from "@/components/Low/Button";
import {PERMISSIONS} from "@/constants/userPermissions"; import {PERMISSIONS} from "@/constants/userPermissions";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import {Type, User, userTypes, CorporateUser} from "@/interfaces/user"; import {Type, User, userTypes, CorporateUser, Group} from "@/interfaces/user";
import {Popover, Transition} from "@headlessui/react"; import {Popover, Transition} from "@headlessui/react";
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table"; import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
import axios from "axios"; import axios from "axios";
@@ -16,18 +16,30 @@ import {countries, TCountries} from "countries-list";
import countryCodes from "country-codes-list"; import countryCodes from "country-codes-list";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import UserCard from "@/components/UserCard"; import UserCard from "@/components/UserCard";
import {USER_TYPE_LABELS} from "@/resources/user"; import {getUserCompanyName, isAgentUser, USER_TYPE_LABELS} from "@/resources/user";
import useFilterStore from "@/stores/listFilterStore"; import useFilterStore from "@/stores/listFilterStore";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {isCorporateUser} from '@/resources/user'; import {isCorporateUser} from "@/resources/user";
import { useListSearch } from "@/hooks/useListSearch"; import {useListSearch} from "@/hooks/useListSearch";
import {getUserCorporate} from "@/utils/groups";
import {asyncSorter} from "@/utils";
import {exportListToExcel, UserListRow} from "@/utils/users";
const columnHelper = createColumnHelper<User>(); const columnHelper = createColumnHelper<User>();
const searchFields = [ const searchFields = [["name"], ["email"], ["corporateInformation", "companyInformation", "name"]];
['name'],
['email'], const CompanyNameCell = ({users, user, groups}: {user: User; users: User[]; groups: Group[]}) => {
['corporateInformation', 'companyInformation', 'name'], const [companyName, setCompanyName] = useState("");
]; const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const name = getUserCompanyName(user, users, groups);
setCompanyName(name);
}, [user, users, groups]);
return isLoading ? <span className="animate-pulse">Loading...</span> : <>{companyName}</>;
};
export default function UserList({user, filters = []}: {user: User; filters?: ((user: User) => boolean)[]}) { export default function UserList({user, filters = []}: {user: User; filters?: ((user: User) => boolean)[]}) {
const [showDemographicInformation, setShowDemographicInformation] = useState(false); const [showDemographicInformation, setShowDemographicInformation] = useState(false);
const [sorter, setSorter] = useState<string>(); const [sorter, setSorter] = useState<string>();
@@ -51,6 +63,7 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
}; };
useEffect(() => { useEffect(() => {
(async () => {
if (user && users) { if (user && users) {
const filterUsers = const filterUsers =
user.type === "corporate" || user.type === "teacher" user.type === "corporate" || user.type === "teacher"
@@ -58,9 +71,12 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
: users; : users;
const filteredUsers = filters.reduce((d, f) => d.filter(f), filterUsers); const filteredUsers = filters.reduce((d, f) => d.filter(f), filterUsers);
const sortedUsers = await asyncSorter<User>(filteredUsers, sortFunction);
console.log(sortedUsers);
setDisplayUsers([...filteredUsers.sort(sortFunction)]); setDisplayUsers([...sortedUsers]);
} }
})();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, users, sorter, groups]); }, [user, users, sorter, groups]);
@@ -331,14 +347,14 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
) as any, ) as any,
cell: (info) => USER_TYPE_LABELS[info.getValue()], cell: (info) => USER_TYPE_LABELS[info.getValue()],
}), }),
columnHelper.accessor('corporateInformation.companyInformation.name', { columnHelper.accessor("corporateInformation.companyInformation.name", {
header: ( header: (
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "companyName"))}> <button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "companyName"))}>
<span>Company Name</span> <span>Company Name</span>
<SorterArrow name="companyName" /> <SorterArrow name="companyName" />
</button> </button>
) as any, ) as any,
cell: (info) => getCorporateName(info.row.original), cell: (info) => <CompanyNameCell user={info.row.original} users={users} groups={groups} />,
}), }),
columnHelper.accessor("subscriptionExpirationDate", { columnHelper.accessor("subscriptionExpirationDate", {
header: ( header: (
@@ -393,15 +409,7 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
return undefined; return undefined;
}; };
const getCorporateName = (user: User) => { const sortFunction = async (a: User, b: User) => {
if(isCorporateUser(user)) {
return user.corporateInformation?.companyInformation?.name
}
return '';
}
const sortFunction = (a: User, b: User) => {
if (sorter === "name" || sorter === reverseString("name")) if (sorter === "name" || sorter === reverseString("name"))
return sorter === "name" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name); return sorter === "name" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name);
@@ -468,25 +476,20 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
: b.demographicInformation!.gender.localeCompare(a.demographicInformation!.gender); : b.demographicInformation!.gender.localeCompare(a.demographicInformation!.gender);
} }
if(sorter === 'companyName' || sorter === reverseString('companyName')) { if (sorter === "companyName" || sorter === reverseString("companyName")) {
const aCorporateName = getCorporateName(a); const aCorporateName = getUserCompanyName(a, users, groups);
const bCorporateName = getCorporateName(b); const bCorporateName = getUserCompanyName(b, users, groups);
if (!aCorporateName && bCorporateName) return sorter === "companyName" ? -1 : 1; if (!aCorporateName && bCorporateName) return sorter === "companyName" ? -1 : 1;
if (aCorporateName && !bCorporateName) return sorter === "companyName" ? 1 : -1; if (aCorporateName && !bCorporateName) return sorter === "companyName" ? 1 : -1;
if (!aCorporateName && !bCorporateName) return 0; if (!aCorporateName && !bCorporateName) return 0;
return sorter === "companyName" return sorter === "companyName" ? aCorporateName.localeCompare(bCorporateName) : bCorporateName.localeCompare(aCorporateName);
? aCorporateName.localeCompare(bCorporateName)
: bCorporateName.localeCompare(aCorporateName);
} }
return a.id.localeCompare(b.id); return a.id.localeCompare(b.id);
}; };
const { rows: filteredRows, renderSearch } = useListSearch( const {rows: filteredRows, renderSearch} = useListSearch<User>(searchFields, displayUsers);
searchFields,
displayUsers,
)
const table = useReactTable({ const table = useReactTable({
data: filteredRows, data: filteredRows,
@@ -494,6 +497,18 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}); });
const downloadExcel = () => {
const csv = exportListToExcel(filteredRows, users, groups);
const element = document.createElement("a");
const file = new Blob([csv], {type: "text/plain"});
element.href = URL.createObjectURL(file);
element.download = "users.xlsx";
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
};
return ( return (
<div className="w-full"> <div className="w-full">
<Modal isOpen={!!selectedUser} onClose={() => setSelectedUser(undefined)}> <Modal isOpen={!!selectedUser} onClose={() => setSelectedUser(undefined)}>
@@ -573,7 +588,12 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
</> </>
</Modal> </Modal>
<div className="w-full flex flex-col gap-2"> <div className="w-full flex flex-col gap-2">
<div className="w-full flex gap-2 items-end">
{renderSearch()} {renderSearch()}
<Button className="w-full max-w-[200px] mb-1" variant="outline" onClick={downloadExcel}>
Download List
</Button>
</div>
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full"> <table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead> <thead>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (

View File

@@ -1,63 +1,109 @@
import {User} from "@/interfaces/user"; import { User } from "@/interfaces/user";
import {Tab} from "@headlessui/react"; import { Tab } from "@headlessui/react";
import clsx from "clsx"; import clsx from "clsx";
import CodeList from "./CodeList";
import DiscountList from "./DiscountList";
import ExamList from "./ExamList"; import ExamList from "./ExamList";
import GroupList from "./GroupList"; import GroupList from "./GroupList";
import PackageList from "./PackageList"; import PackageList from "./PackageList";
import UserList from "./UserList"; import UserList from "./UserList";
export default function Lists({user}: {user: User}) { export default function Lists({ user }: { user: User }) {
return ( return (
<Tab.Group> <Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-mti-purple-ultralight/40 p-1"> <Tab.List className="flex space-x-1 rounded-xl bg-mti-purple-ultralight/40 p-1">
<Tab <Tab
className={({selected}) => className={({ selected }) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark", selected
? "bg-white shadow"
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
) )
}> }
>
User List User List
</Tab> </Tab>
{user?.type === "developer" && ( {user?.type === "developer" && (
<Tab <Tab
className={({selected}) => className={({ selected }) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark", selected
? "bg-white shadow"
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
) )
}> }
>
Exam List Exam List
</Tab> </Tab>
)} )}
<Tab <Tab
className={({selected}) => className={({ selected }) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark", selected
? "bg-white shadow"
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
) )
}> }
>
Group List Group List
</Tab> </Tab>
{user && ["developer", "admin"].includes(user.type) && ( {user && ["developer", "admin", "corporate"].includes(user.type) && (
<Tab <Tab
className={({selected}) => className={({ selected }) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark", selected
? "bg-white shadow"
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
) )
}> }
>
Code List
</Tab>
)}
{user && ["developer", "admin"].includes(user.type) && (
<Tab
className={({ selected }) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected
? "bg-white shadow"
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
)
}
>
Package List Package List
</Tab> </Tab>
)} )}
{user && ["developer", "admin"].includes(user.type) && (
<Tab
className={({ selected }) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected
? "bg-white shadow"
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
)
}
>
Discount List
</Tab>
)}
</Tab.List> </Tab.List>
<Tab.Panels className="mt-2"> <Tab.Panels className="mt-2">
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide"> <Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
@@ -71,11 +117,21 @@ export default function Lists({user}: {user: User}) {
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide"> <Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<GroupList user={user} /> <GroupList user={user} />
</Tab.Panel> </Tab.Panel>
{user && ["developer", "admin", "corporate"].includes(user.type) && (
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<CodeList user={user} />
</Tab.Panel>
)}
{user && ["developer", "admin"].includes(user.type) && ( {user && ["developer", "admin"].includes(user.type) && (
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide"> <Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<PackageList user={user} /> <PackageList user={user} />
</Tab.Panel> </Tab.Panel>
)} )}
{user && ["developer", "admin"].includes(user.type) && (
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<DiscountList user={user} />
</Tab.Panel>
)}
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
); );

View File

@@ -1,6 +1,6 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import {Module} from "@/interfaces"; import { Module } from "@/interfaces";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import AbandonPopup from "@/components/AbandonPopup"; import AbandonPopup from "@/components/AbandonPopup";
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
@@ -12,15 +12,18 @@ import Selection from "@/exams/Selection";
import Speaking from "@/exams/Speaking"; import Speaking from "@/exams/Speaking";
import Writing from "@/exams/Writing"; import Writing from "@/exams/Writing";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
import {Exam, UserSolution, Variant} from "@/interfaces/exam"; import { Exam, UserSolution, Variant } from "@/interfaces/exam";
import {Stat} from "@/interfaces/user"; import { Stat } from "@/interfaces/user";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {evaluateSpeakingAnswer, evaluateWritingAnswer} from "@/utils/evaluation"; import {
import {defaultExamUserSolutions, getExam} from "@/utils/exams"; evaluateSpeakingAnswer,
evaluateWritingAnswer,
} from "@/utils/evaluation";
import { defaultExamUserSolutions, getExam } from "@/utils/exams";
import axios from "axios"; import axios from "axios";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import {toast, ToastContainer} from "react-toastify"; import { toast, ToastContainer } from "react-toastify";
import {v4 as uuidv4} from "uuid"; import { v4 as uuidv4 } from "uuid";
import useSessions from "@/hooks/useSessions"; import useSessions from "@/hooks/useSessions";
import ShortUniqueId from "short-unique-id"; import ShortUniqueId from "short-unique-id";
@@ -28,31 +31,37 @@ interface Props {
page: "exams" | "exercises"; page: "exams" | "exercises";
} }
export default function ExamPage({page}: Props) { export default function ExamPage({ page }: Props) {
const [variant, setVariant] = useState<Variant>("full"); const [variant, setVariant] = useState<Variant>("full");
const [avoidRepeated, setAvoidRepeated] = useState(false); const [avoidRepeated, setAvoidRepeated] = useState(false);
const [hasBeenUploaded, setHasBeenUploaded] = useState(false); const [hasBeenUploaded, setHasBeenUploaded] = useState(false);
const [showAbandonPopup, setShowAbandonPopup] = useState(false); const [showAbandonPopup, setShowAbandonPopup] = useState(false);
const [isEvaluationLoading, setIsEvaluationLoading] = useState(false); const [isEvaluationLoading, setIsEvaluationLoading] = useState(false);
const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<string[]>([]); const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<
string[]
>([]);
const [timeSpent, setTimeSpent] = useState(0); const [timeSpent, setTimeSpent] = useState(0);
const resetStore = useExamStore((state) => state.reset); const resetStore = useExamStore((state) => state.reset);
const assignment = useExamStore((state) => state.assignment); const assignment = useExamStore((state) => state.assignment);
const initialTimeSpent = useExamStore((state) => state.timeSpent); const initialTimeSpent = useExamStore((state) => state.timeSpent);
const {exam, setExam} = useExamStore((state) => state); const examStore = useExamStore;
const {exams, setExams} = useExamStore((state) => state);
const {sessionId, setSessionId} = useExamStore((state) => state);
const {partIndex, setPartIndex} = useExamStore((state) => state);
const {moduleIndex, setModuleIndex} = useExamStore((state) => state);
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
const {showSolutions, setShowSolutions} = useExamStore((state) => state);
const {selectedModules, setSelectedModules} = useExamStore((state) => state);
const {user} = useUser({redirectTo: "/login"}); const { exam, setExam } = useExamStore((state) => state);
const { exams, setExams } = useExamStore((state) => state);
const { sessionId, setSessionId } = useExamStore((state) => state);
const { partIndex, setPartIndex } = useExamStore((state) => state);
const { moduleIndex, setModuleIndex } = useExamStore((state) => state);
const { questionIndex, setQuestionIndex } = useExamStore((state) => state);
const { exerciseIndex, setExerciseIndex } = useExamStore((state) => state);
const { userSolutions, setUserSolutions } = useExamStore((state) => state);
const { showSolutions, setShowSolutions } = useExamStore((state) => state);
const { selectedModules, setSelectedModules } = useExamStore(
(state) => state,
);
const { user } = useUser({ redirectTo: "/login" });
const router = useRouter(); const router = useRouter();
const reset = () => { const reset = () => {
@@ -88,7 +97,10 @@ export default function ExamPage({page}: Props) {
}); });
}; };
useEffect(() => setTimeSpent((prev) => prev + initialTimeSpent), [initialTimeSpent]); useEffect(
() => setTimeSpent((prev) => prev + initialTimeSpent),
[initialTimeSpent],
);
useEffect(() => { useEffect(() => {
if (userSolutions.length === 0 && exams.length > 0) { if (userSolutions.length === 0 && exams.length > 0) {
@@ -110,10 +122,28 @@ export default function ExamPage({page}: Props) {
) )
saveSession(); saveSession();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exam, exams, moduleIndex, selectedModules, sessionId, userSolutions, user, exerciseIndex, partIndex, questionIndex]); }, [
assignment,
exam,
exams,
moduleIndex,
selectedModules,
sessionId,
userSolutions,
user,
exerciseIndex,
partIndex,
questionIndex,
]);
useEffect(() => { useEffect(() => {
if (timeSpent % 20 === 0 && timeSpent > 0 && moduleIndex < selectedModules.length && !showSolutions) saveSession(); if (
timeSpent % 20 === 0 &&
timeSpent > 0 &&
moduleIndex < selectedModules.length &&
!showSolutions
)
saveSession();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeSpent]); }, [timeSpent]);
@@ -123,6 +153,7 @@ export default function ExamPage({page}: Props) {
setSessionId(shortUID.randomUUID(8)); setSessionId(shortUID.randomUUID(8));
} }
}, [setSessionId, selectedModules, sessionId]); }, [setSessionId, selectedModules, sessionId]);
useEffect(() => { useEffect(() => {
if (user?.type === "developer") console.log(exam); if (user?.type === "developer") console.log(exam);
}, [exam, user]); }, [exam, user]);
@@ -146,11 +177,20 @@ export default function ExamPage({page}: Props) {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) { if (
selectedModules.length > 0 &&
exams.length > 0 &&
moduleIndex < selectedModules.length
) {
const nextExam = exams[moduleIndex]; const nextExam = exams[moduleIndex];
if (partIndex === -1 && nextExam.module !== "listening") setPartIndex(0); if (partIndex === -1 && nextExam.module !== "listening")
if (exerciseIndex === -1 && !["reading", "listening"].includes(nextExam.module)) setExerciseIndex(0); setPartIndex(0);
if (
exerciseIndex === -1 &&
!["reading", "listening"].includes(nextExam?.module)
)
setExerciseIndex(0);
setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined); setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined);
} }
})(); })();
@@ -160,7 +200,16 @@ export default function ExamPage({page}: Props) {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (selectedModules.length > 0 && exams.length === 0) { if (selectedModules.length > 0 && exams.length === 0) {
const examPromises = selectedModules.map((module) => getExam(module, avoidRepeated, variant)); const examPromises = selectedModules.map((module) =>
getExam(
module,
avoidRepeated,
variant,
user?.type === "student" || user?.type === "developer"
? user.preferredGender
: undefined,
),
);
Promise.all(examPromises).then((values) => { Promise.all(examPromises).then((values) => {
if (values.every((x) => !!x)) { if (values.every((x) => !!x)) {
setExams(values.map((x) => x!)); setExams(values.map((x) => x!));
@@ -175,21 +224,28 @@ export default function ExamPage({page}: Props) {
}, [selectedModules, setExams, exams]); }, [selectedModules, setExams, exams]);
useEffect(() => { useEffect(() => {
if (selectedModules.length > 0 && exams.length !== 0 && moduleIndex >= selectedModules.length && !hasBeenUploaded && !showSolutions) { if (
selectedModules.length > 0 &&
exams.length !== 0 &&
moduleIndex >= selectedModules.length &&
!hasBeenUploaded &&
!showSolutions
) {
const newStats: Stat[] = userSolutions.map((solution) => ({ const newStats: Stat[] = userSolutions.map((solution) => ({
...solution, ...solution,
id: solution.id || uuidv4(), id: solution.id || uuidv4(),
timeSpent, timeSpent,
session: sessionId, session: sessionId,
exam: exam!.id, exam: solution.exam!,
module: exam!.module, module: solution.module!,
user: user?.id || "", user: user?.id || "",
date: new Date().getTime(), date: new Date().getTime(),
...(assignment ? {assignment: assignment.id} : {}), isDisabled: solution.isDisabled,
...(assignment ? { assignment: assignment.id } : {}),
})); }));
axios axios
.post<{ok: boolean}>("/api/stats", newStats) .post<{ ok: boolean }>("/api/stats", newStats)
.then((response) => setHasBeenUploaded(response.data.ok)) .then((response) => setHasBeenUploaded(response.data.ok))
.catch(() => setHasBeenUploaded(false)); .catch(() => setHasBeenUploaded(false));
} }
@@ -209,10 +265,18 @@ export default function ExamPage({page}: Props) {
const checkIfStatsHaveBeenEvaluated = (ids: string[]) => { const checkIfStatsHaveBeenEvaluated = (ids: string[]) => {
setTimeout(async () => { setTimeout(async () => {
const awaitedStats = await Promise.all(ids.map(async (id) => (await axios.get<Stat>(`/api/stats/${id}`)).data)); try {
const solutionsEvaluated = awaitedStats.every((stat) => stat.solutions.every((x) => x.evaluation !== null)); const awaitedStats = await Promise.all(
ids.map(
async (id) => (await axios.get<Stat>(`/api/stats/${id}`)).data,
),
);
const solutionsEvaluated = awaitedStats.every((stat) =>
stat.solutions.every((x) => x.evaluation !== null),
);
if (solutionsEvaluated) { if (solutionsEvaluated) {
const statsUserSolutions: UserSolution[] = awaitedStats.map((stat) => ({ const statsUserSolutions: UserSolution[] = awaitedStats.map(
(stat) => ({
id: stat.id, id: stat.id,
exercise: stat.exercise, exercise: stat.exercise,
score: stat.score, score: stat.score,
@@ -220,18 +284,26 @@ export default function ExamPage({page}: Props) {
type: stat.type, type: stat.type,
exam: stat.exam, exam: stat.exam,
module: stat.module, module: stat.module,
})); }),
);
const updatedUserSolutions = userSolutions.map((x) => { const updatedUserSolutions = userSolutions.map((x) => {
const respectiveSolution = statsUserSolutions.find((y) => y.exercise === x.exercise); const respectiveSolution = statsUserSolutions.find(
(y) => y.exercise === x.exercise,
);
return respectiveSolution ? respectiveSolution : x; return respectiveSolution ? respectiveSolution : x;
}); });
setUserSolutions(updatedUserSolutions); setUserSolutions(updatedUserSolutions);
return setStatsAwaitingEvaluation((prev) => prev.filter((x) => !ids.includes(x))); return setStatsAwaitingEvaluation((prev) =>
prev.filter((x) => !ids.includes(x)),
);
} }
return checkIfStatsHaveBeenEvaluated(ids); return checkIfStatsHaveBeenEvaluated(ids);
} catch {
return checkIfStatsHaveBeenEvaluated(ids);
}
}, 5 * 1000); }, 5 * 1000);
}; };
@@ -241,54 +313,85 @@ export default function ExamPage({page}: Props) {
Object.assign(p, { Object.assign(p, {
exercises: p.exercises.map((x) => exercises: p.exercises.map((x) =>
Object.assign(x, { Object.assign(x, {
userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions, userSolutions: userSolutions.find((y) => x.id === y.exercise)
?.solutions,
}), }),
), ),
}), }),
); );
return Object.assign(exam, {parts}); return Object.assign(exam, { parts });
} }
const exercises = exam.exercises.map((x) => const exercises = exam.exercises.map((x) =>
Object.assign(x, { Object.assign(x, {
userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions, userSolutions: userSolutions.find((y) => x.id === y.exercise)
?.solutions,
}), }),
); );
return Object.assign(exam, {exercises}); return Object.assign(exam, { exercises });
}; };
const onFinish = (solutions: UserSolution[]) => { const onFinish = async (solutions: UserSolution[]) => {
const solutionIds = solutions.map((x) => x.exercise); const solutionIds = solutions.map((x) => x.exercise);
const solutionExams = solutions.map((x) => x.exam); const solutionExams = solutions.map((x) => x.exam);
let newSolutions = [...solutions];
if (exam && !solutionExams.includes(exam.id)) return; if (exam && !solutionExams.includes(exam.id)) return;
if (exam && (exam.module === "writing" || exam.module === "speaking") && solutions.length > 0 && !showSolutions) { if (
exam &&
(exam.module === "writing" || exam.module === "speaking") &&
solutions.length > 0 &&
!showSolutions
) {
setHasBeenUploaded(true); setHasBeenUploaded(true);
setIsEvaluationLoading(true); setIsEvaluationLoading(true);
Promise.all( const responses: UserSolution[] = (
exam.exercises.map(async (exercise) => { await Promise.all(
exam.exercises.map(async (exercise, index) => {
const evaluationID = uuidv4(); const evaluationID = uuidv4();
if (exercise.type === "writing") if (exercise.type === "writing")
return await evaluateWritingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!, evaluationID); return await evaluateWritingAnswer(
exercise,
index + 1,
solutions.find((x) => x.exercise === exercise.id)!,
evaluationID,
);
if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking") if (
return await evaluateSpeakingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!, evaluationID); exercise.type === "interactiveSpeaking" ||
exercise.type === "speaking"
)
return await evaluateSpeakingAnswer(
exercise,
solutions.find((x) => x.exercise === exercise.id)!,
evaluationID,
);
}), }),
) )
.then((responses) => { ).filter((x) => !!x) as UserSolution[];
setStatsAwaitingEvaluation((prev) => [...prev, ...responses.filter((x) => !!x).map((r) => (r as any).id)]);
setUserSolutions([...userSolutions, ...responses.filter((x) => !!x)] as any); newSolutions = [
}) ...newSolutions.filter(
.finally(() => { (x) => !responses.map((y) => y.exercise).includes(x.exercise),
),
...responses,
];
setStatsAwaitingEvaluation((prev) => [
...prev,
...responses.filter((x) => !!x).map((r) => (r as any).id),
]);
setHasBeenUploaded(false); setHasBeenUploaded(false);
});
} }
axios.get("/api/stats/update"); axios.get("/api/stats/update");
setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...solutions]); setUserSolutions([
...userSolutions.filter((x) => !solutionIds.includes(x.exercise)),
...newSolutions,
]);
setModuleIndex(moduleIndex + 1); setModuleIndex(moduleIndex + 1);
setPartIndex(-1); setPartIndex(-1);
@@ -296,9 +399,14 @@ export default function ExamPage({page}: Props) {
setQuestionIndex(0); setQuestionIndex(0);
}; };
const aggregateScoresByModule = (answers: UserSolution[]): {module: Module; total: number; missing: number; correct: number}[] => { const aggregateScoresByModule = (): {
module: Module;
total: number;
missing: number;
correct: number;
}[] => {
const scores: { const scores: {
[key in Module]: {total: number; missing: number; correct: number}; [key in Module]: { total: number; missing: number; correct: number };
} = { } = {
reading: { reading: {
total: 0, total: 0,
@@ -327,19 +435,25 @@ export default function ExamPage({page}: Props) {
}, },
}; };
answers.forEach((x) => { userSolutions.forEach((x) => {
console.log({x}); const examModule =
x.module ||
(x.type === "writing"
? "writing"
: x.type === "speaking" || x.type === "interactiveSpeaking"
? "speaking"
: undefined);
scores[x.module!] = { scores[examModule!] = {
total: scores[x.module!].total + x.score.total, total: scores[examModule!].total + x.score.total,
correct: scores[x.module!].correct + x.score.correct, correct: scores[examModule!].correct + x.score.correct,
missing: scores[x.module!].missing + x.score.missing, missing: scores[examModule!].missing + x.score.missing,
}; };
}); });
return Object.keys(scores) return Object.keys(scores)
.filter((x) => scores[x as Module].total > 0) .filter((x) => scores[x as Module].total > 0)
.map((x) => ({module: x as Module, ...scores[x as Module]})); .map((x) => ({ module: x as Module, ...scores[x as Module] }));
}; };
const renderScreen = () => { const renderScreen = () => {
@@ -365,36 +479,64 @@ export default function ExamPage({page}: Props) {
isLoading={isEvaluationLoading} isLoading={isEvaluationLoading}
user={user!} user={user!}
modules={selectedModules} modules={selectedModules}
onViewResults={() => { onViewResults={(index?: number) => {
setShowSolutions(true); setShowSolutions(true);
setModuleIndex(0); setModuleIndex(index || 0);
setExerciseIndex(["reading", "listening"].includes(exams[0].module) ? -1 : 0); setExerciseIndex(
["reading", "listening"].includes(exams[0].module) ? -1 : 0,
);
setPartIndex(exams[0].module === "listening" ? -1 : 0); setPartIndex(exams[0].module === "listening" ? -1 : 0);
setExam(exams[0]); setExam(exams[0]);
}} }}
scores={aggregateScoresByModule(userSolutions)} scores={aggregateScoresByModule()}
/> />
); );
} }
if (exam && exam.module === "reading") { if (exam && exam.module === "reading") {
return <Reading exam={exam} onFinish={onFinish} showSolutions={showSolutions} />; return (
<Reading
exam={exam}
onFinish={onFinish}
showSolutions={showSolutions}
/>
);
} }
if (exam && exam.module === "listening") { if (exam && exam.module === "listening") {
return <Listening exam={exam} onFinish={onFinish} showSolutions={showSolutions} />; return (
<Listening
exam={exam}
onFinish={onFinish}
showSolutions={showSolutions}
/>
);
} }
if (exam && exam.module === "writing") { if (exam && exam.module === "writing") {
return <Writing exam={exam} onFinish={onFinish} showSolutions={showSolutions} />; return (
<Writing
exam={exam}
onFinish={onFinish}
showSolutions={showSolutions}
/>
);
} }
if (exam && exam.module === "speaking") { if (exam && exam.module === "speaking") {
return <Speaking exam={exam} onFinish={onFinish} showSolutions={showSolutions} />; return (
<Speaking
exam={exam}
onFinish={onFinish}
showSolutions={showSolutions}
/>
);
} }
if (exam && exam.module === "level") { if (exam && exam.module === "level") {
return <Level exam={exam} onFinish={onFinish} showSolutions={showSolutions} />; return (
<Level exam={exam} onFinish={onFinish} showSolutions={showSolutions} />
);
} }
return <>Loading...</>; return <>Loading...</>;
@@ -407,15 +549,20 @@ export default function ExamPage({page}: Props) {
<Layout <Layout
user={user} user={user}
className="justify-between" className="justify-between"
focusMode={selectedModules.length !== 0 && !showSolutions && moduleIndex < selectedModules.length} focusMode={
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}> selectedModules.length !== 0 &&
!showSolutions &&
moduleIndex < selectedModules.length
}
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}
>
<> <>
{renderScreen()} {renderScreen()}
{!showSolutions && moduleIndex < selectedModules.length && ( {!showSolutions && moduleIndex < selectedModules.length && (
<AbandonPopup <AbandonPopup
isOpen={showAbandonPopup} isOpen={showAbandonPopup}
abandonPopupTitle="Leave Exercise" abandonPopupTitle="Leave Exercise"
abandonPopupDescription="Are you sure you want to leave the exercise? You will lose all your progress." abandonPopupDescription="Are you sure you want to leave the exercise? Your progress will be saved and this exam can be resumed on the Dashboard."
abandonConfirmButtonText="Confirm" abandonConfirmButtonText="Confirm"
onAbandon={() => { onAbandon={() => {
reset(); reset();

View File

@@ -1,23 +1,87 @@
import {LevelExam, MultipleChoiceExercise} from "@/interfaces/exam"; import Select from "@/components/Low/Select";
import {Difficulty, LevelExam, MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams"; import {getExamById} from "@/utils/exams";
import {playSound} from "@/utils/sound"; import {playSound} from "@/utils/sound";
import {Tab} from "@headlessui/react"; import {Tab} from "@headlessui/react";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize, sample} from "lodash";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {useState} from "react"; import {useState} from "react";
import {BsArrowRepeat} from "react-icons/bs"; import {BsArrowRepeat, BsCheck, BsPencilSquare, BsX} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import {v4} from "uuid"; import {v4} from "uuid";
const TaskTab = ({exam, setExam}: {exam?: LevelExam; setExam: (exam: LevelExam) => void}) => { const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
const QuestionDisplay = ({question, onUpdate}: {question: MultipleChoiceQuestion; onUpdate: (question: MultipleChoiceQuestion) => void}) => {
const [isEditing, setIsEditing] = useState(false);
const [options, setOptions] = useState(question.options);
return (
<div key={question.id} className="flex flex-col gap-1">
<span className="font-semibold">
{question.id}. {question.prompt}{" "}
</span>
<div className="flex flex-col gap-1">
{question.options.map((option, index) => (
<span key={option.id} className={clsx(question.solution === option.id && "font-bold")}>
<span className={clsx("font-semibold", question.solution === option.id ? "text-mti-green-light" : "text-ielts-level")}>
({option.id})
</span>{" "}
{isEditing ? (
<input
defaultValue={option.text}
className="w-60"
onChange={(e) => setOptions((prev) => prev.map((x, idx) => (idx === index ? {...x, text: e.target.value} : x)))}
/>
) : (
<span>{option.text}</span>
)}
</span>
))}
</div>
<div className="flex gap-2 mt-2 w-full">
{!isEditing && (
<button
onClick={() => setIsEditing(true)}
className="p-2 border border-neutral-300 bg-white rounded-xl hover:drop-shadow transition ease-in-out duration-300">
<BsPencilSquare />
</button>
)}
{isEditing && (
<>
<button
onClick={() => {
onUpdate({...question, options});
setIsEditing(false);
}}
className="p-2 border border-neutral-300 bg-white rounded-xl hover:drop-shadow transition ease-in-out duration-300">
<BsCheck />
</button>
<button
onClick={() => setIsEditing(false)}
className="p-2 border border-neutral-300 bg-white rounded-xl hover:drop-shadow transition ease-in-out duration-300">
<BsX />
</button>
</>
)}
</div>
</div>
);
};
const TaskTab = ({exam, difficulty, setExam}: {exam?: LevelExam; difficulty: Difficulty; setExam: (exam: LevelExam) => void}) => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const generate = () => { const generate = () => {
const url = new URLSearchParams();
url.append("difficulty", difficulty);
setIsLoading(true); setIsLoading(true);
axios axios
.get(`/api/exam/level/generate/level`) .get(`/api/exam/level/generate/level?${url.toString()}`)
.then((result) => { .then((result) => {
playSound(typeof result.data === "string" ? "error" : "check"); playSound(typeof result.data === "string" ? "error" : "check");
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again."); if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
@@ -30,6 +94,20 @@ const TaskTab = ({exam, setExam}: {exam?: LevelExam; setExam: (exam: LevelExam)
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
const onUpdate = (question: MultipleChoiceQuestion) => {
if (!exam) return;
const updatedExam = {
...exam,
exercises: exam.exercises.map((x) => ({
...x,
questions: (x as MultipleChoiceExercise).questions.map((q) => (q.id === question.id ? question : q)),
})),
};
console.log(updatedExam);
setExam(updatedExam as any);
};
return ( return (
<Tab.Panel className="w-full bg-ielts-level/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4"> <Tab.Panel className="w-full bg-ielts-level/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
<div className="flex gap-4 items-end"> <div className="flex gap-4 items-end">
@@ -73,25 +151,7 @@ const TaskTab = ({exam, setExam}: {exam?: LevelExam; setExam: (exam: LevelExam)
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{exercise.questions.map((question) => ( {exercise.questions.map((question) => (
<div key={question.id} className="flex flex-col gap-1"> <QuestionDisplay question={question} onUpdate={onUpdate} key={question.id} />
<span className="font-semibold">
{question.id}. {question.prompt}
</span>
<div className="flex flex-col gap-1">
{question.options.map((option) => (
<span key={option.id} className={clsx(question.solution === option.id && "font-bold")}>
<span
className={clsx(
"font-semibold",
question.solution === option.id ? "text-mti-green-light" : "text-ielts-level",
)}>
({option.id})
</span>{" "}
{option.text}
</span>
))}
</div>
</div>
))} ))}
</div> </div>
</div> </div>
@@ -107,6 +167,7 @@ const LevelGeneration = () => {
const [generatedExam, setGeneratedExam] = useState<LevelExam>(); const [generatedExam, setGeneratedExam] = useState<LevelExam>();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<LevelExam>(); const [resultingExam, setResultingExam] = useState<LevelExam>();
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
const router = useRouter(); const router = useRouter();
@@ -163,6 +224,16 @@ const LevelGeneration = () => {
return ( return (
<> <>
<div className="flex gap-4 w-1/2">
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
<Select
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
value={{value: difficulty, label: capitalize(difficulty)}}
/>
</div>
</div>
<Tab.Group> <Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1"> <Tab.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1">
<Tab <Tab
@@ -178,7 +249,7 @@ const LevelGeneration = () => {
</Tab> </Tab>
</Tab.List> </Tab.List>
<Tab.Panels> <Tab.Panels>
<TaskTab exam={generatedExam} setExam={setGeneratedExam} /> <TaskTab difficulty={difficulty} exam={generatedExam} setExam={setGeneratedExam} />
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
<div className="w-full flex justify-end gap-4"> <div className="w-full flex justify-end gap-4">

View File

@@ -1,5 +1,6 @@
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import {Exercise, ListeningExam} from "@/interfaces/exam"; import Select from "@/components/Low/Select";
import {Difficulty, Exercise, ListeningExam} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams"; import {getExamById} from "@/utils/exams";
import {playSound} from "@/utils/sound"; import {playSound} from "@/utils/sound";
@@ -7,17 +8,34 @@ import {convertCamelCaseToReadable} from "@/utils/string";
import {Tab} from "@headlessui/react"; import {Tab} from "@headlessui/react";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize, sample} from "lodash";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {BsArrowRepeat, BsCheck} from "react-icons/bs"; import {BsArrowRepeat, BsCheck} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
const PartTab = ({part, types, index, setPart}: {part?: ListeningPart; types: string[]; index: number; setPart: (part?: ListeningPart) => void}) => { const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
const PartTab = ({
part,
types,
difficulty,
index,
setPart,
}: {
part?: ListeningPart;
difficulty: Difficulty;
types: string[];
index: number;
setPart: (part?: ListeningPart) => void;
}) => {
const [topic, setTopic] = useState(""); const [topic, setTopic] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const generate = () => { const generate = () => {
const url = new URLSearchParams(); const url = new URLSearchParams();
url.append("difficulty", difficulty);
if (topic) url.append("topic", topic); if (topic) url.append("topic", topic);
if (types) types.forEach((t) => url.append("exercises", t)); if (types) types.forEach((t) => url.append("exercises", t));
@@ -115,6 +133,7 @@ const ListeningGeneration = () => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<ListeningExam>(); const [resultingExam, setResultingExam] = useState<ListeningExam>();
const [types, setTypes] = useState<string[]>([]); const [types, setTypes] = useState<string[]>([]);
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
useEffect(() => { useEffect(() => {
const part1Timer = part1 ? 5 : 0; const part1Timer = part1 ? 5 : 0;
@@ -148,7 +167,7 @@ const ListeningGeneration = () => {
setIsLoading(true); setIsLoading(true);
axios axios
.post(`/api/exam/listening/generate/listening`, {parts, minTimer}) .post(`/api/exam/listening/generate/listening`, {parts, minTimer, difficulty})
.then((result) => { .then((result) => {
playSound("sent"); playSound("sent");
console.log(`Generated Exam ID: ${result.data.id}`); console.log(`Generated Exam ID: ${result.data.id}`);
@@ -159,6 +178,7 @@ const ListeningGeneration = () => {
setPart2(undefined); setPart2(undefined);
setPart3(undefined); setPart3(undefined);
setPart4(undefined); setPart4(undefined);
setDifficulty(sample(DIFFICULTIES)!);
setTypes([]); setTypes([]);
}) })
.catch((error) => { .catch((error) => {
@@ -186,6 +206,7 @@ const ListeningGeneration = () => {
return ( return (
<> <>
<div className="flex gap-4 w-1/2">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label> <label className="font-normal text-base text-mti-gray-dim">Timer</label>
<Input <Input
@@ -196,6 +217,16 @@ const ListeningGeneration = () => {
className="max-w-[300px]" className="max-w-[300px]"
/> />
</div> </div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
<Select
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
value={{value: difficulty, label: capitalize(difficulty)}}
disabled={!!part1 || !!part2 || !!part3 || !!part4}
/>
</div>
</div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Exercises</label> <label className="font-normal text-base text-mti-gray-dim">Exercises</label>
@@ -271,7 +302,7 @@ const ListeningGeneration = () => {
{part: part3, setPart: setPart3}, {part: part3, setPart: setPart3},
{part: part4, setPart: setPart4}, {part: part4, setPart: setPart4},
].map(({part, setPart}, index) => ( ].map(({part, setPart}, index) => (
<PartTab part={part} types={types} index={index + 1} key={index} setPart={setPart} /> <PartTab part={part} difficulty={difficulty} types={types} index={index + 1} key={index} setPart={setPart} />
))} ))}
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>

View File

@@ -1,5 +1,6 @@
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import {ReadingExam, ReadingPart} from "@/interfaces/exam"; import Select from "@/components/Low/Select";
import {Difficulty, ReadingExam, ReadingPart} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams"; import {getExamById} from "@/utils/exams";
import {playSound} from "@/utils/sound"; import {playSound} from "@/utils/sound";
@@ -7,18 +8,35 @@ import {convertCamelCaseToReadable} from "@/utils/string";
import {Tab} from "@headlessui/react"; import {Tab} from "@headlessui/react";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize, sample} from "lodash";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {BsArrowRepeat, BsCheck} from "react-icons/bs"; import {BsArrowRepeat, BsCheck} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import {v4} from "uuid"; import {v4} from "uuid";
const PartTab = ({part, types, index, setPart}: {part?: ReadingPart; types: string[]; index: number; setPart: (part?: ReadingPart) => void}) => { const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
const PartTab = ({
part,
types,
difficulty,
index,
setPart,
}: {
part?: ReadingPart;
types: string[];
index: number;
difficulty: Difficulty;
setPart: (part?: ReadingPart) => void;
}) => {
const [topic, setTopic] = useState(""); const [topic, setTopic] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const generate = () => { const generate = () => {
const url = new URLSearchParams(); const url = new URLSearchParams();
url.append("difficulty", difficulty);
if (topic) url.append("topic", topic); if (topic) url.append("topic", topic);
if (types) types.forEach((t) => url.append("exercises", t)); if (types) types.forEach((t) => url.append("exercises", t));
@@ -92,6 +110,7 @@ const ReadingGeneration = () => {
const [types, setTypes] = useState<string[]>([]); const [types, setTypes] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<ReadingExam>(); const [resultingExam, setResultingExam] = useState<ReadingExam>();
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
useEffect(() => { useEffect(() => {
const parts = [part1, part2, part3].filter((x) => !!x); const parts = [part1, part2, part3].filter((x) => !!x);
@@ -105,9 +124,9 @@ const ReadingGeneration = () => {
const availableTypes = [ const availableTypes = [
{type: "fillBlanks", label: "Fill the Blanks"}, {type: "fillBlanks", label: "Fill the Blanks"},
{type: "multipleChoice", label: "Multiple Choice"},
{type: "trueFalse", label: "True or False"}, {type: "trueFalse", label: "True or False"},
{type: "writeBlanks", label: "Write the Blanks"}, {type: "writeBlanks", label: "Write the Blanks"},
{type: "matchSentences", label: "Match Sentences"},
]; ];
const toggleType = (type: string) => setTypes((prev) => (prev.includes(type) ? [...prev.filter((x) => x !== type)] : [...prev, type])); const toggleType = (type: string) => setTypes((prev) => (prev.includes(type) ? [...prev.filter((x) => x !== type)] : [...prev, type]));
@@ -144,6 +163,7 @@ const ReadingGeneration = () => {
id: v4(), id: v4(),
type: "academic", type: "academic",
variant: parts.length === 3 ? "full" : "partial", variant: parts.length === 3 ? "full" : "partial",
difficulty,
}; };
axios axios
@@ -157,6 +177,7 @@ const ReadingGeneration = () => {
setPart1(undefined); setPart1(undefined);
setPart2(undefined); setPart2(undefined);
setPart3(undefined); setPart3(undefined);
setDifficulty(sample(DIFFICULTIES)!);
setMinTimer(60); setMinTimer(60);
setTypes([]); setTypes([]);
}) })
@@ -169,6 +190,7 @@ const ReadingGeneration = () => {
return ( return (
<> <>
<div className="flex gap-4 w-1/2">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label> <label className="font-normal text-base text-mti-gray-dim">Timer</label>
<Input <Input
@@ -179,6 +201,16 @@ const ReadingGeneration = () => {
className="max-w-[300px]" className="max-w-[300px]"
/> />
</div> </div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
<Select
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
value={{value: difficulty, label: capitalize(difficulty)}}
disabled={!!part1 || !!part2 || !!part3}
/>
</div>
</div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Exercises</label> <label className="font-normal text-base text-mti-gray-dim">Exercises</label>
@@ -240,7 +272,7 @@ const ReadingGeneration = () => {
{part: part2, setPart: setPart2}, {part: part2, setPart: setPart2},
{part: part3, setPart: setPart3}, {part: part3, setPart: setPart3},
].map(({part, setPart}, index) => ( ].map(({part, setPart}, index) => (
<PartTab part={part} types={types} index={index + 1} key={index} setPart={setPart} /> <PartTab part={part} types={types} difficulty={difficulty} index={index + 1} key={index} setPart={setPart} />
))} ))}
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>

View File

@@ -1,5 +1,7 @@
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import {Exercise, InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise} from "@/interfaces/exam"; import Select from "@/components/Low/Select";
import {Difficulty, Exercise, InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise} from "@/interfaces/exam";
import {AVATARS} from "@/resources/speakingAvatars";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams"; import {getExamById} from "@/utils/exams";
import {playSound} from "@/utils/sound"; import {playSound} from "@/utils/sound";
@@ -7,6 +9,7 @@ import {convertCamelCaseToReadable} from "@/utils/string";
import {Tab} from "@headlessui/react"; import {Tab} from "@headlessui/react";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize, sample, uniq} from "lodash";
import moment from "moment"; import moment from "moment";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
@@ -14,15 +17,31 @@ import {BsArrowRepeat, BsCheck} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import {v4} from "uuid"; import {v4} from "uuid";
const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; setPart: (part?: SpeakingPart) => void}) => { const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
const PartTab = ({
part,
index,
difficulty,
setPart,
}: {
part?: SpeakingPart;
difficulty: Difficulty;
index: number;
setPart: (part?: SpeakingPart) => void;
}) => {
const [gender, setGender] = useState<"male" | "female">("male");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const generate = () => { const generate = () => {
setPart(undefined); setPart(undefined);
setIsLoading(true); setIsLoading(true);
const url = new URLSearchParams();
url.append("difficulty", difficulty);
axios axios
.get(`/api/exam/speaking/generate/speaking_task_${index}`) .get(`/api/exam/speaking/generate/speaking_task_${index}?${url.toString()}`)
.then((result) => { .then((result) => {
playSound(typeof result.data === "string" ? "error" : "check"); playSound(typeof result.data === "string" ? "error" : "check");
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again."); if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
@@ -39,17 +58,19 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
if (!part) return toast.error("Please generate the first part before generating the video!"); if (!part) return toast.error("Please generate the first part before generating the video!");
toast.info("This will take quite a while, please do not leave this page or close the tab/window."); toast.info("This will take quite a while, please do not leave this page or close the tab/window.");
const avatar = sample(AVATARS.filter((x) => x.gender === gender));
setIsLoading(true); setIsLoading(true);
const initialTime = moment(); const initialTime = moment();
axios axios
.post(`/api/exam/speaking/generate/speaking/generate_${index === 3 ? "interactive" : "speaking"}_video`, part) .post(`/api/exam/speaking/generate/speaking/generate_${index === 3 ? "interactive" : "speaking"}_video`, {...part, avatar: avatar?.id})
.then((result) => { .then((result) => {
const isError = typeof result.data === "string" || moment().diff(initialTime, "seconds") < 60; const isError = typeof result.data === "string" || moment().diff(initialTime, "seconds") < 60;
playSound(isError ? "error" : "check"); playSound(isError ? "error" : "check");
if (isError) return toast.error("Something went wrong, please try to generate the video again."); if (isError) return toast.error("Something went wrong, please try to generate the video again.");
setPart({...part, result: result.data}); setPart({...part, result: {...result.data, topic: part?.topic}, gender, avatar});
}) })
.catch((e) => { .catch((e) => {
toast.error("Something went wrong!"); toast.error("Something went wrong!");
@@ -60,6 +81,18 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
return ( return (
<Tab.Panel className="w-full bg-ielts-speaking/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4"> <Tab.Panel className="w-full bg-ielts-speaking/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Gender</label>
<Select
options={[
{value: "male", label: "Male"},
{value: "female", label: "Female"},
]}
value={{value: gender, label: capitalize(gender)}}
onChange={(value) => (value ? setGender(value.value as typeof gender) : null)}
disabled={isLoading}
/>
</div>
<div className="flex gap-4 items-end"> <div className="flex gap-4 items-end">
<button <button
onClick={generate} onClick={generate}
@@ -128,6 +161,11 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
</div> </div>
)} )}
{part.result && <span className="font-bold mt-4">Video Generated: </span>} {part.result && <span className="font-bold mt-4">Video Generated: </span>}
{part.avatar && part.gender && (
<span>
<b>Instructor:</b> {part.avatar.name} - {capitalize(part.avatar.gender)}
</span>
)}
</div> </div>
)} )}
</Tab.Panel> </Tab.Panel>
@@ -140,6 +178,8 @@ interface SpeakingPart {
questions?: string[]; questions?: string[];
topic: string; topic: string;
result?: SpeakingExercise | InteractiveSpeakingExercise; result?: SpeakingExercise | InteractiveSpeakingExercise;
gender?: "male" | "female";
avatar?: (typeof AVATARS)[number];
} }
const SpeakingGeneration = () => { const SpeakingGeneration = () => {
@@ -149,6 +189,7 @@ const SpeakingGeneration = () => {
const [minTimer, setMinTimer] = useState(14); const [minTimer, setMinTimer] = useState(14);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<SpeakingExam>(); const [resultingExam, setResultingExam] = useState<SpeakingExam>();
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
useEffect(() => { useEffect(() => {
const parts = [part1, part2, part3].filter((x) => !!x); const parts = [part1, part2, part3].filter((x) => !!x);
@@ -165,6 +206,8 @@ const SpeakingGeneration = () => {
setIsLoading(true); setIsLoading(true);
const genders = [part1?.gender, part2?.gender, part3?.gender].filter((x) => !!x);
const exam: SpeakingExam = { const exam: SpeakingExam = {
id: v4(), id: v4(),
isDiagnostic: false, isDiagnostic: false,
@@ -172,6 +215,7 @@ const SpeakingGeneration = () => {
minTimer, minTimer,
variant: minTimer >= 14 ? "full" : "partial", variant: minTimer >= 14 ? "full" : "partial",
module: "speaking", module: "speaking",
instructorGender: genders.every((x) => x === "male") ? "male" : genders.every((x) => x === "female") ? "female" : "varied",
}; };
axios axios
@@ -185,6 +229,7 @@ const SpeakingGeneration = () => {
setPart1(undefined); setPart1(undefined);
setPart2(undefined); setPart2(undefined);
setPart3(undefined); setPart3(undefined);
setDifficulty(sample(DIFFICULTIES)!);
setMinTimer(14); setMinTimer(14);
}) })
.catch((error) => { .catch((error) => {
@@ -212,6 +257,7 @@ const SpeakingGeneration = () => {
return ( return (
<> <>
<div className="flex gap-4 w-1/2">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label> <label className="font-normal text-base text-mti-gray-dim">Timer</label>
<Input <Input
@@ -222,6 +268,16 @@ const SpeakingGeneration = () => {
className="max-w-[300px]" className="max-w-[300px]"
/> />
</div> </div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
<Select
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
value={{value: difficulty, label: capitalize(difficulty)}}
disabled={!!part1 || !!part2 || !!part3}
/>
</div>
</div>
<Tab.Group> <Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1"> <Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
@@ -265,7 +321,7 @@ const SpeakingGeneration = () => {
{part: part2, setPart: setPart2}, {part: part2, setPart: setPart2},
{part: part3, setPart: setPart3}, {part: part3, setPart: setPart3},
].map(({part, setPart}, index) => ( ].map(({part, setPart}, index) => (
<PartTab part={part} index={index + 1} key={index} setPart={setPart} /> <PartTab difficulty={difficulty} part={part} index={index + 1} key={index} setPart={setPart} />
))} ))}
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>

View File

@@ -1,24 +1,32 @@
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import {WritingExam, WritingExercise} from "@/interfaces/exam"; import Select from "@/components/Low/Select";
import {Difficulty, WritingExam, WritingExercise} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams"; import {getExamById} from "@/utils/exams";
import {playSound} from "@/utils/sound"; import {playSound} from "@/utils/sound";
import {Tab} from "@headlessui/react"; import {Tab} from "@headlessui/react";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize, sample} from "lodash";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {BsArrowRepeat, BsCheck} from "react-icons/bs"; import {BsArrowRepeat, BsCheck} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import {v4} from "uuid"; import {v4} from "uuid";
const TaskTab = ({task, index, setTask}: {task?: string; index: number; setTask: (task: string) => void}) => { const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
const TaskTab = ({task, index, difficulty, setTask}: {task?: string; difficulty: Difficulty; index: number; setTask: (task: string) => void}) => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const generate = () => { const generate = () => {
setIsLoading(true); setIsLoading(true);
const url = new URLSearchParams();
url.append("difficulty", difficulty);
axios axios
.get(`/api/exam/writing/generate/writing_task${index}_general`) .get(`/api/exam/writing/generate/writing_task${index}_general?${url.toString()}`)
.then((result) => { .then((result) => {
playSound(typeof result.data === "string" ? "error" : "check"); playSound(typeof result.data === "string" ? "error" : "check");
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again."); if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
@@ -72,6 +80,7 @@ const WritingGeneration = () => {
const [minTimer, setMinTimer] = useState(60); const [minTimer, setMinTimer] = useState(60);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<WritingExam>(); const [resultingExam, setResultingExam] = useState<WritingExam>();
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
useEffect(() => { useEffect(() => {
const task1Timer = task1 ? 20 : 0; const task1Timer = task1 ? 20 : 0;
@@ -144,6 +153,7 @@ const WritingGeneration = () => {
exercises: [...(exercise1 ? [exercise1] : []), ...(exercise2 ? [exercise2] : [])], exercises: [...(exercise1 ? [exercise1] : []), ...(exercise2 ? [exercise2] : [])],
id: v4(), id: v4(),
variant: exercise1 && exercise2 ? "full" : "partial", variant: exercise1 && exercise2 ? "full" : "partial",
difficulty,
}; };
axios axios
@@ -156,6 +166,7 @@ const WritingGeneration = () => {
setTask1(undefined); setTask1(undefined);
setTask2(undefined); setTask2(undefined);
setDifficulty(sample(DIFFICULTIES)!);
}) })
.catch((error) => { .catch((error) => {
console.log(error); console.log(error);
@@ -166,6 +177,7 @@ const WritingGeneration = () => {
return ( return (
<> <>
<div className="flex gap-4 w-1/2">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label> <label className="font-normal text-base text-mti-gray-dim">Timer</label>
<Input <Input
@@ -176,6 +188,16 @@ const WritingGeneration = () => {
className="max-w-[300px]" className="max-w-[300px]"
/> />
</div> </div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
<Select
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
value={{value: difficulty, label: capitalize(difficulty)}}
disabled={!!task1 || !!task2}
/>
</div>
</div>
<Tab.Group> <Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1"> <Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1">
@@ -207,7 +229,7 @@ const WritingGeneration = () => {
{task: task1, setTask: setTask1}, {task: task1, setTask: setTask1},
{task: task2, setTask: setTask2}, {task: task2, setTask: setTask2},
].map(({task, setTask}, index) => ( ].map(({task, setTask}, index) => (
<TaskTab task={task} index={index + 1} key={index} setTask={setTask} /> <TaskTab difficulty={difficulty} task={task} index={index + 1} key={index} setTask={setTask} />
))} ))}
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>

View File

@@ -10,6 +10,7 @@ import { toast } from "react-toastify";
import { KeyedMutator } from "swr"; import { KeyedMutator } from "swr";
import Select from "react-select"; import Select from "react-select";
import moment from "moment"; import moment from "moment";
import useAcceptedTerms from "@/hooks/useAcceptedTerms";
interface Props { interface Props {
isLoading: boolean; isLoading: boolean;
@@ -40,6 +41,7 @@ export default function RegisterCorporate({
const [companyName, setCompanyName] = useState(""); const [companyName, setCompanyName] = useState("");
const [companyUsers, setCompanyUsers] = useState(0); const [companyUsers, setCompanyUsers] = useState(0);
const [subscriptionDuration, setSubscriptionDuration] = useState(1); const [subscriptionDuration, setSubscriptionDuration] = useState(1);
const {acceptedTerms, renderCheckbox} = useAcceptedTerms();
const { users } = useUsers(); const { users } = useUsers();
@@ -257,7 +259,9 @@ export default function RegisterCorporate({
/> />
</div> </div>
</div> </div>
<div className="flex w-full flex-col items-start gap-4">
{renderCheckbox()}
</div>
<Button <Button
className="w-full lg:mt-8" className="w-full lg:mt-8"
color="purple" color="purple"

View File

@@ -4,9 +4,10 @@ import Input from "@/components/Low/Input";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { sendEmailVerification } from "@/utils/email"; import { sendEmailVerification } from "@/utils/email";
import axios from "axios"; import axios from "axios";
import { useEffect, useState } from "react"; import { useState } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { KeyedMutator } from "swr"; import { KeyedMutator } from "swr";
import useAcceptedTerms from "@/hooks/useAcceptedTerms";
interface Props { interface Props {
queryCode?: string; queryCode?: string;
@@ -35,6 +36,7 @@ export default function RegisterIndividual({
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [code, setCode] = useState(queryCode || ""); const [code, setCode] = useState(queryCode || "");
const [hasCode, setHasCode] = useState<boolean>(!!queryCode); const [hasCode, setHasCode] = useState<boolean>(!!queryCode);
const {acceptedTerms, renderCheckbox} = useAcceptedTerms();
const onSuccess = () => const onSuccess = () =>
toast.success( toast.success(
@@ -146,7 +148,9 @@ export default function RegisterIndividual({
/> />
)} )}
</div> </div>
<div className="flex w-full flex-col items-start gap-4">
{renderCheckbox()}
</div>
<Button <Button
className="w-full lg:mt-8" className="w-full lg:mt-8"
color="purple" color="purple"
@@ -156,6 +160,7 @@ export default function RegisterIndividual({
!name || !name ||
!password || !password ||
!confirmPassword || !confirmPassword ||
!acceptedTerms ||
password !== confirmPassword || password !== confirmPassword ||
(hasCode ? !code : false) (hasCode ? !code : false)
} }

View File

@@ -1,19 +1,20 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import PayPalPayment from "@/components/PayPalPayment";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import usePackages from "@/hooks/usePackages"; import usePackages from "@/hooks/usePackages";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize} from "lodash"; import {capitalize} from "lodash";
import {useState} from "react"; import {useEffect, useState} from "react";
import getSymbolFromCurrency from "currency-symbol-map"; import getSymbolFromCurrency from "currency-symbol-map";
import useInvites from "@/hooks/useInvites"; import useInvites from "@/hooks/useInvites";
import {BsArrowRepeat} from "react-icons/bs"; import {BsArrowRepeat} from "react-icons/bs";
import InviteCard from "@/components/Medium/InviteCard"; import InviteCard from "@/components/Medium/InviteCard";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {PayPalScriptProvider} from "@paypal/react-paypal-js"; import {ToastContainer} from "react-toastify";
import useDiscounts from "@/hooks/useDiscounts";
import PaymobPayment from "@/components/PaymobPayment";
interface Props { interface Props {
user: User; user: User;
@@ -24,14 +25,26 @@ interface Props {
export default function PaymentDue({user, hasExpired = false, clientID, reload}: Props) { export default function PaymentDue({user, hasExpired = false, clientID, reload}: Props) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [appliedDiscount, setAppliedDiscount] = useState(0);
const router = useRouter(); const router = useRouter();
const {packages} = usePackages(); const {packages} = usePackages();
const {discounts} = useDiscounts();
const {users} = useUsers(); const {users} = useUsers();
const {groups} = useGroups(); const {groups} = useGroups();
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user?.id}); const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user?.id});
useEffect(() => {
const userDiscounts = discounts.filter((x) => user.email.endsWith(`@${x.domain}`));
if (userDiscounts.length === 0) return;
const biggestDiscount = [...userDiscounts].sort((a, b) => b.percentage - a.percentage).shift();
if (!biggestDiscount) return;
setAppliedDiscount(biggestDiscount.percentage);
}, [discounts, user]);
const isIndividual = () => { const isIndividual = () => {
if (user?.type === "developer") return true; if (user?.type === "developer") return true;
if (user?.type !== "student") return false; if (user?.type !== "student") return false;
@@ -45,11 +58,18 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
return ( return (
<> <>
<ToastContainer />
{isLoading && ( {isLoading && (
<div className="absolute left-0 top-0 z-[999] h-screen w-screen overflow-hidden bg-black/60"> <div className="absolute left-0 top-0 z-[999] h-screen w-screen overflow-hidden bg-black/60">
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-8 text-white"> <div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-8 text-white">
<span className={clsx("loading loading-infinity w-48")} /> <span className={clsx("loading loading-infinity w-48 animate-pulse")} />
<span className={clsx("text-2xl font-bold")}>Completing your payment...</span> <span className={clsx("text-2xl font-bold animate-pulse")}>Completing your payment...</span>
<span>If you canceled your payment or it failed, please click the button below to restart</span>
<button
onClick={() => setIsLoading(false)}
className="border border-white rounded-full px-4 py-2 hover:bg-white/80 hover:text-black cursor-pointer transition ease-in-out duration-300">
Cancel Payment
</button>
</div> </div>
</div> </div>
)} )}
@@ -89,14 +109,6 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
To add to your use of EnCoach, please purchase one of the time packages available below: To add to your use of EnCoach, please purchase one of the time packages available below:
</span> </span>
<div className="flex w-full flex-wrap justify-center gap-8"> <div className="flex w-full flex-wrap justify-center gap-8">
<PayPalScriptProvider
options={{
clientId: clientID,
currency: "USD",
intent: "capture",
commit: true,
vault: true,
}}>
{packages.map((p) => ( {packages.map((p) => (
<div key={p.id} className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}> <div key={p.id} className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}>
<div className="mb-2 flex flex-col items-start"> <div className="mb-2 flex flex-col items-start">
@@ -109,18 +121,35 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
</span> </span>
</div> </div>
<div className="flex w-full flex-col items-start gap-2"> <div className="flex w-full flex-col items-start gap-2">
{!appliedDiscount && (
<span className="text-2xl"> <span className="text-2xl">
{p.price} {p.price}
{getSymbolFromCurrency(p.currency)} {getSymbolFromCurrency(p.currency)}
</span> </span>
<PayPalPayment )}
{appliedDiscount && (
<div className="flex items-center gap-2">
<span className="text-2xl line-through">
{p.price}
{getSymbolFromCurrency(p.currency)}
</span>
<span className="text-2xl text-mti-red-light">
{(p.price - p.price * (appliedDiscount / 100)).toFixed(2)}
{getSymbolFromCurrency(p.currency)}
</span>
</div>
)}
<PaymobPayment
key={clientID} key={clientID}
{...p} user={user}
clientID={clientID} setIsPaymentLoading={setIsLoading}
setIsLoading={setIsLoading}
onSuccess={() => { onSuccess={() => {
setTimeout(reload, 500); setTimeout(reload, 500);
}} }}
currency={p.currency}
duration={p.duration}
duration_unit={p.duration_unit}
price={+(p.price - p.price * (appliedDiscount / 100)).toFixed(2)}
/> />
</div> </div>
<div className="flex flex-col items-start gap-1"> <div className="flex flex-col items-start gap-1">
@@ -133,7 +162,6 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
</div> </div>
</div> </div>
))} ))}
</PayPalScriptProvider>
</div> </div>
</div> </div>
)} )}
@@ -152,10 +180,10 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
{user.corporateInformation.payment.value} {user.corporateInformation.payment.value}
{getSymbolFromCurrency(user.corporateInformation.payment.currency)} {getSymbolFromCurrency(user.corporateInformation.payment.currency)}
</span> </span>
<PayPalPayment <PaymobPayment
key={clientID} key={clientID}
clientID={clientID} user={user}
setIsLoading={setIsLoading} setIsPaymentLoading={setIsLoading}
currency={user.corporateInformation.payment.currency} currency={user.corporateInformation.payment.currency}
price={user.corporateInformation.payment.value} price={user.corporateInformation.payment.value}
duration={user.corporateInformation.monthlyDuration} duration={user.corporateInformation.monthlyDuration}
@@ -164,7 +192,6 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
setIsLoading(false); setIsLoading(false);
setTimeout(reload, 500); setTimeout(reload, 500);
}} }}
loadScript
/> />
</div> </div>
<div className="flex flex-col items-start gap-1"> <div className="flex flex-col items-start gap-1">

View File

@@ -1,15 +1,15 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import { toast, ToastContainer } from "react-toastify"; import {toast, ToastContainer} from "react-toastify";
import axios from "axios"; import axios from "axios";
import { FormEvent, useEffect, useState } from "react"; import {FormEvent, useEffect, useState} from "react";
import Head from "next/head"; import Head from "next/head";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
import { Divider } from "primereact/divider"; import {Divider} from "primereact/divider";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import { BsArrowRepeat } from "react-icons/bs"; import {BsArrowRepeat} from "react-icons/bs";
import Link from "next/link"; import Link from "next/link";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import { useRouter } from "next/router"; import {useRouter} from "next/router";
export function getServerSideProps({ export function getServerSideProps({
query, query,
@@ -23,11 +23,11 @@ export function getServerSideProps({
res: any; res: any;
}) { }) {
if (!query || !query.oobCode || !query.mode) { if (!query || !query.oobCode || !query.mode) {
res.setHeader("location", "/login");
res.statusCode = 302;
res.end();
return { return {
props: {}, redirect: {
destination: "/login",
permanent: false,
}
}; };
} }
@@ -35,20 +35,12 @@ export function getServerSideProps({
props: { props: {
code: query.oobCode, code: query.oobCode,
mode: query.mode, mode: query.mode,
...(query.continueUrl ? { continueUrl: query.continueUrl } : {}), ...(query.continueUrl ? {continueUrl: query.continueUrl} : {}),
}, },
}; };
} }
export default function Reset({ export default function Reset({code, mode, continueUrl}: {code: string; mode: string; continueUrl?: string}) {
code,
mode,
continueUrl,
}: {
code: string;
mode: string;
continueUrl?: string;
}) {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -62,8 +54,8 @@ export default function Reset({
useEffect(() => { useEffect(() => {
if (mode === "signIn") { if (mode === "signIn") {
axios axios
.post<{ ok: boolean }>("/api/reset/verify", { .post<{ok: boolean}>("/api/reset/verify", {
email: continueUrl?.replace("https://platform.encoach.com/", ""), email: continueUrl?.replace("https://platform.encoach.com/", "").replace("https://staging.encoach.com/", ""),
}) })
.then((response) => { .then((response) => {
if (response.data.ok) { if (response.data.ok) {
@@ -76,20 +68,14 @@ export default function Reset({
return; return;
} }
toast.error( toast.error("Something went wrong! Please make sure to click the link in your e-mail again and input the correct e-mail!", {
"Something went wrong! Please make sure to click the link in your e-mail again and input the correct e-mail!",
{
toastId: "verify-error", toastId: "verify-error",
}, });
);
}) })
.catch(() => { .catch(() => {
toast.error( toast.error("Something went wrong! Please make sure to click the link in your e-mail again and input the correct e-mail!", {
"Something went wrong! Please make sure to click the link in your e-mail again and input the correct e-mail!",
{
toastId: "verify-error", toastId: "verify-error",
}, });
);
setIsLoading(false); setIsLoading(false);
}); });
} }
@@ -100,7 +86,7 @@ export default function Reset({
setIsLoading(true); setIsLoading(true);
axios axios
.post<{ ok: boolean }>("/api/reset/confirm", { code, password }) .post<{ok: boolean}>("/api/reset/confirm", {code, password})
.then((response) => { .then((response) => {
if (response.data.ok) { if (response.data.ok) {
toast.success("Your password has been reset!", { toast.success("Your password has been reset!", {
@@ -112,16 +98,10 @@ export default function Reset({
return; return;
} }
toast.error( toast.error("Something went wrong! Please make sure to click the link in your e-mail again!", {toastId: "reset-error"});
"Something went wrong! Please make sure to click the link in your e-mail again!",
{ toastId: "reset-error" },
);
}) })
.catch(() => { .catch(() => {
toast.error( toast.error("Something went wrong! Please make sure to click the link in your e-mail again!", {toastId: "reset-error"});
"Something went wrong! Please make sure to click the link in your e-mail again!",
{ toastId: "reset-error" },
);
}) })
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
@@ -138,51 +118,24 @@ export default function Reset({
<ToastContainer /> <ToastContainer />
<section className="relative hidden h-full w-fit min-w-fit lg:flex"> <section className="relative hidden h-full w-fit min-w-fit lg:flex">
<div className="bg-mti-rose-light absolute z-10 h-full w-full bg-opacity-50" /> <div className="bg-mti-rose-light absolute z-10 h-full w-full bg-opacity-50" />
<img <img src="/people-talking-tablet.png" alt="People smiling looking at a tablet" className="aspect-auto h-full" />
src="/people-talking-tablet.png"
alt="People smiling looking at a tablet"
className="aspect-auto h-full"
/>
</section> </section>
{mode === "resetPassword" && ( {mode === "resetPassword" && (
<section className="flex h-full w-full flex-col items-center justify-center gap-2"> <section className="flex h-full w-full flex-col items-center justify-center gap-2">
<div className="relative flex flex-col items-center gap-2"> <div className="relative flex flex-col items-center gap-2">
<img <img src="/logo_title.png" alt="EnCoach's Logo" className="absolute -top-36 w-36 lg:-top-64 lg:w-64" />
src="/logo_title.png" <h1 className="text-2xl font-bold lg:text-4xl">Reset your password</h1>
alt="EnCoach's Logo" <p className="text-mti-gray-cool self-start text-sm font-normal lg:text-base">to your registered Email Address</p>
className="absolute -top-36 w-36 lg:-top-64 lg:w-64"
/>
<h1 className="text-2xl font-bold lg:text-4xl">
Reset your password
</h1>
<p className="text-mti-gray-cool self-start text-sm font-normal lg:text-base">
to your registered Email Address
</p>
</div> </div>
<Divider className="max-w-xs lg:max-w-md" /> <Divider className="max-w-xs lg:max-w-md" />
<form <form className="-lg:px-8 flex w-full flex-col items-center gap-6 lg:w-1/2" onSubmit={login}>
className="-lg:px-8 flex w-full flex-col items-center gap-6 lg:w-1/2" <Input type="password" name="password" onChange={(e) => setPassword(e)} placeholder="Password" />
onSubmit={login}
>
<Input
type="password"
name="password"
onChange={(e) => setPassword(e)}
placeholder="Password"
/>
<Button <Button className="mt-8 w-full" color="purple" disabled={isLoading}>
className="mt-8 w-full"
color="purple"
disabled={isLoading}
>
{!isLoading && "Reset"} {!isLoading && "Reset"}
{isLoading && ( {isLoading && (
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
<BsArrowRepeat <BsArrowRepeat className="animate-spin text-white" size={25} />
className="animate-spin text-white"
size={25}
/>
</div> </div>
)} )}
</Button> </Button>
@@ -198,25 +151,15 @@ export default function Reset({
{mode === "signIn" && ( {mode === "signIn" && (
<section className="flex h-full w-full flex-col items-center justify-center gap-2"> <section className="flex h-full w-full flex-col items-center justify-center gap-2">
<div className="relative flex flex-col items-center gap-2"> <div className="relative flex flex-col items-center gap-2">
<img <img src="/logo_title.png" alt="EnCoach's Logo" className="absolute -top-36 w-36 lg:-top-64 lg:w-64" />
src="/logo_title.png" <h1 className="text-2xl font-bold lg:text-4xl">Confirm your account</h1>
alt="EnCoach's Logo" <p className="text-mti-gray-cool self-start text-sm font-normal lg:text-base">to your registered Email Address</p>
className="absolute -top-36 w-36 lg:-top-64 lg:w-64"
/>
<h1 className="text-2xl font-bold lg:text-4xl">
Confirm your account
</h1>
<p className="text-mti-gray-cool self-start text-sm font-normal lg:text-base">
to your registered Email Address
</p>
</div> </div>
<Divider className="max-w-xs lg:max-w-md" /> <Divider className="max-w-xs lg:max-w-md" />
<div className="-lg:px-8 flex w-full flex-col items-center gap-6 lg:w-1/2"> <div className="-lg:px-8 flex w-full flex-col items-center gap-6 lg:w-1/2">
<span className="text-center"> <span className="text-center">
Your e-mail is currently being verified, please wait a second.{" "} Your e-mail is currently being verified, please wait a second. <br /> <br />
<br /> <br /> Once it has been verified, you will be redirected to the home page.
Once it has been verified, you will be redirected to the home
page.
</span> </span>
</div> </div>
</section> </section>

View File

@@ -0,0 +1,33 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
import { getFirestore, doc, getDoc, setDoc } from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function post(req: NextApiRequest, res: NextApiResponse) {
// verify if it's a logged user that is trying to archive
if (req.session.user) {
const { id } = req.query as { id: string };
const docSnap = await getDoc(doc(db, "assignments", id));
if (!docSnap.exists()) {
res.status(404).json({ ok: false });
return;
}
await setDoc(docSnap.ref, { archived: true }, { merge: true });
res.status(200).json({ ok: true });
return;
}
res.status(401).json({ ok: false });
}
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") return post(req, res);
res.status(404).json({ ok: false });
}

View File

@@ -370,7 +370,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
studentsData={studentsData} studentsData={studentsData}
showLevel={showLevel} showLevel={showLevel}
summaryPNG={overallPNG} summaryPNG={overallPNG}
summaryScore={`${(overallResult * 100).toFixed(0)}%`} summaryScore={`${Math.floor(overallResult * 100)}%`}
groupScoreSummary={groupScoreSummary} groupScoreSummary={groupScoreSummary}
passportId={demographicInformation?.passport_id || ""} passportId={demographicInformation?.passport_id || ""}
/>, />,

View File

@@ -0,0 +1,33 @@
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, doc, getDoc, setDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function post(req: NextApiRequest, res: NextApiResponse) {
// verify if it's a logged user that is trying to archive
if (req.session.user) {
const {id} = req.query as {id: string};
const docSnap = await getDoc(doc(db, "assignments", id));
if (!docSnap.exists()) {
res.status(404).json({ok: false});
return;
}
await setDoc(docSnap.ref, {archived: false}, {merge: true});
res.status(200).json({ok: true});
return;
}
res.status(401).json({ok: false});
}
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") return post(req, res);
res.status(404).json({ok: false});
}

View File

@@ -1,26 +1,17 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import { app } from "@/firebase"; import {app} from "@/firebase";
import { import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc} from "firebase/firestore";
getFirestore, import {withIronSessionApiRoute} from "iron-session/next";
collection, import {sessionOptions} from "@/lib/session";
getDocs, import {uuidv4} from "@firebase/util";
query, import {Module} from "@/interfaces";
where, import {getExams} from "@/utils/exams.be";
setDoc, import {Exam, InstructorGender, Variant} from "@/interfaces/exam";
doc, import {capitalize, flatten, uniqBy} from "lodash";
getDoc, import {User} from "@/interfaces/user";
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { uuidv4 } from "@firebase/util";
import { Module } from "@/interfaces";
import { getExams } from "@/utils/exams.be";
import { Exam, Variant } from "@/interfaces/exam";
import { capitalize, flatten, uniqBy } from "lodash";
import { User } from "@/interfaces/user";
import moment from "moment"; import moment from "moment";
import { sendEmail } from "@/email"; import {sendEmail} from "@/email";
const db = getFirestore(app); const db = getFirestore(app);
@@ -28,14 +19,14 @@ export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ ok: false }); res.status(401).json({ok: false});
return; return;
} }
if (req.method === "GET") return GET(req, res); if (req.method === "GET") return GET(req, res);
if (req.method === "POST") return POST(req, res); if (req.method === "POST") return POST(req, res);
res.status(404).json({ ok: false }); res.status(404).json({ok: false});
} }
async function GET(req: NextApiRequest, res: NextApiResponse) { async function GET(req: NextApiRequest, res: NextApiResponse) {
@@ -66,61 +57,47 @@ const generateExams = async (
selectedModules: Module[], selectedModules: Module[],
assignees: string[], assignees: string[],
variant?: Variant, variant?: Variant,
instructorGender?: InstructorGender,
): Promise<ExamWithUser[]> => { ): Promise<ExamWithUser[]> => {
if (generateMultiple) { if (generateMultiple) {
// for optimization purposes, it would be better to create a new endpoint that returned the answers for all users at once // for optimization purposes, it would be better to create a new endpoint that returned the answers for all users at once
const allExams = assignees.map(async (assignee) => { const allExams = assignees.map(async (assignee) => {
const selectedModulePromises = selectedModules.map( const selectedModulePromises = selectedModules.map(async (module: Module) => {
async (module: Module) => {
try { try {
const exams: Exam[] = await getExams( const exams: Exam[] = await getExams(db, module, "true", assignee, variant, instructorGender);
db,
module,
"true",
assignee,
variant,
);
const exam = exams[getRandomIndex(exams)]; const exam = exams[getRandomIndex(exams)];
if (exam) { if (exam) {
return { module: exam.module, id: exam.id, assignee }; return {module: exam.module, id: exam.id, assignee};
} }
return null; return null;
} catch (e) { } catch (e) {
console.error(e); console.error(e);
return null; return null;
} }
}, }, []);
[],
);
const newModules = await Promise.all(selectedModulePromises); const newModules = await Promise.all(selectedModulePromises);
return newModules; return newModules;
}, []); }, []);
const exams = flatten(await Promise.all(allExams)).filter( const exams = flatten(await Promise.all(allExams)).filter((x) => x !== null) as ExamWithUser[];
(x) => x !== null,
) as ExamWithUser[];
return exams; return exams;
} }
const selectedModulePromises = selectedModules.map(async (module: Module) => { const selectedModulePromises = selectedModules.map(async (module: Module) => {
const exams: Exam[] = await getExams(db, module, "false", undefined); const exams: Exam[] = await getExams(db, module, "false", undefined, variant, instructorGender);
const exam = exams[getRandomIndex(exams)]; const exam = exams[getRandomIndex(exams)];
if (exam) { if (exam) {
return { module: exam.module, id: exam.id }; return {module: exam.module, id: exam.id};
} }
return null; return null;
}); });
const exams = await Promise.all(selectedModulePromises); const exams = await Promise.all(selectedModulePromises);
const examesFiltered = exams.filter((x) => x !== null) as ExamWithUser[]; const examesFiltered = exams.filter((x) => x !== null) as ExamWithUser[];
return flatten( return flatten(assignees.map((assignee) => examesFiltered.map((exam) => ({...exam, assignee}))));
assignees.map((assignee) =>
examesFiltered.map((exam) => ({ ...exam, assignee })),
),
);
}; };
async function POST(req: NextApiRequest, res: NextApiResponse) { async function POST(req: NextApiRequest, res: NextApiResponse) {
@@ -131,6 +108,7 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
// false would generate the same exam for all users // false would generate the same exam for all users
generateMultiple = false, generateMultiple = false,
variant, variant,
instructorGender,
...body ...body
} = req.body as { } = req.body as {
selectedModules: Module[]; selectedModules: Module[];
@@ -140,19 +118,13 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
startDate: string; startDate: string;
endDate: string; endDate: string;
variant?: Variant; variant?: Variant;
instructorGender?: InstructorGender;
}; };
const exams: ExamWithUser[] = await generateExams( const exams: ExamWithUser[] = await generateExams(generateMultiple, selectedModules, assignees, variant, instructorGender);
generateMultiple,
selectedModules,
assignees,
variant,
);
if (exams.length === 0) { if (exams.length === 0) {
res res.status(400).json({ok: false, error: "No exams found for the selected modules"});
.status(400)
.json({ ok: false, error: "No exams found for the selected modules" });
return; return;
} }
@@ -161,16 +133,17 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
assignees, assignees,
results: [], results: [],
exams, exams,
instructorGender,
...body, ...body,
}); });
res.status(200).json({ ok: true }); res.status(200).json({ok: true});
for (const assigneeID of assignees) { for (const assigneeID of assignees) {
const assigneeSnapshot = await getDoc(doc(db, "users", assigneeID)); const assigneeSnapshot = await getDoc(doc(db, "users", assigneeID));
if (!assigneeSnapshot.exists()) continue; if (!assigneeSnapshot.exists()) continue;
const assignee = { id: assigneeID, ...assigneeSnapshot.data() } as User; const assignee = {id: assigneeID, ...assigneeSnapshot.data()} as User;
const name = body.name; const name = body.name;
const teacher = req.session.user!; const teacher = req.session.user!;
const examModulesLabel = uniqBy(exams, (x) => x.module) const examModulesLabel = uniqBy(exams, (x) => x.module)
@@ -182,7 +155,7 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
await sendEmail( await sendEmail(
"assignment", "assignment",
{ {
user: { name: assignee.name }, user: {name: assignee.name},
assignment: { assignment: {
name, name,
startDate, startDate,
@@ -190,6 +163,7 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
modules: examModulesLabel, modules: examModulesLabel,
assigner: teacher.name, assigner: teacher.name,
}, },
environment: process.env.ENVIRONMENT,
}, },
[assignee.email], [assignee.email],
"EnCoach - New Assignment!", "EnCoach - New Assignment!",

View File

@@ -10,6 +10,7 @@ const db = getFirestore(app);
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return GET(req, res); if (req.method === "GET") return GET(req, res);
if (req.method === "DELETE") return DELETE(req, res);
res.status(404).json({ok: false}); res.status(404).json({ok: false});
} }
@@ -21,3 +22,13 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
res.status(200).json({...snapshot.data(), id: snapshot.id}); res.status(200).json({...snapshot.data(), id: snapshot.id});
} }
async function DELETE(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query;
const snapshot = await getDoc(doc(db, "codes", id as string));
if (!snapshot.exists()) return res.status(404).json;
await deleteDoc(snapshot.ref);
res.status(200).json({...snapshot.data(), id: snapshot.id});
}

View File

@@ -9,10 +9,12 @@ import {
collection, collection,
where, where,
getDocs, getDocs,
getDoc,
deleteDoc,
} from "firebase/firestore"; } from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { Type } from "@/interfaces/user"; import { Code, Type } from "@/interfaces/user";
import { PERMISSIONS } from "@/constants/userPermissions"; import { PERMISSIONS } from "@/constants/userPermissions";
import { uuidv4 } from "@firebase/util"; import { uuidv4 } from "@firebase/util";
import { prepareMailer, prepareMailOptions } from "@/email"; import { prepareMailer, prepareMailOptions } from "@/email";
@@ -24,6 +26,7 @@ export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return get(req, res); if (req.method === "GET") return get(req, res);
if (req.method === "POST") return post(req, res); if (req.method === "POST") return post(req, res);
if (req.method === "DELETE") return del(req, res);
return res.status(404).json({ ok: false }); return res.status(404).json({ ok: false });
} }
@@ -37,7 +40,10 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
} }
const { creator } = req.query as { creator?: string }; const { creator } = req.query as { creator?: string };
const q = query(collection(db, "codes"), where("creator", "==", creator)); const q = query(
collection(db, "codes"),
where("creator", "==", creator || ""),
);
const snapshot = await getDocs(creator ? q : collection(db, "codes")); const snapshot = await getDocs(creator ? q : collection(db, "codes"));
res.status(200).json(snapshot.docs.map((doc) => doc.data())); res.status(200).json(snapshot.docs.map((doc) => doc.data()));
@@ -60,9 +66,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const permission = PERMISSIONS.generateCode[type]; const permission = PERMISSIONS.generateCode[type];
if (!permission.includes(req.session.user.type)) { if (!permission.includes(req.session.user.type)) {
res res.status(403).json({
.status(403)
.json({
ok: false, ok: false,
reason: reason:
"Your account type does not have permissions to generate a code for that type of user!", "Your account type does not have permissions to generate a code for that type of user!",
@@ -70,13 +74,14 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
return; return;
} }
if (req.session.user.type === "corporate") {
const codesGeneratedByUserSnapshot = await getDocs( const codesGeneratedByUserSnapshot = await getDocs(
query( query(collection(db, "codes"), where("creator", "==", req.session.user.id)),
collection(db, "codes"),
where("creator", "==", req.session.user.id),
),
); );
const userCodes = codesGeneratedByUserSnapshot.docs.map((x) => ({
...x.data(),
}));
if (req.session.user.type === "corporate") {
const totalCodes = codesGeneratedByUserSnapshot.docs.length + codes.length; const totalCodes = codesGeneratedByUserSnapshot.docs.length + codes.length;
const allowedCodes = const allowedCodes =
req.session.user.corporateInformation?.companyInformation.userAmount || 0; req.session.user.corporateInformation?.companyInformation.userAmount || 0;
@@ -94,21 +99,24 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const codePromises = codes.map(async (code, index) => { const codePromises = codes.map(async (code, index) => {
const codeRef = doc(db, "codes", code); const codeRef = doc(db, "codes", code);
const codeInformation = { let codeInformation = {
type, type,
code, code,
creator: req.session.user!.id, creator: req.session.user!.id,
creationDate: new Date().toISOString(),
expiryDate, expiryDate,
}; };
if (infos && infos.length > index) { if (infos && infos.length > index) {
const { email, name, passport_id } = infos[index]; const { email, name, passport_id } = infos[index];
const previousCode = userCodes.find((x) => x.email === email) as Code;
const transport = prepareMailer(); const transport = prepareMailer();
const mailOptions = prepareMailOptions( const mailOptions = prepareMailOptions(
{ {
type, type,
code, code: previousCode ? previousCode.code : code,
environment: process.env.ENVIRONMENT,
}, },
[email.toLowerCase().trim()], [email.toLowerCase().trim()],
"EnCoach Registration", "EnCoach Registration",
@@ -117,6 +125,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
try { try {
await transport.sendMail(mailOptions); await transport.sendMail(mailOptions);
if (!previousCode) {
await setDoc( await setDoc(
codeRef, codeRef,
{ {
@@ -127,6 +137,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
}, },
{ merge: true }, { merge: true },
); );
}
return true; return true;
} catch (e) { } catch (e) {
@@ -141,3 +152,23 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
res.status(200).json({ ok: true, valid: results.filter((x) => x).length }); res.status(200).json({ ok: true, valid: results.filter((x) => x).length });
}); });
} }
async function del(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res
.status(401)
.json({ ok: false, reason: "You must be logged in to generate a code!" });
return;
}
const codes = req.query.code as string[];
for (const code of codes) {
const snapshot = await getDoc(doc(db, "codes", code as string));
if (!snapshot.exists()) continue;
await deleteDoc(snapshot.ref);
}
res.status(200).json({ codes });
}

View File

@@ -0,0 +1,94 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
import {
getFirestore,
doc,
getDoc,
deleteDoc,
setDoc,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { PERMISSIONS } from "@/constants/userPermissions";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return get(req, res);
if (req.method === "DELETE") return del(req, res);
if (req.method === "PATCH") return patch(req, res);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
const { id } = req.query as { id: string };
const docRef = doc(db, "discounts", id);
const docSnap = await getDoc(docRef);
if (docSnap.exists()) {
res.status(200).json({
id: docSnap.id,
...docSnap.data(),
});
} else {
res.status(404).json(undefined);
}
}
async function patch(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
const { id } = req.query as { id: string };
const docRef = doc(db, "discounts", id);
const docSnap = await getDoc(docRef);
if (docSnap.exists()) {
if (!["developer", "admin"].includes(req.session.user.type)) {
res.status(403).json({ ok: false });
return;
}
await setDoc(docRef, req.body, { merge: true });
res.status(200).json({ ok: true });
} else {
res.status(404).json({ ok: false });
}
}
async function del(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
const { id } = req.query as { id: string };
const docRef = doc(db, "discounts", id);
const docSnap = await getDoc(docRef);
if (docSnap.exists()) {
if (!["developer", "admin"].includes(req.session.user.type)) {
res.status(403).json({ ok: false });
return;
}
await deleteDoc(docRef);
res.status(200).json({ ok: true });
} else {
res.status(404).json({ ok: false });
}
}

View File

@@ -0,0 +1,81 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
import {
getFirestore,
collection,
getDocs,
setDoc,
doc,
getDoc,
deleteDoc,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Group } from "@/interfaces/user";
import { Discount, Package } from "@/interfaces/paypal";
import { v4 } from "uuid";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") await get(req, res);
if (req.method === "POST") await post(req, res);
if (req.method === "DELETE") return del(req, res);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
const snapshot = await getDocs(collection(db, "discounts"));
res.status(200).json(
snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})),
);
}
async function post(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
if (!["developer", "admin"].includes(req.session.user!.type))
return res.status(403).json({
ok: false,
reason: "You do not have permission to create a new discount",
});
const body = req.body as Discount;
await setDoc(doc(db, "discounts", v4()), body);
res.status(200).json({ ok: true });
}
async function del(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res
.status(401)
.json({ ok: false, reason: "You must be logged in to generate a code!" });
return;
}
const discounts = req.query.discount as string[];
for (const discount of discounts) {
const snapshot = await getDoc(doc(db, "discounts", discount as string));
if (!snapshot.exists()) continue;
await deleteDoc(snapshot.ref);
}
res.status(200).json({ discounts });
}

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