Compare commits

..

1035 Commits

Author SHA1 Message Date
Tiago Ribeiro
720597e916 Merged develop into ENCOA-83_MasterStatistical 2024-08-21 16:18:37 +00:00
carlos.mesquita
e74ded676e Merged in feature/level-file-upload (pull request #77)
Forgot to check if exam had shuffled enabled

Approved-by: Tiago Ribeiro
2024-08-21 16:18:23 +00:00
carlos.mesquita
ee60eedd0d Merged develop into feature/level-file-upload 2024-08-21 12:03:57 +00:00
Carlos Mesquita
c37a1becbf Forgot to check if exam had shuffled enabled 2024-08-21 13:02:30 +01:00
João Ramos
b9cca483ec Merged develop into ENCOA-83_MasterStatistical 2024-08-20 23:39:57 +00:00
Joao Ramos
c758bdaf9e Merge branch 'ENCOA-83_MasterStatistical' of https://bitbucket.org/ecropdev/ielts-ui into ENCOA-83_MasterStatistical 2024-08-21 00:39:22 +01:00
Joao Ramos
5ada588b16 Reverted minor change 2024-08-21 00:38:26 +01:00
Joao Ramos
eec1bb0c30 Added final touches 2024-08-21 00:37:33 +01:00
Joao Ramos
65f8368708 Initial draft of level test report 2024-08-20 23:40:54 +01:00
Tiago Ribeiro
806e621c5b Updated the record for students 2024-08-20 22:43:34 +01:00
carlos.mesquita
ce35b23714 Merged in feature/level-file-upload (pull request #75)
Part intro's, modals between parts and some fixes

Approved-by: Tiago Ribeiro
2024-08-20 21:18:22 +00:00
Carlos Mesquita
2cd025b118 Patched part divider, forgot to check if the exam had field intro 2024-08-20 20:14:42 +01:00
Carlos Mesquita
2e699d7e25 Forgot to save TextComponent, and cleanup some warnings 2024-08-20 20:02:59 +01:00
Carlos Mesquita
30da295c60 Merge branch 'develop' of https://bitbucket.org/ecropdev/ielts-ui into feature/level-file-upload 2024-08-20 19:37:06 +01:00
Carlos Mesquita
a82a399d52 Merge branch 'feature/level-file-upload' of https://bitbucket.org/ecropdev/ielts-ui into feature/level-file-upload 2024-08-20 18:53:25 +01:00
Carlos Mesquita
505df31d6b Part intro's, modals between parts and some fixes 2024-08-20 18:52:38 +01:00
Tiago Ribeiro
a4d8ba72af Disallowed the name to be equal to Corporate 2024-08-20 17:41:06 +01:00
Tiago Ribeiro
2bfd0cb502 More stuff related to the groups 2024-08-20 17:38:48 +01:00
Tiago Ribeiro
5ee071028c Removed a WIP page 2024-08-20 17:14:54 +01:00
Tiago Ribeiro
23b9452a3a Another bug solved 2024-08-20 17:12:10 +01:00
Tiago Ribeiro
0ce3a16d3a Resolved another bug related to master corporate groups 2024-08-20 16:29:55 +01:00
Tiago Ribeiro
4315a7b17c Corrected a bug related to groups of a master corporate 2024-08-20 14:37:33 +01:00
Tiago Ribeiro
247f192a0a Added a scroll to the selection 2024-08-20 12:43:34 +01:00
João Ramos
9c944ae3d2 Merged in ENCOA-83_MasterStatistical (pull request #74)
ENCOA-83 MasterStatistical

Approved-by: Tiago Ribeiro
2024-08-20 10:14:19 +00:00
Tiago Ribeiro
a390aa429d Merged develop into ENCOA-83_MasterStatistical 2024-08-20 10:13:42 +00:00
Joao Ramos
3367384791 Merge branch 'ENCOA-83_MasterStatistical' of https://bitbucket.org/ecropdev/ielts-ui into ENCOA-83_MasterStatistical 2024-08-20 11:12:05 +01:00
Joao Ramos
158324a705 Added second excel for master corporate export 2024-08-20 11:10:51 +01:00
Tiago Ribeiro
f9286d1793 Updated the Dockerfile after having upgraded NextJS 2024-08-20 10:47:56 +01:00
João Ramos
2e376c37dd Merged in ENCOA-83_MasterStatistical (pull request #73)
ENCOA-83 MasterStatistical

Approved-by: Tiago Ribeiro
2024-08-20 09:33:42 +00:00
Tiago Ribeiro
5bda9ed227 Merge branch 'develop' into ENCOA-83_MasterStatistical 2024-08-20 10:32:19 +01:00
Tiago Ribeiro
97b533bd3a Took care of some warnings 2024-08-20 10:07:18 +01:00
Tiago Ribeiro
75a45108a2 Upgraded @types/react and @types/react-dom 2024-08-20 09:52:22 +01:00
Tiago Ribeiro
bfc0def20f Updated the exam list to be visible 2024-08-20 09:35:13 +01:00
Joao Ramos
9db33e6a51 Fixed date usage 2024-08-20 09:17:48 +01:00
Joao Ramos
ba5d926659 Merge branch 'develop' into ENCOA-83_MasterStatistical 2024-08-20 01:16:46 +01:00
Joao Ramos
1cd4dfc397 Added download option to master Corporate and teacher 2024-08-20 01:15:43 +01:00
Joao Ramos
bf5dd62b35 Updated Excel document 2024-08-20 01:01:50 +01:00
Tiago Ribeiro
4e583d11b6 Improved the bug on the teachers 2024-08-20 00:10:26 +01:00
Tiago Ribeiro
688505b4eb Updated the permissions access 2024-08-20 00:01:59 +01:00
carlos.mesquita
81b8ceb2b3 Merged in feature/level-file-upload (pull request #72)
Fixed question numbers for fillBlanks exercises, reverted multipleChoice underline prompt, added part label to module title, and changed some styles
2024-08-19 22:48:26 +00:00
carlos.mesquita
d93d36c392 Merged develop into feature/level-file-upload 2024-08-19 22:47:48 +00:00
Carlos Mesquita
3299acee36 Forgot to add a key in Level 2024-08-19 23:46:51 +01:00
Carlos Mesquita
abddead402 Fixed question numbers for fillBlanks exercises, reverted multipleChoice underline prompt, added part label to module title, and changed some styles 2024-08-19 23:43:08 +01:00
Joao Ramos
2d69fdac3c Added missing updated lockfile 2024-08-19 23:39:38 +01:00
Joao Ramos
506ff2503e Merge branch 'develop' into ENCOA-83_MasterStatistical 2024-08-19 23:38:46 +01:00
Tiago Ribeiro
5d191730d2 Added the total of assignments to the Master Corporate 2024-08-19 23:04:13 +01:00
Tiago Ribeiro
346b131388 Improved the sorting and filtering for the Student Performance page 2024-08-19 19:46:43 +01:00
carlos.mesquita
aba49e385f Merged in feature/level-file-upload (pull request #71)
Previously commented a required line
2024-08-19 16:25:52 +00:00
Carlos Mesquita
5789688eab Previously commented a required line 2024-08-19 17:24:10 +01:00
Tiago Ribeiro
f7da11bc69 Updated the way groups work there 2024-08-19 17:03:33 +01:00
carlos.mesquita
10802f6bb5 Merged in feature/level-file-upload (pull request #70)
Feature/level file upload
2024-08-19 15:55:10 +00:00
Carlos Mesquita
37e356572b Merge branch 'develop' of https://bitbucket.org/ecropdev/ielts-ui into feature/level-file-upload 2024-08-19 16:53:39 +01:00
Carlos Mesquita
8669ef462d Commented all related to shuffle 2024-08-19 16:42:14 +01:00
Tiago Ribeiro
df1c0bad4d Created a student performance page 2024-08-19 16:41:35 +01:00
Carlos Mesquita
bcb1a0f914 If someone else wants to join in on the fun be my guest 2024-08-19 01:24:55 +01:00
Joao Ramos
bf1bdd935c Improvements on excel rendering 2024-08-18 21:25:18 +01:00
Carlos Mesquita
edc9d4de2a Fill Blanks changes 2024-08-18 08:07:16 +01:00
Tiago Ribeiro
229275aaee Created a groups page for students and teachers 2024-08-17 20:18:28 +01:00
Tiago Ribeiro
f0ff6ac691 ENCOA-87: Allow MasterCorporate & Corporate to change the type of students and teachers 2024-08-17 19:15:20 +01:00
Tiago Ribeiro
878c7c2ef0 Updated the Groups List to allow teachers to view their corporate's students 2024-08-16 11:50:27 +01:00
Tiago Ribeiro
0a28c2bd41 Added a "last login" to the users 2024-08-15 23:55:08 +01:00
Tiago Ribeiro
38e48c90bb Updated the label for Admin 2024-08-15 23:37:34 +01:00
Tiago Ribeiro
c6f35d7750 Enable the option to not have only full exams 2024-08-15 19:25:39 +01:00
Tiago Ribeiro
85f684dff5 Updated the user balance to be based not only on the amount of codes 2024-08-15 19:24:08 +01:00
Tiago Ribeiro
d94a9bb88a Quick little fix 2024-08-15 18:39:17 +01:00
Joao Ramos
1950d5f15d Added initial Excel changes 2024-08-15 14:56:14 +01:00
Joao Ramos
e84cc8ddd8 Cleaned up some code 2024-08-15 13:39:42 +01:00
Joao Ramos
cf2fd06d39 Added Icon ofr consolidate highest student 2024-08-15 10:38:56 +01:00
Joao Ramos
b6015b6433 Added any to error to prevent an error 2024-08-15 10:35:08 +01:00
Joao Ramos
fea58a7b40 Added initial implementation for master statistical 2024-08-15 10:34:31 +01:00
João Ramos
13284eab75 Merged in ENCOA-82_Permissions (pull request #69)
Updated permissions to have a key to group them
2024-08-14 19:38:30 +00:00
Tiago Ribeiro
dd4e3a4694 Merged develop into ENCOA-82_Permissions 2024-08-14 19:38:16 +00:00
Tiago Ribeiro
eb55e65d91 Solved some issues related to the BatchCreateUser 2024-08-14 20:36:47 +01:00
Joao Ramos
cb75ba6056 Updated permissions to have a key to group them 2024-08-14 18:44:35 +01:00
João Ramos
859d9283a7 Merged in ENCOA-77_GenerationTitle (pull request #64)
Added title to the exam generate

Approved-by: Tiago Ribeiro
2024-08-13 21:40:04 +00:00
Tiago Ribeiro
1a3437b333 Merged develop into ENCOA-77_GenerationTitle 2024-08-13 21:38:56 +00:00
João Ramos
bbbf17daa0 Merged in ENCOA-79_PaymentRecordsFilters (pull request #61)
ENCOA-79 PaymentRecordsFilters

Approved-by: Tiago Ribeiro
2024-08-13 21:38:38 +00:00
Joao Ramos
ae79aef132 Merge branch 'develop' into ENCOA-79_PaymentRecordsFilters 2024-08-13 21:32:33 +01:00
Joao Ramos
c3e71b4389 Merge branch 'develop' into ENCOA-77_GenerationTitle 2024-08-13 21:30:50 +01:00
Tiago Ribeiro
2784117862 Updated the MasterCorporate and Corporate pages to allow to have Assignments 2024-08-13 10:02:40 +01:00
Tiago Ribeiro
8162567e12 Updated the Batch Create User to also have an expiry date 2024-08-12 19:49:18 +01:00
Tiago Ribeiro
58300e32ff Solved an issue where, for developers, because of the amount of permissions, the cookie was too big, so I separated the permissions logic into a hook 2024-08-12 19:35:11 +01:00
carlos.mesquita
cb489bf0ca Merged in feature/training-content (pull request #67)
Tooltips for assessment criteria on Writing and Speaking

Approved-by: Tiago Ribeiro
2024-08-12 14:13:28 +00:00
Tiago Ribeiro
91bc91e725 Merged develop into feature/training-content 2024-08-12 14:12:55 +00:00
João Ramos
ce086a8b22 Merged in ENCOA-81_CodeListPermissions (pull request #68)
ENCOA-81: Fixed issue with the users that each role could create

Approved-by: Tiago Ribeiro
2024-08-12 14:12:25 +00:00
Joao Ramos
6e71ee7cb0 ENCOA-81: Fixed issue with the users that each role could create 2024-08-08 09:17:33 +01:00
Carlos Mesquita
21e58e3b9c Tooltips for assessment criteria on Writing and Speaking 2024-08-07 13:04:42 +01:00
João Ramos
b885dd46b5 Merged in ENCOA-80_Permissions (pull request #66)
ENCOA-80 Permissions

Approved-by: Tiago Ribeiro
2024-08-07 09:30:04 +00:00
Joao Ramos
0fc2df1070 Added permission to codes 2024-08-07 10:03:17 +01:00
Joao Ramos
cf91f1812d Added group permissions 2024-08-07 09:36:08 +01:00
Tiago Ribeiro
3289f27cd5 Merged in settings-import-users (pull request #65)
Settings import users
2024-08-07 06:48:37 +00:00
Tiago Ribeiro
80939d16a5 Merged develop into settings-import-users 2024-08-07 06:47:59 +00:00
carlos.mesquita
11b5490af4 Merged in feature/training-content (pull request #62)
Feature/training content

Approved-by: Tiago Ribeiro
2024-08-06 18:46:43 +00:00
João Ramos
a31070d4a3 Merged in ENCOA-78_ExamList (pull request #63)
Added more columns to exam list

Approved-by: Tiago Ribeiro
2024-08-06 18:46:20 +00:00
Joao Ramos
95c3f89911 Added title to the exam generate 2024-08-06 19:24:43 +01:00
Joao Ramos
2a58e0d33f Added more columns to exam list 2024-08-06 18:40:49 +01:00
mohammedzeglam-pg
afe59f5a3a add group creation. 2024-08-06 18:03:00 +02:00
Carlos Mesquita
7fd56357e0 UI changes to Solutions (Writing, Speaking and Interactive Speaking) as requested 2024-08-06 17:02:17 +01:00
Carlos Mesquita
a4a40b9145 Bug fixes to training, added a spinner to record while it loads, made changes to speaking as requested 2024-08-05 21:44:14 +01:00
Joao Ramos
48faee07f6 Added missing fussy search fileds and support for number 2024-08-05 19:32:53 +01:00
Joao Ramos
f0d7d7644b Added date filter 2024-08-05 19:26:50 +01:00
Carlos Mesquita
309dfba583 Fixed the training content view 2024-08-05 10:41:11 +01:00
carlos.mesquita
cf64a91651 Merged in feature/training-content (pull request #60)
Patched the module badges on the training view
2024-08-03 14:23:05 +00:00
Carlos Mesquita
0f47a8af70 Merge branch 'feature/training-content' of https://bitbucket.org/ecropdev/ielts-ui into feature/training-content 2024-08-03 14:33:55 +01:00
Carlos Mesquita
d0310f7c2b Patched the module badges on the training view 2024-08-03 14:32:54 +01:00
carlos.mesquita
f6a0a391b9 Merged in feature/training-content (pull request #59)
Dropdown on training view
2024-08-03 10:13:44 +00:00
Tiago Ribeiro
8dd4dad096 Merged develop into feature/training-content 2024-08-03 10:13:37 +00:00
Carlos Mesquita
96baa2a6e0 Dropdown on training view 2024-08-03 11:10:01 +01:00
carlos.mesquita
8dd557a29b Merged in feature/training-content (pull request #58)
Feature/training content

Approved-by: Tiago Ribeiro
2024-08-03 09:49:00 +00:00
mzerone
4e30eda06f make user verified by default. 2024-08-03 01:37:41 +02:00
Carlos Mesquita
12bb124d91 Merge branch 'develop' of https://bitbucket.org/ecropdev/ielts-ui into feature/training-content 2024-08-02 15:01:05 +01:00
Carlos Mesquita
a71e6632d6 Hooked training content to backend, did refactors to training components and record.tsx 2024-08-01 20:40:31 +01:00
mzerone
36f518afca add create user in settings. 2024-08-01 00:41:35 +02:00
Carlos Mesquita
a534126c61 training.tsx still a bit messy, all that is left is to retrieve data from firestore /training and /walkthrough and render it 2024-07-31 20:44:46 +01:00
Tiago Ribeiro
752a46b247 Updated the LevelGeneration to be enabled by default 2024-07-31 19:15:36 +01:00
Tiago Ribeiro
663b1aae4f Updated the yarn.lock 2024-07-31 10:42:47 +01:00
Tiago Ribeiro
9b37b60be0 Improved the table 2024-07-30 23:22:30 +01:00
Tiago Ribeiro
4347d0cabb Merge branch 'develop' into feature/update-permission-ui 2024-07-30 23:20:16 +01:00
Tiago Ribeiro
0403773b8e Changed the IDs to now be words and allows the assignment to be like chosen 2024-07-30 23:18:50 +01:00
mzerone
8d99a6b03c ui updates for permissions. 2024-07-29 16:16:14 +02:00
Tiago Ribeiro
02320b9484 Tiny issue 2024-07-28 11:22:46 +01:00
Tiago Ribeiro
fb077fd8cc Updated the multiple choice 2024-07-27 17:43:26 +01:00
Tiago Ribeiro
b5a305485f Finalized the reading generation 2024-07-27 14:55:48 +01:00
Tiago Ribeiro
8f5b27e9ce Updated the FillBlanks to the new format 2024-07-27 14:38:45 +01:00
Tiago Ribeiro
9ef04b822a Updated the design of the feedback 2024-07-27 00:04:52 +01:00
Tiago Ribeiro
a6160c3cf0 Updated and fixed the level generation 2024-07-26 23:43:23 +01:00
Tiago Ribeiro
8f6639b7fc Finalized the Level Generation 2024-07-26 14:09:08 +01:00
Tiago Ribeiro
6a803fe137 Merge branch 'develop' of bitbucket.org:ecropdev/ielts-ui into develop 2024-07-26 11:51:03 +01:00
Carlos Mesquita
d7f6a4dde7 Undefined was causing record page to crash 2024-07-26 11:00:18 +01:00
Tiago Ribeiro
6058e510de Added the ability to generate custom level exams, still WIP in some parts 2024-07-26 10:29:36 +01:00
carlos.mesquita
7208530879 Merged in feature/ai-detection (pull request #57)
Used main branch as base branch in the last time

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Approved-by: Tiago Ribeiro
2024-02-06 20:45:13 +00:00
Joao Ramos
044a4f91aa Added PDF version to footer 2024-02-06 19:19:03 +00:00
Tiago Ribeiro
65fe1ec8ed Made it so it currently is possible to save the progress on the Writing exercise as well 2024-02-06 15:51:55 +00:00
Tiago Ribeiro
779fb76b8b Solved some issues related to the listening loading 2024-02-06 15:24:51 +00:00
Tiago Ribeiro
4ec439492e Added the capability for users to resume their previously stopped sessions 2024-02-06 14:44:22 +00:00
Tiago Ribeiro
c4b61c4787 - Adapted the exam to store all of its information to Zustand;
- Made it so, every time there is a change or every X seconds, it saves the session;
2024-02-06 12:34:45 +00:00
Joao Ramos
934394b17f Added approach to allow versioning of PDFs (Requires env var!) 2024-02-05 20:01:50 +00:00
Tiago Ribeiro
8baa25c445 Added a Scroll To Top function 2024-02-05 17:59:46 +00:00
Tiago Ribeiro
f6166ca9e1 Added an "instructions" panel to the Listening before it actually starts 2024-02-05 14:52:35 +00:00
Tiago Ribeiro
e6017854fd From now on, when a student accepts an invite from a corporate, they are removed from previous corporate groups 2024-02-05 10:40:39 +00:00
Tiago Ribeiro
0bd8b0ab24 Solved a small display issue 2024-02-05 10:07:04 +00:00
Tiago Ribeiro
401d212d85 Added a better way to distinguish the options 2024-02-04 00:32:34 +00:00
Tiago Ribeiro
9383929ebb Merge branch 'develop' of bitbucket.org:ecropdev/ielts-ui into develop 2024-02-04 00:23:59 +00:00
Tiago Ribeiro
5dcab23fdb Extracted the PayPalScriptProvider 2024-02-03 23:40:31 +00:00
Joao Ramos
d111be2f70 Fixed the assignments export based on unique exams. 2024-02-03 22:51:21 +00:00
Tiago Ribeiro
00c171b161 Merge branch 'develop' 2024-02-03 15:15:24 +00:00
João Ramos
53d3f843da Merged in stats-tooltip (pull request #30)
Added tooltip to stats screen
2024-02-03 15:14:49 +00:00
Tiago Ribeiro
8d7f312a83 Merged develop into stats-tooltip 2024-02-03 15:14:25 +00:00
Joao Ramos
6f11818876 Added tooltip to stats screen 2024-02-03 15:11:19 +00:00
Tiago Ribeiro
81bc4e7a0c Merge branch 'develop' 2024-02-03 15:02:49 +00:00
Tiago Ribeiro
48265a8e54 Reverted the name 2024-02-03 15:01:54 +00:00
Tiago Ribeiro
0053105dd3 Updated the use of the Desired Levels to be configurable 2024-02-03 14:46:35 +00:00
Tiago Ribeiro
846d829d10 Had left some code behind? 2024-02-03 12:40:27 +00:00
Tiago Ribeiro
c0c3e37568 Aligned the selection text to the left;\nUpdated the service account for the Firebase. 2024-02-01 14:00:34 +00:00
Tiago Ribeiro
a872190e1b Turned it bold 2024-01-31 23:45:36 +00:00
Tiago Ribeiro
147a450be2 Changed the Level test finish screen to only show the grade 2024-01-31 23:45:09 +00:00
Tiago Ribeiro
908ce5b5b9 Added an Invite list to the payment due page as well 2024-01-31 23:42:46 +00:00
Tiago Ribeiro
0ec62c107c Corrected a bug where a corporate with unlimited subscription could not generate multiple codes 2024-01-31 23:00:43 +00:00
Tiago Ribeiro
626655d0d0 Merge branch 'develop' 2024-01-31 11:46:07 +00:00
Tiago Ribeiro
16eeba76fd Added the tickets link to the mobile menu 2024-01-31 11:45:23 +00:00
Tiago Ribeiro
85729116e7 Updated the tickets to allow agents to also view theirs 2024-01-31 10:47:14 +00:00
Tiago Ribeiro
2de9636c8b Merged in feature/ticket-system (pull request #29)
Features: Ticket and Invite systems
2024-01-30 18:27:49 +00:00
Tiago Ribeiro
bcad5b5646 Added date sorting to the ticket list 2024-01-30 18:02:21 +00:00
Tiago Ribeiro
4e40dc9c8c Prevented the creation of multiple equal invites 2024-01-30 17:50:48 +00:00
Tiago Ribeiro
e3bcaf6b30 Merge branch 'develop' into feature/ticket-system 2024-01-29 21:03:47 +00:00
Tiago Ribeiro
a35c85545e Updated the code to set the participant's expiration date to use the corporate's one if it is better 2024-01-29 21:02:52 +00:00
Tiago Ribeiro
c4707d6426 Merge branch 'develop' into feature/ticket-system 2024-01-29 15:41:20 +00:00
Tiago Ribeiro
3564d0af6b Solved the same bug but for the e-mail 2024-01-29 15:40:50 +00:00
Tiago Ribeiro
e7acdb5858 Merge branch 'develop' into feature/ticket-system 2024-01-29 15:33:55 +00:00
Tiago Ribeiro
8bff64dd13 Solved a bug on the assignments because of the multiple exams 2024-01-29 15:31:41 +00:00
Tiago Ribeiro
2c4168a014 Created an e-mail type to be sent to the ticket reporter with the ticket's information 2024-01-29 13:19:12 +00:00
Tiago Ribeiro
800d04da37 Updated the login and register to transform the e-mail to lowercase 2024-01-29 09:53:47 +00:00
Tiago Ribeiro
b7b2718387 Merge branch 'develop' into feature/ticket-system 2024-01-29 09:51:50 +00:00
Tiago Ribeiro
a862e59574 Updated the login and register to transform the e-mail to lowercase 2024-01-29 09:51:11 +00:00
Tiago Ribeiro
688d8ba0b2 Created a simple invite system that notifies users via e-mail when a corporate uploads an Excel file with already registered students 2024-01-29 09:36:59 +00:00
Tiago Ribeiro
8b7e550a70 Created a new page for ticket handling as well as submission 2024-01-28 21:05:17 +00:00
João Ramos
cf1cb6f270 Merged in bug-fixing-27-jan-24 (pull request #28)
Bug fixing 27 jan 24
2024-01-28 18:47:10 +00:00
Tiago Ribeiro
476a6b0188 Merged develop into bug-fixing-27-jan-24 2024-01-28 18:46:54 +00:00
Tiago Ribeiro
01e55f970d Solved a bug where if two evaluations where too fast, they would overwrite each other 2024-01-28 18:10:56 +00:00
Joao Ramos
bca73dff2e Fixed userid 2024-01-27 20:10:15 +00:00
Joao Ramos
aef3800c08 Fixed issue with serialization on forgot password locally 2024-01-27 20:08:44 +00:00
Joao Ramos
a40c21ca53 Fixed table columns headers 2024-01-27 20:08:10 +00:00
Joao Ramos
34b1c7f25b Fixed footer paddings 2024-01-27 20:07:40 +00:00
Joao Ramos
7c641508ce Added user id to the footer of the report 2024-01-27 19:34:15 +00:00
Joao Ramos
4163076524 Added passportid to the table ; removed gender column from table 2024-01-27 19:27:28 +00:00
Joao Ramos
009c610033 Removed some candidate information 2024-01-27 19:26:48 +00:00
Joao Ramos
c05df7d6b7 Removed condition that blocked admins/devs from exporting a pdf for assignment 2024-01-27 19:12:05 +00:00
Joao Ramos
b881969bd4 Exporting a report from an user now allows access to the user data of the user that did the exam instead of the current session 2024-01-27 19:10:41 +00:00
Tiago Ribeiro
5e6af11156 Update the Group List to always allow editing for developers and admins 2024-01-26 19:13:44 +00:00
Tiago Ribeiro
c1162c5e88 Merge branch 'develop' 2024-01-26 16:33:55 +00:00
Tiago Ribeiro
213bdd0c8f Added the ability for developers to choose what screen they wanna see 2024-01-26 16:21:34 +00:00
Tiago Ribeiro
13401562fb Added the ability for assignments to use partial exams as well 2024-01-26 16:16:28 +00:00
Tiago Ribeiro
4e199931aa Solved another weird bug 2024-01-26 11:49:03 +00:00
Tiago Ribeiro
3eafc799ab Solved a quick bug 2024-01-26 11:41:31 +00:00
Tiago Ribeiro
9b87764afb Updated the code generator to only generate after the e-mails are sent 2024-01-25 12:14:12 +00:00
Tiago Ribeiro
a969e90c98 Added a confirmation when generating codes 2024-01-25 11:32:29 +00:00
Tiago Ribeiro
c38c1d9ff6 Removed the need for it to alway use Passport ID 2024-01-25 10:28:45 +00:00
Tiago Ribeiro
bcacbbdd15 Made the phone row optional 2024-01-25 10:19:06 +00:00
Tiago Ribeiro
fa481dc50e Merge branch 'develop' 2024-01-24 15:58:35 +00:00
Tiago Ribeiro
710c7931aa Updated the permissions to disallow corporate and teachers from editing other users 2024-01-24 15:40:05 +00:00
Tiago Ribeiro
d3f80603c4 Improved the Speaking Generation 2024-01-24 15:37:51 +00:00
Tiago Ribeiro
fea2d311ae Updated it so an agent can't edit the commission on a corporate 2024-01-24 15:24:45 +00:00
Tiago Ribeiro
5f475fb7a7 Finalized the Speaking exam generation 2024-01-24 00:37:54 +00:00
Tiago Ribeiro
bd0fab4c8f Updated and fixed part of the partial test generation 2024-01-23 11:45:32 +00:00
Tiago Ribeiro
74d3f30c93 Added the ability to choose between partial and full exams 2024-01-23 10:11:04 +00:00
Tiago Ribeiro
67c2e06575 Improved the Generation frontend 2024-01-22 23:34:42 +00:00
Tiago Ribeiro
506ee1e0e4 Updated the zIndex of the Select 2024-01-22 22:56:08 +00:00
Tiago Ribeiro
81943dbf42 Updated the module generation to allow for only certain parts to be made 2024-01-22 18:50:12 +00:00
Tiago Ribeiro
c868ea8795 Turned the nav to Gray when it is disabled 2024-01-22 14:18:08 +00:00
Tiago Ribeiro
cfde8ac9f0 Updated it so the Corporate is updated into Active when its payment is accepted 2024-01-21 20:35:35 +00:00
Tiago Ribeiro
8c1da3a84a Updated the UserCard to only show buttons to adequate users 2024-01-21 20:28:42 +00:00
Tiago Ribeiro
52143d2472 Solved a bug that caused some user's profile page to crash 2024-01-21 20:23:48 +00:00
Tiago Ribeiro
c7f303e410 Solved a bug 2024-01-21 20:20:08 +00:00
Tiago Ribeiro
da93b79c78 Solved an issue with sorting 2024-01-21 13:34:48 +00:00
Tiago Ribeiro
83b8ab7774 Allowed admins and others to download reports related to other users 2024-01-21 12:48:29 +00:00
Tiago Ribeiro
f6bb69f994 Updated the condition to close assignment: to be end date or when all students finish the assignment 2024-01-21 00:30:44 +00:00
Tiago Ribeiro
a97c40dc47 Merge branch 'develop' 2024-01-20 15:24:38 +00:00
Tiago Ribeiro
3de0357369 Oops, left an ID accidentally 2024-01-20 15:24:02 +00:00
Tiago Ribeiro
8eb8a7af46 Added a new card for the Corporate to show their user balance 2024-01-20 15:09:42 +00:00
Tiago Ribeiro
9773f1da72 Updated the user deletion to allow corporate to remove users from their groups, instead of deleting them 2024-01-20 13:33:22 +00:00
Tiago Ribeiro
2ef86344cd Updated the Assignment default start date to be the current time 2024-01-20 01:17:22 +00:00
Tiago Ribeiro
5e8b6f96bb Added a timezone selector to the Demographic Input 2024-01-20 01:15:12 +00:00
Tiago Ribeiro
b757cbbed7 Solved a date sorting bug 2024-01-20 01:09:03 +00:00
Tiago Ribeiro
4e08afb259 Merge branch 'develop' 2024-01-18 00:27:31 +00:00
Tiago Ribeiro
68069d118f Added correction visualizers for the Speaking transcript correction 2024-01-17 23:40:46 +00:00
Tiago Ribeiro
74dcccf089 Added a new type of e-mail to send to students when a new assignment is created 2024-01-17 15:55:33 +00:00
Tiago Ribeiro
b7ae9fb837 Merge branch 'develop' 2024-01-17 14:29:33 +00:00
Tiago Ribeiro
63d2baf35f Improved the overall redirection of the login page 2024-01-17 14:25:37 +00:00
Tiago Ribeiro
c02a6a01f4 Merge branch 'develop' into feature/writing-diff-viewer 2024-01-17 12:58:44 +00:00
Tiago Ribeiro
a646955493 Solved a bug with calculations of the stats page 2024-01-17 11:59:40 +00:00
Tiago Ribeiro
7a577a7ca2 Solved another stupid bug 2024-01-17 11:50:50 +00:00
Tiago Ribeiro
c26ff48b60 Solved some issues with the Student Dashboard 2024-01-17 11:32:20 +00:00
Tiago Ribeiro
9ee09c8fda Added a diff viewer for the writing correction 2024-01-17 11:22:23 +00:00
João Ramos
d4867fd9a2 Merged in bug-fixing-16-jan-24 (pull request #26)
Report PDF improvements / bugs

Approved-by: Tiago Ribeiro
2024-01-16 23:20:00 +00:00
Tiago Ribeiro
13e52bfce6 Merge branch 'develop' into bug-fixing-16-jan-24 2024-01-16 23:14:05 +00:00
Tiago Ribeiro
5540e4a3e6 Updated the profile page a bit to accommodate recent changes 2024-01-16 23:11:16 +00:00
João Ramos
a18ee93909 Merged in feature-user-timezone-pdf (pull request #25)
Added Date export based on user timezone

Approved-by: Tiago Ribeiro
2024-01-16 23:07:07 +00:00
Tiago Ribeiro
0641d4250c Merged develop into feature-user-timezone-pdf 2024-01-16 23:03:46 +00:00
Tiago Ribeiro
f85a1f5601 Updated part of the correction display for Writing 2024-01-16 23:00:58 +00:00
Joao Ramos
6bcc303b74 Fixed institution print 2024-01-16 22:24:08 +00:00
Joao Ramos
8002c71b91 Fixed issue with 100% being hyphenized 2024-01-16 22:22:55 +00:00
Joao Ramos
31d3232f19 Added passport id to PDF 2024-01-16 19:24:19 +00:00
Joao Ramos
4448c2019e Added some bold text to PDF footer 2024-01-16 18:48:01 +00:00
Joao Ramos
01a9da3a5b Added Date export based on user timezone 2024-01-16 18:42:12 +00:00
Tiago Ribeiro
d0b0dfb16f Solved a bug with the UserCard 2024-01-16 16:30:38 +00:00
Tiago Ribeiro
c5007a316f Updated the profile of the Corporate user according to the client's instructions 2024-01-16 16:26:59 +00:00
Tiago Ribeiro
c68e206aae Updated the Group creation modal to use Excel 2024-01-15 21:32:54 +00:00
Tiago Ribeiro
2bad3ad09f Solved: A second “Next” button appears on Listening part transitions 2024-01-15 21:21:08 +00:00
Tiago Ribeiro
f9e037bd7b Updated to "Linked to:" 2024-01-15 21:01:38 +00:00
Tiago Ribeiro
ccde1c84b7 Added a log for the exam for developers 2024-01-15 20:35:11 +00:00
Tiago Ribeiro
367553eb44 Added associated corporate’s name to Students and Teachers 2024-01-15 20:27:20 +00:00
Tiago Ribeiro
576d2ac29d Merge branch 'develop' of bitbucket.org:ecropdev/ielts-ui into develop 2024-01-15 20:19:21 +00:00
João Ramos
e13af65d88 Merged in bug-missing-radial-performance-sumary (pull request #24)
Fixed Missing radial performance sumary

Approved-by: Tiago Ribeiro
2024-01-15 20:13:29 +00:00
Joao Ramos
294d319ab3 Removed debuggers 2024-01-15 19:47:53 +00:00
Joao Ramos
7572909b13 Removed unnecessary margin ruining percentage centered 2024-01-15 19:33:31 +00:00
Joao Ramos
46b9fe50ef Added the missing radial progress 2024-01-15 19:32:11 +00:00
Tiago Ribeiro
1335c14acc Removed the ability for a Teacher to upload a file for the Group creation 2024-01-15 16:34:06 +00:00
Tiago Ribeiro
e47607597c Solved some bugs related to the payment page 2024-01-15 14:52:17 +00:00
Tiago Ribeiro
b7b2dca2dd Updated the user deletion to work in the backend 2024-01-15 11:02:40 +00:00
Tiago Ribeiro
a14c9f8b3c Updated the label of the cancel button on FillBlanks 2024-01-15 10:20:23 +00:00
Tiago Ribeiro
e59d36e892 Updated the UserCard to not show the Commission for corporate users 2024-01-15 10:11:18 +00:00
Tiago Ribeiro
f5bdedee2f Updated the message of the failed delete payment 2024-01-14 23:35:48 +00:00
Tiago Ribeiro
3f0821eb33 Added the corporate name to the user's top-right profile link 2024-01-14 23:31:50 +00:00
Tiago Ribeiro
31e09c94c7 Added an explanation for the Excel file format requested 2024-01-14 23:29:22 +00:00
Tiago Ribeiro
404e5a8a0c Added a * to required fields 2024-01-14 23:20:12 +00:00
Tiago Ribeiro
b7a3778f01 Solved another bug with the TrueFalse 2024-01-14 23:18:51 +00:00
Tiago Ribeiro
24ec336dca Updated the Record to start with the overall screen 2024-01-14 23:13:12 +00:00
Tiago Ribeiro
e324b37942 Prepared for partial exams 2024-01-14 22:36:39 +00:00
Tiago Ribeiro
066baa9492 Solved a bug with the WriteBlanks warning 2024-01-14 22:27:48 +00:00
Tiago Ribeiro
08aec9b54c Solved some bugs with reading the Excel file 2024-01-14 22:18:15 +00:00
Tiago Ribeiro
10a480aa81 Updated the Code generators select to depend on the type of user 2024-01-14 22:08:17 +00:00
Tiago Ribeiro
9baf3109c9 Merge branch 'develop' 2024-01-14 07:44:23 +00:00
João Ramos
360e6f8f60 Merged in bug-fixing-13-jan-24 (pull request #23)
Editing country manager is now only available for admins/dev
2024-01-14 00:10:25 +00:00
Tiago Ribeiro
eadddbf505 Merged develop into bug-fixing-13-jan-24 2024-01-14 00:09:32 +00:00
Joao Ramos
be03760cb9 Editing country amanger is now only available for admins/dev 2024-01-13 23:58:03 +00:00
Tiago Ribeiro
99758d860d BatchCodeGenerator.tsx edited online with Bitbucket 2024-01-13 18:54:21 +00:00
Tiago Ribeiro
8aca34e8b5 Merged in Tiago-Ribeiro/batchcodegeneratortsx-edited-online-with-1705170801981 (pull request #22)
BatchCodeGenerator.tsx edited online with Bitbucket
2024-01-13 18:34:01 +00:00
Tiago Ribeiro
aaaf7f646d BatchCodeGenerator.tsx edited online with Bitbucket 2024-01-13 18:33:26 +00:00
João Ramos
51dcb69b81 Merged in bug-fixing-12-jan-24 (pull request #21)
Bug fixing 12 jan 24

Approved-by: Tiago Ribeiro
2024-01-12 21:30:28 +00:00
Joao Ramos
580ddfd9e6 Fixed Pending payment page key 2024-01-12 21:27:46 +00:00
Joao Ramos
9e6dc4b4c2 Reviewed all IconCard for non-matching icons 2024-01-12 20:00:31 +00:00
Joao Ramos
72b9e1f11d Added payment done and pending visible to Admin and Developers without filters 2024-01-12 19:55:46 +00:00
Tiago Ribeiro
ad1dbaef27 Formatted the code to accept .xlsx 2024-01-12 13:49:27 +00:00
Tiago Ribeiro
6cdee9b268 Merge branch 'develop' into feature/62/upload-users-with-excel 2024-01-12 13:42:25 +00:00
João Ramos
7f4d82072f Merged in feature-payment-done-pending (pull request #20)
Added payment done and pending

Approved-by: Tiago Ribeiro
2024-01-12 10:15:12 +00:00
João Ramos
e365640620 Merged in bug-fixing-11-jan-24 (pull request #19)
Bug fixing 11 jan 24

Approved-by: Tiago Ribeiro
2024-01-12 07:55:42 +00:00
Joao Ramos
27a4014f63 Added payment done and pending 2024-01-12 01:38:34 +00:00
Joao Ramos
cb91acdded Removed some horizontal margins on PDFs 2024-01-12 00:32:02 +00:00
Joao Ramos
7714854338 Changed all corporate icons 2024-01-12 00:25:14 +00:00
Joao Ramos
5379cdb0d2 Blocked corporate user edit for corporate 2024-01-12 00:23:18 +00:00
Joao Ramos
39ea11bc9b Fixed naming of the table 2024-01-12 00:20:03 +00:00
Joao Ramos
bb1a2e477a Revert "Removed references to Referred corporated"
This reverts commit 21b612eaa4.
2024-01-12 00:19:35 +00:00
Tiago Ribeiro
34c1041182 Hard coded the CORS for the EnCoach website 2024-01-11 23:10:51 +00:00
Tiago Ribeiro
b2690f748b Merge branch 'develop' into feature/62/upload-users-with-excel 2024-01-11 21:55:57 +00:00
João Ramos
edbf405c30 Merged in bug-fixing-10-Jan-24 (pull request #17)
Exported Route for CORS usage

Approved-by: Tiago Ribeiro
2024-01-11 21:35:56 +00:00
Tiago Ribeiro
84c42ccf3e Adapted the BatchCodeGenerator to use an Excel file 2024-01-11 21:35:26 +00:00
Joao Ramos
5e283e358b Added CORS as .env var 2024-01-11 19:32:39 +00:00
Tiago Ribeiro
c9ed3b5a72 Merge branch 'develop' into feature/62/upload-users-with-excel 2024-01-11 14:39:40 +00:00
Tiago Ribeiro
3dfd65e161 Merged develop into bug-fixing-10-Jan-24 2024-01-11 14:31:56 +00:00
João Ramos
040102c835 Merged in feature-export-csv-roles (pull request #18)
Feature export csv roles

Approved-by: Tiago Ribeiro
2024-01-11 14:22:42 +00:00
Joao Ramos
c781c10fe9 Prevented an error that should only happen if the user had the type changed directly on the DB for testing purposes 2024-01-11 14:18:57 +00:00
Joao Ramos
a91539ec61 Download CSV is now also allowed for Agent and Corporates 2024-01-11 14:18:04 +00:00
Tiago Ribeiro
f79857fabe Started trying out reading Excel files 2024-01-11 13:55:37 +00:00
João Ramos
14d8c1e294 Merged in feature-report-export (pull request #16)
Feature report export

Approved-by: Tiago Ribeiro
2024-01-11 12:50:35 +00:00
Tiago Ribeiro
fd1af3efee Updated a bit of the conditions to show the Demographic input 2024-01-11 11:10:08 +00:00
Tiago Ribeiro
0c9f0b3dbd Added a National ID/Passport field to the demographic information of a student 2024-01-11 11:05:14 +00:00
Joao Ramos
93d5015c99 Exported Route for CORS usage 2024-01-11 00:06:09 +00:00
Joao Ramos
356d7e6a9d Merge branch 'feature-report-export' of https://bitbucket.org/ecropdev/ielts-ui into feature-report-export 2024-01-10 21:59:43 +00:00
João Ramos
2a4b7ed82d Merged develop into feature-report-export 2024-01-10 21:58:03 +00:00
Joao Ramos
2ec7e85ace Added page break + Improvement footer behaviour 2024-01-10 21:57:21 +00:00
Tiago Ribeiro
174398b4f7 Updated the text color of the unanswered WriteBlanks solutions 2024-01-10 09:43:16 +00:00
Joao Ramos
b00bf19620 Merge branch 'develop' into feature-report-export 2024-01-09 23:33:57 +00:00
Joao Ramos
744aa1e788 Added missing % on percentage
Removed unnecessary prop
2024-01-09 23:16:39 +00:00
Joao Ramos
cc0f9712d6 Added download option for assignment cards
Export PDF Download to hook
Prevented some NaN's
2024-01-09 23:15:13 +00:00
Joao Ramos
418221427a Added pdf download to record page
Reenabled reuse of PDF
2024-01-09 22:42:42 +00:00
Joao Ramos
6c741f944d Minor improvements on labels 2024-01-09 21:32:07 +00:00
Joao Ramos
1aadc4647c Final improvements for Groups PDF's 2024-01-09 20:17:21 +00:00
Joao Ramos
4e378f0c71 Added level to table 2024-01-09 18:47:32 +00:00
Joao Ramos
f8bf58e57c Removed ID and improved unknown user handling 2024-01-09 18:25:00 +00:00
Tiago Ribeiro
271364a939 Updated the payment records screen for the corporate to make sure they can't see agent related stuff 2024-01-09 17:26:37 +00:00
Tiago Ribeiro
f8f8ee5e13 Hid the country manager from the corporate on the payment records 2024-01-09 14:00:01 +00:00
Tiago Ribeiro
3b35a899e0 Removed an unused console.log 2024-01-09 13:56:31 +00:00
Tiago Ribeiro
59d1a12439 Added a simple spellchecker for the correction of the Writing 2024-01-09 13:55:04 +00:00
Tiago Ribeiro
e100c401e9 Updated the color of the unanswered questions to gray 2024-01-09 13:08:02 +00:00
Tiago Ribeiro
7b0f8c1c20 - It is no longer possible to upload/edit/delete a transfer after it is considered paid
- When deleting a row, the transfers are also deleted from the storage
2024-01-09 12:12:20 +00:00
Tiago Ribeiro
db2f5f2c0b Removed an unused console.log 2024-01-09 11:32:46 +00:00
Tiago Ribeiro
0ed843125a Made it so the isPaid property is controlled with the file uploads/deletes 2024-01-09 11:32:17 +00:00
Tiago Ribeiro
14d19257df Merged in feature/68/update-evaluation-to-background (pull request #15)
Feature/68/update evaluation to background
2024-01-09 10:16:37 +00:00
Joao Ramos
bdf65a7215 Added initial group report pdf 2024-01-09 02:22:54 +00:00
Joao Ramos
2540398ab0 Renamed to setup for group testing 2024-01-08 22:19:48 +00:00
Joao Ramos
cd8860f6ac PDF Report titles are now dynamic 2024-01-08 22:15:54 +00:00
Tiago Ribeiro
2cd18376f2 Merge branch 'develop' into feature/68/update-evaluation-to-background 2024-01-08 20:59:55 +00:00
Tiago Ribeiro
0694950bba Solved a bug where the "Perfect answer" would not show for speaking 2024-01-08 20:59:20 +00:00
Tiago Ribeiro
c6b15eaca1 Solved a small bug 2024-01-08 20:57:03 +00:00
Joao Ramos
647807a07c Separated suggestion from evaluation 2024-01-08 19:27:05 +00:00
Joao Ramos
094fd05df7 Removed unnecessary code 2024-01-08 19:18:10 +00:00
Joao Ramos
1ea9d8e60f Added custom stylesheet 2024-01-08 19:17:22 +00:00
Joao Ramos
63998b50d6 Added more comment 2024-01-08 19:04:44 +00:00
Joao Ramos
0f029a21f7 Added todo notification 2024-01-08 19:00:23 +00:00
Joao Ramos
7328f5c57f Temporarily disabled hasPDF validation 2024-01-08 18:59:51 +00:00
Joao Ramos
12d608879d Added some code comments 2024-01-08 18:58:54 +00:00
Tiago Ribeiro
9ceb71ae2f Refactored evaluation process for improved efficiency:
- Initial response set to null; Frontend now generates a list of anticipated stats;
- Background evaluation dynamically updates DB upon completion;
- Frontend actively monitors and finalizes upon stat evaluation completion.
2024-01-08 17:02:46 +00:00
Joao Ramos
e6c82412bf Added integration with backend to fetch skills feedback 2024-01-08 01:01:17 +00:00
Joao Ramos
5e8e46ff09 Added PNGs with partial radial progress 2024-01-07 23:51:05 +00:00
Tiago Ribeiro
957400cb82 Payment Record: Created Filters for Corporate and Country Manager that has submitted file in payment records 2024-01-07 15:22:46 +00:00
Tiago Ribeiro
e687a2b3e5 Payment Record: Prevent the tick without all files submitted 2024-01-07 15:06:59 +00:00
Joao Ramos
7a297a6f6c Added level report 2024-01-04 22:20:00 +00:00
Joao Ramos
432f4a735f Added QRCode for PDF 2024-01-04 19:49:15 +00:00
Joao Ramos
a4f79d236d Fixed Logo
Modules results display
2024-01-04 19:19:21 +00:00
Joao Ramos
a4771d5d29 Updated export to now work based on session 2024-01-04 15:48:30 +00:00
Joao Ramos
227de4ffc4 Fixed Date String for PDF 2024-01-04 12:18:34 +00:00
Joao Ramos
42fe650ae6 PDF Styling improvements 2024-01-04 12:18:11 +00:00
Tiago Ribeiro
026730c077 Updated the evaluation to work recursively when failing 2024-01-03 15:32:51 +00:00
Tiago Ribeiro
35d1157b0c Added the ability for a corporate account to check the payment record 2024-01-03 10:41:00 +00:00
Tiago Ribeiro
06dc92fdaa Added a confirmation for the payment record 2024-01-03 10:33:37 +00:00
Tiago Ribeiro
c9cac3539c Made sure it only happens for corporate students 2024-01-02 11:48:15 +00:00
Tiago Ribeiro
d2276eba1d Made it so that students, connected to a corporate, if they change their e-mail, they get unassigned 2024-01-02 11:07:18 +00:00
Tiago Ribeiro
1c2c3fe402 Lock the e-mail on input if the code has an e-mail associated 2023-12-31 15:22:42 +00:00
João Ramos
d4b90b5fa4 Merged in feature-payment-corporate-id (pull request #10)
Replaced display of payment id with corporate's id

Approved-by: Tiago Ribeiro
2023-12-29 10:47:55 +00:00
Tiago Ribeiro
383ddde7b5 Merged develop into feature-payment-corporate-id 2023-12-29 10:42:20 +00:00
João Ramos
e56636ca1f Merged in bug-fixing-271223 (pull request #14)
Bug fixing 271223

Approved-by: Tiago Ribeiro
2023-12-29 10:41:53 +00:00
Joao Ramos
0b6a66b12d Initial version of PDF export 2023-12-28 23:41:21 +00:00
João Ramos
e0be2fd222 Merged develop into feature-payment-corporate-id 2023-12-28 18:29:12 +00:00
Joao Ramos
9e23e3e608 Added support for currency in the CSV 2023-12-28 18:28:18 +00:00
João Ramos
47ecc2be27 Merged develop into bug-fixing-271223 2023-12-28 18:13:01 +00:00
Joao Ramos
3ca0ad353e Removed unnecessary code 2023-12-28 18:11:30 +00:00
Tiago Ribeiro
5447c89da4 Updated the permissions to be admin instead of owner 2023-12-28 11:44:21 +00:00
Joao Ramos
c88757c869 Profile is now auto filled with the user data 2023-12-27 22:33:11 +00:00
Joao Ramos
8831729470 Changed behaviour for new payment values 2023-12-27 22:14:11 +00:00
Joao Ramos
b3bb5a2337 Added search area for table users 2023-12-27 21:44:13 +00:00
Joao Ramos
b7ddee1db2 Company Name is now displayed on the Settings table, if available 2023-12-27 21:06:06 +00:00
João Ramos
d85b9db535 Merged develop into feature-payment-corporate-id 2023-12-27 19:27:04 +00:00
Joao Ramos
d03d790327 Merge branch 'develop' into feature-payment-corporate-id
CSV Improvements after merge issues
2023-12-27 19:21:02 +00:00
Tiago Ribeiro
79b159f948 Merged in improvement/stats-page-updaes (pull request #13)
Updates to the Stats page according to the client's requests
2023-12-27 15:03:03 +00:00
Tiago Ribeiro
3a0a9e1e99 Merge branch 'develop' into improvement/stats-page-updaes 2023-12-27 15:02:12 +00:00
João Ramos
cc2d0bf1b0 Merged in feature-paymentFilters (pull request #8)
Added payment and date filter

Approved-by: Tiago Ribeiro
2023-12-27 14:54:08 +00:00
João Ramos
03a199983b Merged develop into feature-paymentFilters 2023-12-27 14:52:53 +00:00
Tiago Ribeiro
a07e5a7312 Merge branch 'develop' into improvement/stats-page-updaes 2023-12-27 14:50:10 +00:00
Joao Ramos
fe5833b061 Fixed Date range picker label 2023-12-27 14:50:10 +00:00
Joao Ramos
0c2200f49f Removed debugger 2023-12-27 14:48:55 +00:00
Tiago Ribeiro
cb73196503 Limited the chevron to only work if it does not go after today 2023-12-27 14:47:42 +00:00
Tiago Ribeiro
c5fe405389 Updated the scale to be between 0 and 9 2023-12-27 09:53:45 +00:00
Tiago Ribeiro
fddc3ff2f3 Finished updating the stats page according to the client's requests 2023-12-27 09:14:13 +00:00
João Ramos
9dbe876d65 Merged in feature-payment-block-corporate-update (pull request #9)
Blocked Corporate user update through payment records screen

Approved-by: Tiago Ribeiro
2023-12-26 20:25:46 +00:00
Tiago Ribeiro
fd402bbd32 Merged develop into feature-payment-block-corporate-update 2023-12-26 20:24:39 +00:00
João Ramos
f2aa377cfe Merged in feature-country-manager-flag (pull request #11)
Feature country manager flag

Approved-by: Tiago Ribeiro
2023-12-26 20:23:56 +00:00
Tiago Ribeiro
0f0223725e Merged develop into feature-paymentFilters 2023-12-26 20:23:40 +00:00
Tiago Ribeiro
3ef29e43f5 Merged develop into feature-country-manager-flag 2023-12-26 20:22:06 +00:00
João Ramos
60a7835040 Merged in feature-payment-export-csv (pull request #12)
Feature payment export csv

Approved-by: Tiago Ribeiro
2023-12-26 20:21:13 +00:00
Tiago Ribeiro
1c645fcba2 Added an ID to every payment record column 2023-12-23 20:44:04 +00:00
Joao Ramos
938a5e9c7c Removed debugger 2023-12-23 14:52:56 +00:00
Joao Ramos
cc655fed6c Added flag display for agents 2023-12-23 14:51:28 +00:00
Joao Ramos
7f9692a3d9 Replaced display of payment id with corporate's id 2023-12-23 14:38:18 +00:00
Joao Ramos
cf90cae4eb Blocked Corporate user update through payment records screen 2023-12-23 14:29:24 +00:00
Joao Ramos
fea8e0672e Added Export CSV 2023-12-23 00:01:24 +00:00
Joao Ramos
359748841f Added payment and date filter 2023-12-22 23:22:25 +00:00
Tiago Ribeiro
438778a03c Added more control over the stats appearing in the stats page 2023-12-18 22:42:14 +00:00
Tiago Ribeiro
c37bb2691b Added the ability to view the stats in a specific time interval 2023-12-18 17:57:27 +00:00
João Ramos
6c49409de8 Merged in bug-fixing-140223 (pull request #7)
Bug fixing 140223
2023-12-15 15:52:07 +00:00
Tiago Ribeiro
2a335026de Used a already set constant instead of creating a new function 2023-12-15 10:53:25 +00:00
Tiago Ribeiro
7712e5c71d Made it so it also reloads the users 2023-12-15 10:51:31 +00:00
Tiago Ribeiro
861d97222a Updated from companyName to name 2023-12-15 10:40:32 +00:00
Tiago Ribeiro
de862f635c Merge branch 'develop' into bug-fixing-140223 2023-12-15 10:37:38 +00:00
João Ramos
ae058422aa Merged in feature-paymentAssetManagement (pull request #5)
Added file storage handling for Corporate and Commission transfer

Approved-by: Tiago Ribeiro
2023-12-15 10:09:02 +00:00
Joao Ramos
44454d1e05 Changed corporate from user.name to user.companyName
Company name is now updateable
2023-12-14 17:57:39 +00:00
Joao Ramos
a2b9ba17a7 Replaced 'Agent' with 'Country Manager' on role display 2023-12-14 17:38:24 +00:00
Joao Ramos
6f61fe1564 Comission is now hidden from everyone apart from admins 2023-12-14 17:34:52 +00:00
Joao Ramos
73d7ddc4af Profile screen was crashing 2023-12-14 17:34:12 +00:00
João Ramos
263f4afa82 Merged develop into feature-paymentAssetManagement 2023-12-14 17:21:24 +00:00
Joao Ramos
45cf2dc279 Added a number asset to limit to a specific number of decimal cases if needed 2023-12-14 17:20:36 +00:00
João Ramos
786a425d85 Merged in feature-displayRole (pull request #6)
Show User type in the navbar

Approved-by: Tiago Ribeiro
2023-12-14 09:58:06 +00:00
Tiago Ribeiro
d57223bd01 Added a Fixed decimal point for the payment records 2023-12-14 09:52:47 +00:00
Joao Ramos
fbc2cff3f1 Minor identation changes 2023-12-13 23:58:48 +00:00
Joao Ramos
9ad4f077d1 Added user type to navbar 2023-12-13 23:57:32 +00:00
Joao Ramos
e2b6061310 Merge branch 'develop' into feature-paymentAssetManagement 2023-12-13 23:43:52 +00:00
Joao Ramos
b77e97a9d2 Changed icon used for replacing files 2023-12-13 23:39:22 +00:00
João Ramos
67925c8a9e Merged in feature-newPaymentOnUserUpdate (pull request #3)
Feature newPaymentOnUserUpdate

Approved-by: Tiago Ribeiro
2023-12-13 23:33:30 +00:00
Joao Ramos
020ecff29c Implemented file storage handling for Corporate Transfer and Comission Transfer 2023-12-13 23:29:14 +00:00
Tiago Ribeiro
964660ed5d Merged develop into feature-newPaymentOnUserUpdate 2023-12-13 22:01:43 +00:00
João Ramos
1390af62ab Merged in feature-levelScoringTiers (pull request #4)
Changed approach to display level for Level Testing

Approved-by: Tiago Ribeiro
2023-12-13 22:01:07 +00:00
Joao Ramos
15947f942c Fixed issue with payment records on update 2023-12-13 17:08:26 +00:00
Joao Ramos
7b3c3d15db Changed approach to display level for Level Testing 2023-12-13 00:14:34 +00:00
Joao Ramos
1cff6fe242 Temporary fix on date/data payment 2023-12-12 22:48:38 +00:00
Joao Ramos
4cbd045502 Added comission to user card and updated user update to insert a new payment entry 2023-12-12 22:44:33 +00:00
Joao Ramos
21b612eaa4 Removed references to Referred corporated 2023-12-11 22:34:42 +00:00
Tiago Ribeiro
ef18e304a1 - Created a package list for student packages;
- Updated the group creation wizard to work as a modal;
2023-12-11 13:43:23 +00:00
Tiago Ribeiro
8e4223a9e7 Slight tweak on the sidebar logout 2023-12-09 15:44:02 +00:00
Tiago Ribeiro
7d696735ba Improved a bit of the UI for the admin dashboard 2023-12-09 15:35:56 +00:00
Tiago Ribeiro
e0ecc5be05 Merge branch 'develop' into improvement-37/writing-evaluation-perfect-answer 2023-12-09 14:47:06 +00:00
João Ramos
77af0b3495 Merged in feature-multiplerandomexams (pull request #1)
Dynamic tests generation of assignment + Minor changes

Approved-by: Tiago Ribeiro
2023-12-09 14:43:48 +00:00
Tiago Ribeiro
e2e38284a7 Uncommented a section 2023-12-09 14:39:03 +00:00
Tiago Ribeiro
ccd2560451 Merged develop into feature-multiplerandomexams 2023-12-09 14:37:34 +00:00
João Ramos
390658f2b0 Merged in feature-removeCompanyReferences (pull request #2)
Changed Comercial labels to Corporate

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

7
.dockerignore Normal file
View File

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

5
.gitignore vendored
View File

@@ -1,3 +1,5 @@
src/constants/test_firebase.json
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
@@ -36,3 +38,6 @@ yarn-error.log*
next-env.d.ts
.env
.yarn/*
.history*
__ENV.js

4
.husky/pre-commit Executable file
View File

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

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

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

28
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,28 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug server-side",
"type": "node-terminal",
"request": "launch",
"command": "npm run dev"
},
{
"name": "Next.js: debug client-side",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000"
},
{
"name": "Next.js: debug full stack",
"type": "node-terminal",
"request": "launch",
"command": "npm run dev",
"serverReadyAction": {
"pattern": "- Local:.+(https?://.+)",
"uriFormat": "%s",
"action": "debugWithChrome"
}
}
]
}

57
Dockerfile Normal file
View File

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

17
components.json Normal file
View File

@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": false,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

View File

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

11767
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,49 +6,104 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"prepare": "husky install"
},
"dependencies": {
"@headlessui/react": "^1.7.13",
"@beam-australia/react-env": "^3.1.1",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@firebase/util": "^1.9.7",
"@headlessui/react": "^2.1.2",
"@mdi/js": "^7.1.96",
"@mdi/react": "^1.6.1",
"@next/font": "13.1.6",
"@paypal/paypal-js": "^7.1.0",
"@paypal/react-paypal-js": "^8.1.3",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-popover": "^1.1.1",
"@react-pdf/renderer": "^3.1.14",
"@react-spring/web": "^9.7.4",
"@tanstack/react-table": "^8.10.1",
"@types/node": "18.13.0",
"@types/react": "18.0.27",
"@types/react-dom": "18.0.10",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@use-gesture/react": "^10.3.1",
"axios": "^1.3.5",
"bcrypt": "^5.1.1",
"chart.js": "^4.2.1",
"clsx": "^1.2.1",
"daisyui": "^2.50.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"countries-list": "^3.0.1",
"country-codes-list": "^1.6.11",
"currency-symbol-map": "^5.1.0",
"daisyui": "^3.1.5",
"eslint": "8.33.0",
"eslint-config-next": "13.1.6",
"exceljs": "^4.4.0",
"express-handlebars": "^7.1.2",
"firebase": "9.19.1",
"firebase-admin": "^11.10.1",
"formidable": "^3.5.0",
"formidable-serverless": "^1.1.1",
"framer-motion": "^9.0.2",
"howler": "^2.2.4",
"iron-session": "^6.3.1",
"lodash": "^4.17.21",
"moment": "^2.29.4",
"next": "13.1.6",
"moment-timezone": "^0.5.44",
"next": "^14.2.5",
"nodemailer": "^6.9.5",
"nodemailer-express-handlebars": "^6.1.0",
"primeicons": "^6.0.1",
"primereact": "^9.2.3",
"qrcode": "^1.5.3",
"random-words": "^2.0.0",
"react": "18.2.0",
"react-chartjs-2": "^5.2.0",
"react-csv": "^2.2.2",
"react-currency-input-field": "^3.6.12",
"react-datepicker": "^4.18.0",
"react-diff-viewer": "^3.1.1",
"react-dom": "18.2.0",
"react-firebase-hooks": "^5.1.1",
"react-icons": "^4.8.0",
"react-lineto": "^3.3.0",
"react-media-recorder": "^1.6.6",
"react-media-recorder": "1.6.5",
"react-phone-number-input": "^3.3.6",
"react-player": "^2.12.0",
"react-select": "^5.7.5",
"react-string-replace": "^1.1.0",
"react-toastify": "^9.1.2",
"react-tooltip": "^5.27.1",
"react-xarrows": "^2.0.2",
"read-excel-file": "^5.7.1",
"short-unique-id": "5.0.2",
"stripe": "^13.10.0",
"swr": "^2.1.3",
"tailwind-merge": "^2.5.2",
"tailwind-scrollbar-hide": "^1.1.7",
"tailwindcss-animate": "^1.0.7",
"typescript": "4.9.5",
"use-file-picker": "^2.1.0",
"uuid": "^9.0.0",
"wavesurfer.js": "^6.6.4",
"zustand": "^4.3.6"
},
"devDependencies": {
"@types/blob-stream": "^0.1.33",
"@types/formidable": "^3.4.0",
"@types/howler": "^2.2.11",
"@types/lodash": "^4.14.191",
"@types/nodemailer": "^6.4.11",
"@types/nodemailer-express-handlebars": "^4.0.3",
"@types/qrcode": "^1.5.5",
"@types/react-csv": "^1.1.10",
"@types/react-datepicker": "^4.15.1",
"@types/uuid": "^9.0.1",
"@types/wavesurfer.js": "^6.0.6",
"@wixc3/react-board": "^2.2.0",
"autoprefixer": "^10.4.13",
"husky": "^8.0.3",
"postcss": "^8.4.21",
"tailwindcss": "^3.2.4"
}

BIN
public/audio/check.mp3 Normal file

Binary file not shown.

BIN
public/audio/error.mp3 Normal file

Binary file not shown.

BIN
public/audio/sent.mp3 Normal file

Binary file not shown.

BIN
public/defaultAvatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

BIN
public/logo_title.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

BIN
public/manuals/student.pdf Normal file

Binary file not shown.

BIN
public/manuals/teacher.pdf Normal file

Binary file not shown.

1
public/mat-icon-info.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed"><path d="M440-280h80v-240h-80v240Zm40-320q17 0 28.5-11.5T520-640q0-17-11.5-28.5T480-680q-17 0-28.5 11.5T440-640q0 17 11.5 28.5T480-600Zm0 520q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>

After

Width:  |  Height:  |  Size: 535 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 832 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,193 @@
import Image from "next/image";
import clsx from "clsx";
import RadialProgressBar from "./RadialProgressBar";
import { AIDetectionAttributes } from "@/interfaces/exam";
import { Tooltip } from 'react-tooltip';
import SegmentedProgressBar from "./SegmentedProgressBar";
// Colors and texts scrapped from gpt's zero react bundle
const AIDetection: React.FC<AIDetectionAttributes> = ({ predicted_class, confidence_category, class_probabilities, sentences }) => {
const probabilityTooltipContent = `
Encoach's deep learning model predicts the <br/>
probability this text has been entirely <br/>
generated by AI. For instance, a 40% AI <br/>
probability does not indicate that the text<br/>
contains 40% AI-written content. Rather, it<br/>
indicates the text is more likely to be partially<br/>
human written than be entirely AI-written.
`;
const confidenceTooltipContent = `
Confidence scores are a safeguard to better<br/>
understand AI identification results. Encoach<br/>
trained it's deep learning model on a diverse<br/>
dataset of millions of human and AI-written<br/>
documents. Green scores indicate that you can scan<br/>
with confidence that the model has classified<br/>
many similar documents with high accuracy.<br/>
Red scores indicate that this text is dissimilar<br/>
to the ones in their training set, which can impact<br/>
the model's accuracy, and to proceed with caution.
`;
const confidenceKeywords = ["moderately", "highly", "confident", "uncertain"];
var confidence = {
low: {
ai: "Encoach is uncertain about this text. If Encoach had to classify it, it would be considered",
human: "Encoach is uncertain about this text. If Encoach had to classify it, it would likely be considered",
mixed: "Encoach is uncertain about this text. If Encoach had to classify it, it would likely be a"
},
medium: {
ai: "Encoach is moderately confident this text was",
human: "Encoach is moderately confident this text is entirely",
mixed: "Encoach is moderately confident this text is a"
},
high: {
ai: "Encoach is highly confident this text was",
human: "Encoach is highly confident this text is entirely",
mixed: "Encoach is highly confident this text is a"
}
}
var classPrediction = {
ai: {
background: "bg-ai-detection-result-ai-bg",
color: "text-ai-detection-result-ai",
text: "ai generated"
},
mixed: {
background: "bg-ai-detection-result-mixed-bg",
color: "text-ai-detection-result-mixed",
text: "mix of ai and human"
},
human: {
background: "bg-ai-detection-result-human-bg",
color: "text-ai-detection-result-human",
text: "human"
}
}
const segments = [
{ percentage: Math.round(class_probabilities["human"] * 100), subtitle: 'human', color: "ai-detection-result-human" },
{ percentage: Math.round(class_probabilities["mixed"] * 100), subtitle: 'mixed', color: "ai-detection-result-mixed" },
{ percentage: Math.round(class_probabilities["ai"] * 100), subtitle: 'ai', color: "ai-detection-result-ai" }
];
const styleConfidenceText = (text: string): [string, string[]] => {
const keywords: string[] = [];
const styledText = text.split(" ").map(word => {
if (confidenceKeywords.includes(word)) {
keywords.push(word);
return `<span style="font-weight: 500; text-decoration: underline;">${word}</span>`;
}
return word
}).join(" ");
return [styledText, keywords];
};
const confidenceText = confidence[confidence_category][predicted_class];
const [styledText, keywords] = styleConfidenceText(confidenceText);
const tooltipStyle = {
"backgroundColor": "rgb(255, 255, 255)",
"color": "#8992B1",
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
borderRadius: '0.125rem'
}
const highestProbability = Math.max(class_probabilities["ai"], class_probabilities["human"], class_probabilities["mixed"]);
const spanTextColor = highestProbability === class_probabilities["ai"]
? "#f4bf4f"
: highestProbability === class_probabilities["human"]
? "#50c08a"
: "#93aafb";
let spanClassName = highestProbability === class_probabilities["ai"]
? "text-ai-detection-result-ai"
: highestProbability === class_probabilities["human"]
? "text-ai-detection-result-human"
: "text-ai-detection-result-mixed";
spanClassName = `${spanClassName} font-bold text-lg`
const percentage = Math.round(highestProbability * 100)
const hasHighlightedForAI = sentences.some(item => item.highlight_sentence_for_ai);
return (
<>
<Tooltip id="probability-tooltip" className="z-50 bg-white shadow-md rounded-sm" style={tooltipStyle} />
<Tooltip id="confidence-tooltip" className="z-50 bg-white shadow-md rounded-sm" style={tooltipStyle} />
<div className="flex flex-col bg-white p-6 rounded-lg shadow-lg gap-16">
<h1 className="text-lg font-semibold">Encoach Detection Results</h1>
<div className="flex flex-row -md:flex-col -lg:gap-0 -xl:gap-10 gap-20 items-stretch -md:items-center">
<div className="flex -md:w-5/6 w-1/2 justify-center">
<div className="flex flex-col border rounded-xl">
<h1 className="border-b p-6 font-medium">Text Classification</h1>
<div className="flex flex-row gap-8 items-center p-6">
<RadialProgressBar
percentage={percentage}
text={predicted_class}
color={spanTextColor}
spanClassName={spanClassName}
/>
<div className="flex flex-col gap-1 text-sm">
<div className="flex flex-row items-center">
<span className="mr-2 text-ai-detection-result-ai-text font-semibold text-xl">
{`${Math.round(class_probabilities["ai"] * 100)}%`}
</span>
<span className="text-sm -md:text-xs text-ai-detection-text">Probability AI generated</span>
<a data-tooltip-id="probability-tooltip" data-tooltip-html={probabilityTooltipContent} className='ml-1 flex items-center justify-center'>
<Image src="/mat-icon-info.svg" width={24} height={24} alt="Probability Tooltip" />
</a>
</div>
<div className="flex flex-row items-center gap-1">
<div className={clsx(
"rounded-full w-3 h-3",
confidence_category == 'low' ?
"bg-ai-detection-confidence-low border border-ai-detection-confidence-border" : "bg-ai-detection-confidence-low-transparent"
)}></div>
<div className={clsx(
"rounded-full w-3 h-3",
confidence_category == 'medium' ?
"bg-ai-detection-confidence-medium border border-ai-detection-confidence-border" : "bg-ai-detection-confidence-medium-transparent"
)}></div>
<div className={clsx(
"rounded-full w-3 h-3 mr-2",
confidence_category == 'high' ?
"bg-ai-detection-confidence-high border border-ai-detection-confidence-border" : "bg-ai-detection-confidence-high-transparent"
)}></div>
<span className="text-sm -md:text-xs text-ai-detection-text">{keywords.join(' ')}</span>
<a data-tooltip-id="confidence-tooltip" data-tooltip-html={confidenceTooltipContent} className='ml-1 flex items-center justify-center'>
<Image src="/mat-icon-info.svg" width={24} height={24} alt="Probability Tooltip" />
</a>
</div>
</div>
</div>
</div>
</div>
<div className="flex flex-col border rounded-xl -md:w-5/6 w-2/6">
<h1 className="border-b p-6 font-medium">Probability Breakdown</h1>
<div className="flex items-center w-full h-full">
<SegmentedProgressBar segments={segments} className="w-full px-8 -md:py-8 text-ai-detection-text" />
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-row items-center">
<div dangerouslySetInnerHTML={{ __html: styledText }} className="mr-2"></div>
<div className={clsx(
"flex items-center justify-center p-2 rounded",
classPrediction[predicted_class]['color'],
classPrediction[predicted_class]['background']
)}>
<span className="text-sm">{classPrediction[predicted_class]['text']}</span>
</div>
</div>
{(hasHighlightedForAI && <div>
Sentences that are likely written by AI are <span className="font-semibold bg-ai-detection-highlight">highlighted</span>.
</div>)}
</div>
</div >
<div>
{sentences.map((item, index) => (
<span
key={index}
className={item.highlight_sentence_for_ai ? 'bg-ai-detection-highlight' : ''}
>
{item.sentence}{' '}
</span>
))}
</div>
</>
)
}
export default AIDetection;

View File

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

View File

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

View File

@@ -0,0 +1,137 @@
import {EmploymentStatus, EMPLOYMENT_STATUS, Gender, User} from "@/interfaces/user";
import {FormEvent, useEffect, useState} from "react";
import countryCodes from "country-codes-list";
import {RadioGroup} from "@headlessui/react";
import Input from "./Low/Input";
import clsx from "clsx";
import Button from "./Low/Button";
import {BsArrowRepeat} from "react-icons/bs";
import axios from "axios";
import {toast} from "react-toastify";
import {KeyedMutator} from "swr";
import CountrySelect from "./Low/CountrySelect";
import GenderInput from "@/components/High/GenderInput";
import EmploymentStatusInput from "@/components/High/EmploymentStatusInput";
import TimezoneSelect from "./Low/TImezoneSelect";
import moment from "moment";
interface Props {
user: User;
mutateUser: KeyedMutator<User>;
}
export default function DemographicInformationInput({user, mutateUser}: Props) {
const [country, setCountry] = useState<string>();
const [phone, setPhone] = useState<string>();
const [passport_id, setPassportID] = useState<string | undefined>(user.type === "student" ? user.demographicInformation?.passport_id : undefined);
const [gender, setGender] = useState<Gender>();
const [employment, setEmployment] = useState<EmploymentStatus>();
const [position, setPosition] = useState<string>();
const [timezone, setTimezone] = useState<string>(moment.tz.guess());
const [isLoading, setIsLoading] = useState(false);
const [companyName, setCompanyName] = useState<string>();
const [commercialRegistration, setCommercialRegistration] = useState<string>();
const save = (e?: FormEvent) => {
if (e) e.preventDefault();
setIsLoading(true);
axios
.patch("/api/users/update", {
demographicInformation: {
country,
phone: `+${countryCodes.findOne("countryCode" as any, country!).countryCallingCode}${phone}`,
gender,
employment: user.type === "corporate" ? undefined : employment,
position: user.type === "corporate" ? position : undefined,
passport_id,
timezone,
},
agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined,
})
.then((response) => mutateUser((response.data as {user: User}).user))
.catch(() => {
toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"});
})
.finally(() => setIsLoading(false));
};
return (
<div className="flex flex-col items-center justify-center gap-12 w-full">
<h2 className="font-semibold text-center text-xl max-w-[800px]">
Welcome to EnCoach, the ultimate platform dedicated to helping you master the IELTS ! We are thrilled that you have chosen us as your
learning companion on this journey towards achieving your desired IELTS score.
<br />
<br />
To make the most of your learning experience, we kindly request you to complete your profile. By providing some essential information
about yourself.
</h2>
<form className="flex flex-col items-center justify-items-center gap-6 w-full h-full -md:px-4 lg:w-1/2 mb-32" onSubmit={save}>
{user.type === "agent" && (
<div className="w-full flex gap-8">
<Input type="text" onChange={setCompanyName} name="companyName" label="Corporate Name" required />
<Input
type="text"
onChange={setCommercialRegistration}
name="commercialRegistration"
label="Commercial Registration"
required
/>
</div>
)}
<div className="w-full grid grid-cols-2 gap-6">
<div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
<CountrySelect value={country} onChange={setCountry} />
</div>
<Input type="tel" name="phone" label="Phone number" onChange={(e) => setPhone(e)} placeholder="Enter phone number" required />
</div>
{user.type === "student" && (
<Input
type="text"
name="passport_id"
label="Passport/National ID"
onChange={(e) => setPassportID(e)}
value={passport_id}
placeholder="Enter National ID or Passport number"
required
/>
)}
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Timezone</label>
<TimezoneSelect value={timezone} onChange={setTimezone} />
</div>
<GenderInput value={gender} onChange={setGender} />
{user.type === "corporate" && (
<Input name="position" onChange={setPosition} type="text" label="Position" placeholder="CEO, Head of Marketing..." required />
)}
{user.type !== "corporate" && <EmploymentStatusInput value={employment} onChange={setEmployment} />}
</form>
<div className="self-end flex justify-end w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
className="lg:mt-8 max-w-[400px] w-full self-end"
color="purple"
onClick={save}
disabled={
isLoading ||
!country ||
!phone ||
!gender ||
(user.type === "corporate" ? !position : !employment) ||
(user.type === "agent" ? !companyName || !commercialRegistration : false)
}>
{!isLoading && "Save information"}
{isLoading && (
<div className="flex items-center justify-center">
<BsArrowRepeat className="text-white animate-spin" size={25} />
</div>
)}
</Button>
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,84 @@
import React, { useState, ReactNode, useRef, useEffect } from 'react';
import { animated, useSpring } from '@react-spring/web';
interface DropdownProps {
title: ReactNode;
open?: boolean;
className?: string;
contentWrapperClassName?: string;
bottomPadding?: number;
children: ReactNode;
}
const Dropdown: React.FC<DropdownProps> = ({
title,
open = false,
className = "w-full text-left font-semibold flex justify-between items-center p-4",
contentWrapperClassName = "px-6",
bottomPadding = 12,
children
}) => {
const [isOpen, setIsOpen] = useState<boolean>(open);
const contentRef = useRef<HTMLDivElement>(null);
const [contentHeight, setContentHeight] = useState<number>(0);
useEffect(() => {
let resizeObserver: ResizeObserver | null = null;
if (contentRef.current) {
resizeObserver = new ResizeObserver(entries => {
for (let entry of entries) {
if (entry.borderBoxSize && entry.borderBoxSize.length > 0) {
const height = entry.borderBoxSize[0].blockSize;
setContentHeight(height + bottomPadding);
} else {
// Fallback for browsers that don't support borderBoxSize
const height = entry.contentRect.height;
setContentHeight(height + bottomPadding);
}
}
});
resizeObserver.observe(contentRef.current);
}
return () => {
if (resizeObserver) {
resizeObserver.disconnect();
}
};
}, [bottomPadding]);
const springProps = useSpring({
height: isOpen ? contentHeight : 0,
opacity: isOpen ? 1 : 0,
config: { tension: 300, friction: 30 }
});
return (
<>
<button
onClick={() => setIsOpen(!isOpen)}
className={className}
>
{title}
<svg
className={`w-4 h-4 transform transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<animated.div style={springProps} className="overflow-hidden">
<div ref={contentRef} className={contentWrapperClassName} style={{paddingBottom: bottomPadding}}>
{children}
</div>
</animated.div>
</>
);
};
export default Dropdown;

View File

@@ -1,149 +0,0 @@
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import {FillBlanksExercise} from "@/interfaces/exam";
import {Dialog, Transition} from "@headlessui/react";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {Fragment, useState} from "react";
import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
interface WordsPopoutProps {
words: {word: string; isDisabled: boolean}[];
isOpen: boolean;
onCancel: () => void;
onAnswer: (answer: string) => void;
}
function WordsPopout({words, isOpen, onCancel, onAnswer}: WordsPopoutProps) {
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={onCancel}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95">
<Dialog.Panel className="w-fit transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all flex flex-col">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
List of words
</Dialog.Title>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{words.map((word) => (
<button
key={word.word}
onClick={() => onAnswer(word.word)}
disabled={word.isDisabled}
className={clsx("btn sm:btn-wide gap-4 relative text-white", infoButtonStyle)}>
{word.word}
</button>
))}
</div>
<div className="mt-4 self-end">
<button onClick={onCancel} className={clsx("btn md:btn-wide gap-4 relative text-white", errorButtonStyle)}>
Close
</button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
);
}
export default function FillBlanks({id, allowRepetition, type, prompt, solutions, text, words, onNext, onBack}: FillBlanksExercise & CommonProps) {
const [userSolutions, setUserSolutions] = useState<{id: string; solution: string}[]>([]);
const [currentBlankId, setCurrentBlankId] = useState<string>();
const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = userSolutions.filter((x) => solutions.find((y) => x.id === y.id)?.solution === x.solution.toLowerCase() || false).length;
return {total, correct};
};
const renderLines = (line: string) => {
return (
<span>
{reactStringReplace(line, /({{\d+}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = userSolutions.find((x) => x.id === id);
return (
<button className="border-2 rounded-xl px-4 text-blue-400 border-blue-400 my-2" onClick={() => setCurrentBlankId(id)}>
{userSolution ? userSolution.solution : id}
</button>
);
})}
</span>
);
};
return (
<>
<div className="flex flex-col gap-4">
<WordsPopout
words={words.map((word) => ({word, isDisabled: allowRepetition ? false : userSolutions.map((x) => x.solution).includes(word)}))}
isOpen={!!currentBlankId}
onCancel={() => setCurrentBlankId(undefined)}
onAnswer={(solution: string) => {
setUserSolutions((prev) => [...prev.filter((x) => x.id !== currentBlankId), {id: currentBlankId!, solution}]);
setCurrentBlankId(undefined);
}}
/>
<span className="text-base md:text-lg font-medium text-center px-2 md:px-4 lg:px-48">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</span>
<span>
{text.split("\\n").map((line, index) => (
<Fragment key={index}>
{renderLines(line)}
<br />
</Fragment>
))}
</span>
</div>
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
<div className="absolute left-4">
<Icon path={mdiArrowLeft} color="white" size={1} />
</div>
Back
</button>
<button
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}>
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</div>
</>
);
}

View File

@@ -0,0 +1,64 @@
import Button from "@/components/Low/Button";
import clsx from "clsx";
import { useState } from "react";
interface WordsDrawerProps {
words: {word: string; isDisabled: boolean}[];
isOpen: boolean;
blankId?: string;
previouslySelectedWord?: string;
onCancel: () => void;
onAnswer: (answer: string) => void;
}
const WordsDrawer: React.FC<WordsDrawerProps> = ({words, isOpen, blankId, previouslySelectedWord, onCancel, onAnswer}) => {
const [selectedWord, setSelectedWord] = useState<string | undefined>(previouslySelectedWord);
return (
<>
<div
className={clsx(
"w-full h-full absolute top-0 left-0 bg-gradient-to-t from-mti-black to-transparent z-10",
isOpen ? "visible opacity-10" : "invisible opacity-0",
)}
/>
<div
className={clsx(
"absolute w-full bg-white px-7 py-8 bottom-0 left-0 shadow-2xl rounded-2xl z-20 flex flex-col gap-8 transition-opacity duration-300 ease-in-out",
isOpen ? "visible opacity-100" : "invisible opacity-0",
)}>
<div className="w-full flex gap-2">
<div className="rounded-full w-6 h-6 flex items-center justify-center text-white bg-mti-purple-light">{blankId}</div>
<span> Choose the correct word:</span>
</div>
<div className="grid grid-cols-6 gap-6" key="word-array">
{words.map(({word, isDisabled}) => (
<button
key={`${word}_${blankId}`}
onClick={() => setSelectedWord((prev) => (prev === word ? undefined : word))}
className={clsx(
"rounded-full py-3 text-center transition duration-300 ease-in-out",
selectedWord === word ? "text-white bg-mti-purple-light" : "bg-mti-purple-ultralight",
!isDisabled && "hover:text-white hover:bg-mti-purple",
"disabled:cursor-not-allowed disabled:text-mti-gray-dim",
)}
disabled={isDisabled}>
{word}
</button>
))}
</div>
<div className="flex justify-between w-full">
<Button color="purple" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
Cancel
</Button>
<Button color="purple" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
Confirm
</Button>
</div>
</div>
</>
);
}
export default WordsDrawer;

View File

@@ -0,0 +1,243 @@
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import clsx from "clsx";
import { Fragment, useEffect, useState } from "react";
import reactStringReplace from "react-string-replace";
import { CommonProps } from "..";
import Button from "../../Low/Button";
import { v4 } from "uuid";
const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
id,
type,
prompt,
solutions,
text,
words,
userSolutions,
variant,
onNext,
onBack,
}) => {
const { shuffleMaps, exam, partIndex, questionIndex, exerciseIndex } = useExamStore((state) => state);
const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const [currentMCSelection, setCurrentMCSelection] = useState<{ id: string, selection: FillBlanksMCOption }>();
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
return Array.isArray(words) && words.every(
word => word && typeof word === 'object' && 'id' in word && 'options' in word
);
}
const excludeWordMCType = (x: any) => {
return typeof x === "string" ? x : x as { letter: string; word: string };
}
useEffect(() => {
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
let correctWords: any;
if (exam && exam.module === "level" && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") {
correctWords = (exam.parts[partIndex].exercises[exerciseIndex] as FillBlanksExercise).words;
}
const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = answers!.filter((x) => {
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
if (!solution) return false;
const option = correctWords!.find((w: any) => {
if (typeof w === "string") {
return w.toLowerCase() === x.solution.toLowerCase();
} else if ('letter' in w) {
return w.word.toLowerCase() === x.solution.toLowerCase();
} else {
return w.id.toString() === x.id.toString();
}
});
if (!option) return false;
if (typeof option === "string") {
return solution.toLowerCase() === option.toLowerCase();
} else if ('letter' in option) {
return solution.toLowerCase() === option.word.toLowerCase();
} else if ('options' in option) {
return option.options[solution as keyof typeof option.options] == x.solution;
}
return false;
}).length;
const missing = total - answers!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return { total, correct, missing };
};
const renderLines = (line: string) => {
return (
<div className="text-base leading-5">
{reactStringReplace(line, /({{\d+}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = answers.find((x) => x.id === id);
const styles = clsx(
"rounded-full hover:text-white focus:ring-0 focus:outline-none focus:!text-white focus:bg-mti-purple transition duration-300 ease-in-out my-1 px-5 py-2 text-center",
!userSolution && "text-center text-mti-purple-light bg-mti-purple-ultralight",
userSolution && "text-center text-mti-purple-dark bg-mti-purple-ultralight",
)
return (
variant === "mc" ? (
<>
{/*<span className="mr-2">{`(${id})`}</span>*/}
<button
className={styles}
onClick={() => {
setCurrentMCSelection(
{
id: id,
selection: words.find((x) => {
if (typeof x !== "string" && 'id' in x) {
return (x as FillBlanksMCOption).id.toString() == id.toString();
}
return false;
}) as FillBlanksMCOption
}
);
}}
>
{userSolution?.solution === undefined ? <span className="text-transparent select-none">placeholder</span> : <span> {userSolution.solution} </span>}
</button>
</>
) : (
<input
className={styles}
onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution: e.target.value }])}
value={userSolution?.solution} />
)
);
})}
</div>
);
};
const onSelection = (id: string, value: string) => {
setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id: id, solution: value }]);
}
const getShuffles = () => {
let shuffle = {};
if (shuffleMaps.length !== 0) {
shuffle = {
shuffleMaps: shuffleMaps.filter((map) =>
answers.some(answer => answer.id === map.id)
)
}
}
return shuffle;
}
return (
<>
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
{false && <span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</span>}
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
{text.split("\\n").map((line, index) => (
<p key={index} className={clsx(variant === "mc" && "whitespace-pre-wrap")}>
{renderLines(line)}
<br />
</p>
))}
</span>
{variant === "mc" && typeCheckWordsMC(words) ? (
<>
{currentMCSelection && (
<div className="bg-mti-gray-smoke rounded-xl flex flex-col gap-4 px-16 py-8">
<span className="font-medium text-lg text-mti-purple-dark mb-4 px-2">{`${currentMCSelection.id} - Select the appropriate word.`}</span>
<div className="flex gap-4 flex-wrap justify-between">
{currentMCSelection.selection?.options && Object.entries(currentMCSelection.selection.options).sort((a, b) => a[0].localeCompare(b[0])).map(([key, value]) => {
return <div
key={v4()}
onClick={() => onSelection(currentMCSelection.id, value)}
className={clsx(
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base",
!!answers.find((x) => x.solution.toLocaleLowerCase() === value.toLocaleLowerCase() && x.id === currentMCSelection.id) &&
"border-mti-purple-light",
)}>
<span className="font-semibold">{key}.</span>
<span>{value}</span>
</div>
/*<button
className={clsx(
"border border-mti-purple-light rounded-full px-3 py-0.5 transition ease-in-out duration-300",
!!answers.find((x) => x.solution.toLocaleLowerCase() === value.toLocaleLowerCase() && x.id === currentMCSelection.id) &&
"bg-mti-purple-dark text-white",
)}
key={v4()}
onClick={() => onSelection(currentMCSelection.id, value)}
>
{value}
</button>;*/
})}
</div>
</div>
)}
</>
) : (
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-4">
<span className="font-medium text-mti-purple-dark">Options</span>
<div className="flex gap-4 flex-wrap">
{words.map((v) => {
v = excludeWordMCType(v);
const text = typeof v === "string" ? v : `${v.letter} - ${v.word}`;
return (
<span
className={clsx(
"border border-mti-purple-light rounded-full px-3 py-0.5 transition ease-in-out duration-300",
!!answers.find((x) => x.solution.toLowerCase() === (typeof v === "string" ? v : ("letter" in v ? v.letter : "")).toLowerCase()) &&
"bg-mti-purple-dark text-white",
)}
key={v4()}
>
{text}
</span>
)
})}
</div>
</div >
)}
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() })}
className="max-w-[200px] w-full"
disabled={
exam && typeof partIndex !== "undefined" && exam.module === "level" &&
typeof exam.parts[0].intro === "string" && questionIndex === 0}
>
Back
</Button>
<Button
color="purple"
onClick={() => onNext({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() })}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</>
);
}
export default FillBlanks;

View File

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

View File

@@ -1,37 +1,101 @@
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 Icon from "@mdi/react";
import clsx from "clsx";
import {Fragment, useState} from "react";
import {Fragment, useEffect, useState} from "react";
import LineTo from "react-lineto";
import {CommonProps} from ".";
import Button from "../Low/Button";
import Xarrow from "react-xarrows";
import useExamStore from "@/stores/examStore";
import {DndContext, DragEndEvent, useDraggable, useDroppable} from "@dnd-kit/core";
export default function MatchSentences({id, options, type, prompt, sentences, onNext, onBack}: MatchSentencesExercise & CommonProps) {
const [selectedQuestion, setSelectedQuestion] = useState<string>();
const [userSolutions, setUserSolutions] = useState<{question: string; option: string}[]>([]);
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) {
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const handleDragEnd = (event: DragEndEvent) => {
if (event.over && event.over.id.toString().startsWith("droppable")) {
const optionID = event.active.id.toString().replace("draggable_option_", "");
const sentenceID = event.over.id.toString().replace("droppable_sentence_", "");
setAnswers((prev) => [...prev.filter((x) => x.question.toString() !== sentenceID), {question: sentenceID, option: optionID}]);
}
};
const calculateScore = () => {
const total = sentences.length;
const correct = userSolutions.filter((x) => sentences.find((y) => y.id === x.question)?.solution === x.option || false).length;
const correct = answers.filter(
(x) => sentences.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false,
).length;
const missing = total - answers.filter((x) => sentences.find((y) => y.id.toString() === x.question.toString())).length;
return {total, correct};
return {total, correct, missing};
};
const selectOption = (option: string) => {
if (!selectedQuestion) return;
setUserSolutions((prev) => [...prev.filter((x) => x.question !== selectedQuestion), {question: selectedQuestion, option}]);
setSelectedQuestion(undefined);
};
const getSentenceColor = (id: string) => {
return sentences.find((x) => x.id === id)?.color || "";
};
useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
return (
<>
<div className="flex flex-col items-center gap-8">
<span className="text-base md:text-lg font-medium text-center px-2 md:px-4 lg:px-48">
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
@@ -39,74 +103,45 @@ export default function MatchSentences({id, options, type, prompt, sentences, on
</Fragment>
))}
</span>
<div className="grid grid-cols-2 gap-16 place-items-center">
<div className="flex flex-col gap-1">
{sentences.map(({sentence, id, color}) => (
<div
key={`question_${id}`}
className="flex items-center justify-end gap-2 cursor-pointer"
onClick={() => setSelectedQuestion((prev) => (prev === id ? undefined : id))}>
<span>
<span className="font-semibold">{id}.</span> {sentence}{" "}
</span>
<div
style={{borderColor: color, backgroundColor: selectedQuestion === id ? color : "transparent"}}
className={clsx("border-2 border-blue-500 w-4 h-4 rounded-full", id)}
<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">
{sentences.map((question) => (
<DroppableQuestionArea
key={`question_${question.id}`}
question={question}
answer={answers.find((x) => x.question.toString() === question.id.toString())?.option}
/>
</div>
))}
</div>
<div className="flex flex-col gap-1">
{options.map(({sentence, id}) => (
<div
key={`answer_${id}`}
className={clsx("flex items-center justify-start gap-2 cursor-pointer")}
onClick={() => selectOption(id)}>
<div
style={
userSolutions.find((x) => x.option === id)
? {
border: `2px solid ${getSentenceColor(userSolutions.find((x) => x.option === id)!.question)}`,
}
: {}
}
className={clsx("border-2 border-green-500 bg-transparent w-4 h-4 rounded-full", id)}
/>
<span>
<span className="font-semibold">{id}.</span> {sentence}{" "}
</span>
</div>
))}
</div>
{userSolutions.map((solution, index) => (
<div key={`solution_${index}`} className="absolute">
<LineTo
className="rounded-full"
from={solution.question}
to={solution.option}
borderColor={sentences.find((x) => x.id === solution.question)!.color}
borderWidth={5}
/>
))}
</div>
))}
</div>
<div className="flex flex-col gap-4">
<span>Drag one of these paragraphs into the slots above:</span>
<div className="flex gap-4 flex-wrap justify-center items-center max-w-lg">
{options.map((option) => (
<DraggableOptionArea key={`answer_${option.id}`} option={option} />
))}
</div>
</div>
</div>
</DndContext>
</div>
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
<div className="absolute left-4">
<Icon path={mdiArrowLeft} color="white" size={1} />
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] w-full">
Back
</button>
<button
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}>
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</Button>
</div>
</>
);

View File

@@ -1,80 +1,68 @@
/* eslint-disable @next/next/no-img-element */
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight, mdiCheck, mdiClose} from "@mdi/js";
import Icon from "@mdi/react";
import { MultipleChoiceExercise, MultipleChoiceQuestion } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import clsx from "clsx";
import {useState} from "react";
import {CommonProps} from ".";
import { useEffect, useState } from "react";
import reactStringReplace from "react-string-replace";
import { CommonProps } from ".";
import Button from "../Low/Button";
import { v4 } from "uuid";
function Question({
id,
variant,
prompt,
solution,
options,
userSolution,
onSelectOption,
showSolution = false,
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
const optionColor = (option: string) => {
if (!showSolution) {
return userSolution === option ? "border-blue-400" : "";
}
}: MultipleChoiceQuestion & {
userSolution: string | undefined;
onSelectOption?: (option: string) => void;
showSolution?: boolean,
}) {
if (option === solution) {
return "border-green-500 text-green-500";
}
return userSolution === option ? "border-red-500 text-red-500" : "";
};
const optionBadge = (option: string) => {
if (option === userSolution) {
if (solution === option) {
return (
<div className="badge badge-lg bg-green-500 border-green-500 absolute -top-2 -right-4">
<div className="tooltip" data-tip="You have correctly answered!">
<Icon path={mdiCheck} color="white" size={0.8} />
</div>
</div>
);
}
return (
<div className="badge badge-lg bg-red-500 border-red-500 absolute -top-2 -right-4">
<div className="tooltip" data-tip="You have wrongly answered!">
<Icon path={mdiClose} color="white" size={0.8} />
</div>
</div>
);
}
const renderPrompt = (prompt: string) => {
return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => {
const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
return word.length > 0 ? <u>{word}</u> : null;
});
};
return (
<div className="flex flex-col items-center gap-4">
<span className="text-center">{prompt}</span>
<div className="grid grid-rows-4 md:grid-rows-1 md:grid-cols-4 gap-4 place-items-center">
<div className="flex flex-col gap-8">
{isNaN(Number(id)) ? (
<span className="text-lg">{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}</span>
) : (
<span className="text-lg">
<>
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}</span>
</>
</span>
)}
<div className="flex flex-wrap gap-4 justify-between">
{variant === "image" &&
options.map((option) => (
<div
key={option.id}
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
key={v4()}
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
className={clsx(
"flex flex-col items-center border-2 p-4 rounded-xl gap-4 cursor-pointer bg-white relative",
optionColor(option.id),
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative select-none",
userSolution === option.id.toString() && "border-mti-purple-light",
)}>
{showSolution && optionBadge(option.id)}
<img src={option.src!} alt={`Option ${option.id}`} />
<span>{option.id}</span>
<span className={clsx("text-sm", userSolution !== option.id.toString() && "opacity-50")}>{option.id.toString()}</span>
<img src={option.src!} alt={`Option ${option.id.toString()}`} />
</div>
))}
{variant === "text" &&
options.map((option) => (
<div
key={option.id}
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
className={clsx("flex border-2 p-4 rounded-xl gap-2 cursor-pointer bg-white", optionColor(option.id))}>
<span className="font-bold">{option.id}.</span>
key={v4()}
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
className={clsx(
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base select-none",
userSolution === option.id.toString() && "border-mti-purple-light",
)}>
<span className="font-semibold">{option.id.toString()}.</span>
<span>{option.text}</span>
</div>
))}
@@ -83,64 +71,115 @@ function Question({
);
}
export default function MultipleChoice({id, prompt, type, questions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
const [userSolutions, setUserSolutions] = useState<{question: string; option: string}[]>([]);
const [questionIndex, setQuestionIndex] = useState(0);
export default function MultipleChoice({ id, prompt, type, questions, userSolutions, onNext, onBack }: MultipleChoiceExercise & CommonProps) {
const [answers, setAnswers] = useState<{ question: string; option: string }[]>(userSolutions);
const {
questionIndex,
exam,
shuffleMaps,
hasExamEnded,
userSolutions: storeUserSolutions,
setQuestionIndex,
setUserSolutions
} = useExamStore((state) => state);
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
useEffect(() => {
setUserSolutions(
[...storeUserSolutions.filter((x) => x.exercise !== id), {
exercise: id, solutions: answers, score: calculateScore(), type
}]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers]);
useEffect(() => {
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
const onSelectOption = (option: string) => {
const question = questions[questionIndex];
setUserSolutions((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), { option, question: question.id }]);
};
const calculateScore = () => {
const total = questions.length;
const correct = userSolutions.filter((x) => questions.find((y) => y.id === x.question)?.solution === x.option || false).length;
const correct = answers.filter((x) => {
const matchingQuestion = questions.find((y) => {
return y.id.toString() === x.question.toString();
});
return {total, correct};
let isSolutionCorrect;
if (shuffleMaps.length == 0) {
isSolutionCorrect = matchingQuestion?.solution === x.option;
} else {
const shuffleMap = shuffleMaps.find((map) => map.id == x.question)
isSolutionCorrect = shuffleMap?.map[x.option] == matchingQuestion?.solution;
}
return isSolutionCorrect || false;
}).length;
const missing = total - correct;
return { total, correct, missing };
};
const getShuffles = () => {
let shuffle = {};
if (shuffleMaps.length !== 0) {
shuffle = {
shuffleMaps: shuffleMaps.filter((map) =>
answers.some(answer => answer.question === map.id)
)
}
}
return shuffle;
}
const next = () => {
if (questionIndex === questions.length - 1) {
onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() });
} else {
setQuestionIndex((prev) => prev + 1);
setQuestionIndex(questionIndex + 1);
}
scrollToTop();
};
const back = () => {
if (questionIndex === 0) {
onBack();
onBack({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() });
} else {
setQuestionIndex((prev) => prev - 1);
setQuestionIndex(questionIndex - 1);
}
scrollToTop();
};
return (
<>
<div className="flex flex-col items-center gap-4">
<span className="text-base md:text-lg font-medium text-center px-2 md:px-4 lg:px-48">{prompt}</span>
<div className="flex flex-col gap-2 mt-4 h-fit w-full mb-20 bg-mti-gray-smoke rounded-xl px-16 py-8">
{/*<span className="text-xl font-semibold mb-2">{"Select the appropriate option."}</span>*/}
{questionIndex < questions.length && (
<Question
{...questions[questionIndex]}
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option}
userSolution={answers.find((x) => questions[questionIndex].id === x.question)?.option}
onSelectOption={onSelectOption}
/>
)}
</div>
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={back}>
<div className="absolute left-4">
<Icon path={mdiArrowLeft} color="white" size={1} />
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full"
disabled={
exam && exam.module === "level" && typeof exam.parts[0].intro === "string" && questionIndex === 0}
>
Back
</button>
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={next}>
</Button>
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</Button>
</div>
</>
);

View File

@@ -1,52 +1,303 @@
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import {SpeakingExercise, WritingExercise} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {CommonProps} from ".";
import {Fragment, useEffect, useState} from "react";
import {toast} from "react-toastify";
import { SpeakingExercise } from "@/interfaces/exam";
import { CommonProps } from ".";
import { Fragment, useEffect, useState } from "react";
import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs";
import dynamic from "next/dynamic";
import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
import { downloadBlob } from "@/utils/evaluation";
import axios from "axios";
import Modal from "../Modal";
const Waveform = dynamic(() => import("../Waveform"), { ssr: false });
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
ssr: false,
});
export default function Speaking({ id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack }: SpeakingExercise & CommonProps) {
const [recordingDuration, setRecordingDuration] = useState(0);
const [isRecording, setIsRecording] = useState(false);
const [mediaBlob, setMediaBlob] = useState<string>();
const [audioURL, setAudioURL] = useState<string>();
const [isLoading, setIsLoading] = useState(false);
const [isPromptsModalOpen, setIsPromptsModalOpen] = useState(false);
const [inputText, setInputText] = useState("");
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const saveToStorage = async () => {
if (mediaBlob && mediaBlob.startsWith("blob")) {
const blobBuffer = await downloadBlob(mediaBlob);
const audioFile = new File([blobBuffer], "audio.wav", { type: "audio/wav" });
const seed = Math.random().toString().replace("0.", "");
const formData = new FormData();
formData.append("audio", audioFile, `${seed}.wav`);
formData.append("root", "speaking_recordings");
const config = {
headers: {
"Content-Type": "audio/wav",
},
};
const response = await axios.post<{ path: string }>("/api/storage/insert", formData, config);
if (audioURL) await axios.post("/api/storage/delete", { path: audioURL });
return response.data.path;
}
return undefined;
};
useEffect(() => {
if (userSolutions.length > 0) {
const { solution } = userSolutions[0] as { solution?: string };
if (solution && !mediaBlob) setMediaBlob(solution);
if (solution && !solution.startsWith("blob")) setAudioURL(solution);
}
}, [userSolutions, mediaBlob]);
useEffect(() => {
if (hasExamEnded) next();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
useEffect(() => {
let recordingInterval: NodeJS.Timer | undefined = undefined;
if (isRecording) {
recordingInterval = setInterval(() => setRecordingDuration((prev) => prev + 1), 1000);
} else if (recordingInterval) {
clearInterval(recordingInterval);
}
return () => {
if (recordingInterval) clearInterval(recordingInterval);
};
}, [isRecording]);
const next = async () => {
onNext({
exercise: id,
solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [],
score: { correct: 0, total: 100, missing: 0 },
type,
});
};
const back = async () => {
onBack({
exercise: id,
solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [],
score: { correct: 0, total: 100, missing: 0 },
type,
});
};
const handleNoteWriting = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newText = e.target.value;
const words = newText.match(/\S+/g);
const wordCount = words ? words.length : 0;
if (wordCount <= 100) {
setInputText(newText);
} else {
let count = 0;
let lastIndex = 0;
const matches = newText.matchAll(/\S+/g);
for (const match of matches) {
count++;
if (count > 100) break;
lastIndex = match.index! + match[0].length;
}
setInputText(newText.slice(0, lastIndex));
}
};
export default function Speaking({id, title, text, type, prompts, onNext, onBack}: SpeakingExercise & CommonProps) {
return (
<div className="flex flex-col h-full w-2/3 items-center justify-center gap-8">
<div className="flex flex-col max-w-2xl gap-4">
<span className="font-bold">{title}</span>
<span className="font-regular ml-8">
{text.split("\\n").map((line, index) => (
<Fragment key={index}>
<span>{line}</span>
<br />
</Fragment>
))}
</span>
<div className="flex gap-8">
<span>You should talk about the following things:</span>
<div className="flex flex-col gap-4">
<div className="flex flex-col h-full w-full gap-9">
<Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}>
<div className="flex flex-col items-center justify-center gap-4 w-full h-full">
<div className="flex flex-col gap-1 ml-4">
{prompts.map((x, index) => (
<li className="italic" key={index}>
{x}
</li>
))}
</div>
{!!suffix && <span className="font-bold">{suffix}</span>}
</div>
</Modal>
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-0">
<span className="font-semibold">{title}</span>
{prompts.length > 0 && (
<span className="font-semibold">You should talk for at least 1 minute and 30 seconds for your answer to be valid.</span>
)}
</div>
{!video_url && (
<span className="font-regular">
{text.split("\\n").map((line, index) => (
<Fragment key={index}>
<span>{line}</span>
<br />
</Fragment>
))}
</span>
)}
</div>
<div className="flex flex-col gap-6 items-center">
{video_url && (
<div className="flex flex-col gap-4 w-full items-center">
<video key={id} autoPlay controls className="max-w-3xl rounded-xl">
<source src={video_url} />
</video>
</div>
)}
{prompts && prompts.length > 0 && <Button onClick={() => setIsPromptsModalOpen(true)}>View Prompts</Button>}
</div>
</div>
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
<div className="absolute left-4">
<Icon path={mdiArrowLeft} color="white" size={1} />
{prompts && prompts.length > 0 && (
<div className="w-full h-full flex flex-col gap-4">
<textarea
onContextMenu={(e) => e.preventDefault()}
className="w-full h-full min-h-[200px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
onChange={handleNoteWriting}
value={inputText}
placeholder="Write your notes here..."
spellCheck={false}
/>
<span className="text-base self-end text-mti-gray-cool">Word Count: {(inputText.match(/\S+/g) || []).length}/100</span>
</div>
)}
<ReactMediaRecorder
audio
onStop={(blob) => setMediaBlob(blob)}
render={({ status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl }) => (
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
<p className="text-base font-normal">Record your answer:</p>
<div className="flex gap-8 items-center justify-center py-8">
{status === "idle" && !mediaBlob && (
<>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
{status === "idle" && (
<BsMicFill
onClick={() => {
setRecordingDuration(0);
startRecording();
setIsRecording(true);
}}
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
/>
)}
</>
)}
{status === "recording" && (
<>
<div className="flex gap-4 items-center">
<span className="text-xs w-9">
{Math.floor(recordingDuration / 60)
.toString(10)
.padStart(2, "0")}
:
{Math.floor(recordingDuration % 60)
.toString(10)
.padStart(2, "0")}
</span>
</div>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
<div className="flex gap-4 items-center">
<BsPauseCircle
onClick={() => {
setIsRecording(false);
pauseRecording();
}}
className="text-red-500 w-8 h-8 cursor-pointer"
/>
<BsCheckCircleFill
onClick={() => {
setIsRecording(false);
stopRecording();
}}
className="text-mti-purple-light w-8 h-8 cursor-pointer"
/>
</div>
</>
)}
{status === "paused" && (
<>
<div className="flex gap-4 items-center">
<span className="text-xs w-9">
{Math.floor(recordingDuration / 60)
.toString(10)
.padStart(2, "0")}
:
{Math.floor(recordingDuration % 60)
.toString(10)
.padStart(2, "0")}
</span>
</div>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
<div className="flex gap-4 items-center">
<BsPlayCircle
onClick={() => {
setIsRecording(true);
resumeRecording();
}}
className="text-mti-purple-light w-8 h-8 cursor-pointer"
/>
<BsCheckCircleFill
onClick={() => {
setIsRecording(false);
stopRecording();
}}
className="text-mti-purple-light w-8 h-8 cursor-pointer"
/>
</div>
</>
)}
{((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && (
<>
<Waveform audio={mediaBlobUrl ? mediaBlobUrl : mediaBlob!} waveColor="#FCDDEC" progressColor="#EF5DA8" />
<div className="flex gap-4 items-center">
<BsTrashFill
className="text-mti-gray-cool cursor-pointer w-5 h-5"
onClick={() => {
setRecordingDuration(0);
clearBlobUrl();
setMediaBlob(undefined);
}}
/>
<BsMicFill
onClick={() => {
clearBlobUrl();
setRecordingDuration(0);
startRecording();
setIsRecording(true);
setMediaBlob(undefined);
}}
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
/>
</div>
</>
)}
</div>
</div>
)}
/>
<div className="self-end flex justify-between w-full gap-8">
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
Back
</button>
<button
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
onClick={() => onNext({exercise: id, solutions: [], score: {correct: 1, total: 1}, type})}>
</Button>
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</Button>
</div>
</div>
);

View File

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

View File

@@ -3,14 +3,17 @@ import {WriteBlanksExercise} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {useEffect, useState} from "react";
import {Fragment, useEffect, useState} from "react";
import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
import {toast} from "react-toastify";
import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
function Blank({
id,
maxWords,
userSolution,
showSolutions = false,
setUserSolution,
}: {
@@ -19,13 +22,13 @@ function Blank({
userSolution?: string;
maxWords: number;
showSolutions?: boolean;
setUserSolution?: (solution: string) => void;
setUserSolution: (solution: string) => void;
}) {
const [userInput, setUserInput] = useState("");
const [userInput, setUserInput] = useState(userSolution || "");
useEffect(() => {
const words = userInput.split(" ").filter((x) => x !== "");
if (words.length >= maxWords) {
const words = userInput.split(" ");
if (words.length > maxWords) {
toast.warning(`You have reached your word limit of ${maxWords} words!`, {toastId: "word-limit"});
setUserInput(words.join(" ").trim());
}
@@ -33,39 +36,48 @@ function Blank({
return (
<input
className={clsx("input border rounded-xl px-2 py-1 bg-white text-blue-400 border-blue-400 my-2")}
className="py-2 px-3 mx-2 rounded-2xl w-48 bg-white focus:outline-none my-2"
placeholder={id}
onChange={(e) => setUserInput(e.target.value)}
onBlur={() => setUserSolution(userInput)}
value={userInput}
contentEditable={showSolutions}
/>
);
}
export default function WriteBlanks({id, prompt, type, maxWords, solutions, text, onNext, onBack}: WriteBlanksExercise & CommonProps) {
const [userSolutions, setUserSolutions] = useState<{id: string; solution: string}[]>([]);
export default function WriteBlanks({id, prompt, type, maxWords, solutions, userSolutions, text, onNext, onBack}: WriteBlanksExercise & CommonProps) {
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = userSolutions.filter(
const correct = answers.filter(
(x) =>
solutions
.find((y) => x.id === y.id)
?.solution.map((y) => y.toLowerCase())
.includes(x.solution.toLowerCase()) || false,
.find((y) => x.id.toString() === y.id.toString())
?.solution.map((y) => y.toLowerCase().trim())
.includes(x.solution.toLowerCase().trim()) || false,
).length;
const missing = total - answers.filter((x) => solutions.find((y) => x.id === y.id)).length;
return {total, correct};
return {total, correct, missing};
};
const renderLines = (line: string) => {
return (
<span>
<span className="text-base leading-5">
{reactStringReplace(line, /({{\d+}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = userSolutions.find((x) => x.id === id);
const userSolution = answers.find((x) => x.id === id);
const setUserSolution = (solution: string) => {
setUserSolutions((prev) => [...prev.filter((x) => x.id !== id), {id, solution}]);
setAnswers((prev) => [...prev.filter((x) => x.id !== id), {id, solution}]);
};
return <Blank userSolution={userSolution?.solution} maxWords={maxWords} id={id} setUserSolution={setUserSolution} />;
@@ -76,33 +88,40 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, text
return (
<>
<div className="flex flex-col">
<span className="text-lg font-medium text-center px-48">{prompt}</span>
<span>
{text.split("\\n").map((line) => (
<>
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<span key={index}>
{line}
<br />
</span>
))}
</span>
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
{text.split("\\n").map((line, index) => (
<p key={index}>
{renderLines(line)}
<br />
</>
</p>
))}
</span>
</div>
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
<div className="absolute left-4">
<Icon path={mdiArrowLeft} color="white" size={1} />
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] w-full">
Back
</button>
<button
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}>
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</Button>
</div>
</>
);

View File

@@ -1,16 +1,73 @@
/* eslint-disable @next/next/no-img-element */
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import {WritingExercise} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {CommonProps} from ".";
import {Fragment, useEffect, useState} from "react";
import React, {Fragment, useEffect, useRef, useState} from "react";
import {toast} from "react-toastify";
import Button from "../Low/Button";
import {Dialog, Transition} from "@headlessui/react";
import useExamStore from "@/stores/examStore";
export default function Writing({id, prompt, info, type, wordCounter, attachment, onNext, onBack}: WritingExercise & CommonProps) {
const [inputText, setInputText] = useState("");
export default function Writing({
id,
prompt,
prefix,
suffix,
type,
wordCounter,
attachment,
userSolutions,
onNext,
onBack,
}: WritingExercise & CommonProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : "");
const [isSubmitEnabled, setIsSubmitEnabled] = useState(false);
const [saveTimer, setSaveTimer] = useState(0);
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
const saveTimerInterval = setInterval(() => {
setSaveTimer((prev) => prev + 1);
}, 1000);
return () => {
clearInterval(saveTimerInterval);
};
}, []);
useEffect(() => {
if (inputText.length > 0 && saveTimer % 10 === 0) {
setUserSolutions([
...storeUserSolutions.filter((x) => x.exercise !== id),
{exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type, module: "writing"},
]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [saveTimer]);
useEffect(() => {
if (localStorage.getItem("enable_paste")) return;
const listener = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "v") {
e.preventDefault();
}
};
document.addEventListener("keydown", listener);
return () => {
document.removeEventListener("keydown", listener);
};
}, []);
useEffect(() => {
if (hasExamEnded)
onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type, module: "writing"});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
useEffect(() => {
const words = inputText.split(" ").filter((x) => x !== "");
@@ -27,59 +84,92 @@ export default function Writing({id, prompt, info, type, wordCounter, attachment
}, [inputText, wordCounter]);
return (
<div className="flex flex-col h-full w-2/3 items-center justify-center gap-8">
<div className="flex flex-col max-w-2xl gap-2">
<span>{info}</span>
<span className="font-bold ml-8">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
<span>{line}</span>
<br />
</Fragment>
))}
</span>
<span>
You should write {wordCounter.type === "min" ? "at least" : "at most"} {wordCounter.limit} words.
</span>
{attachment && <img src={attachment} alt="Exercise attachment" />}
<>
{attachment && (
<Transition show={isModalOpen} as={Fragment}>
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-black/30" />
</Transition.Child>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95">
<div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="w-fit h-fit rounded-xl bg-white">
<img src={attachment.url} alt={attachment.description} className="max-w-4xl w-full self-center rounded-xl p-4" />
</Dialog.Panel>
</div>
</Transition.Child>
</Dialog>
</Transition>
)}
<div className="flex flex-col h-full w-full gap-9 mb-20">
<div className="flex flex-col w-full gap-7 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
<span className="whitespace-pre-wrap">{prefix.replaceAll("\\n", "\n")}</span>
<span className="font-semibold whitespace-pre-wrap">{prompt.replaceAll("\\n", "\n")}</span>
{attachment && (
<img
onClick={() => setIsModalOpen(true)}
src={attachment.url}
alt={attachment.description}
className="max-w-md self-center rounded-xl cursor-pointer"
/>
)}
</div>
<div className="w-full h-full flex flex-col gap-4">
<span className="whitespace-pre-wrap">{suffix}</span>
<textarea
onContextMenu={(e) => e.preventDefault()}
className="w-full h-full min-h-[300px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
onChange={(e) => setInputText(e.target.value)}
value={inputText}
placeholder="Write your text here..."
spellCheck={false}
/>
<span className="text-base self-end text-mti-gray-cool">Word Count: {inputText.split(" ").filter((x) => x !== "").length}</span>
</div>
</div>
<textarea
className="w-full h-1/3 cursor-text p-2 input input-bordered bg-white"
onChange={(e) => setInputText(e.target.value)}
value={inputText}
placeholder="Write your text here..."
/>
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
<div className="absolute left-4">
<Icon path={mdiArrowLeft} color="white" size={1} />
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() =>
onBack({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type})
}
className="max-w-[200px] self-end w-full">
Back
</button>
{!isSubmitEnabled && (
<div className="tooltip" data-tip={`You have not yet reached your minimum word count of ${wordCounter.limit} words!`}>
<button
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
disabled={!isSubmitEnabled}
onClick={() => onNext({exercise: id, solutions: [inputText], score: {correct: 1, total: 1}, type})}>
Next
</button>
</div>
)}
{isSubmitEnabled && (
<button
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
disabled={!isSubmitEnabled}
onClick={() => onNext({exercise: id, solutions: [inputText], score: {correct: 1, total: 1}, type})}>
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
)}
</Button>
<Button
color="purple"
disabled={!isSubmitEnabled}
onClick={() =>
onNext({
exercise: id,
solutions: [{id, solution: inputText.replaceAll(/\s{2,}/g, " ")}],
score: {correct: 100, total: 100, missing: 0},
type,
module: "writing",
})
}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</div>
</>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,82 @@
import {FillBlanksExercise} from "@/interfaces/exam";
import React from "react";
import Input from "@/components/Low/Input";
interface Props {
exercise: FillBlanksExercise;
updateExercise: (data: any) => void;
}
const FillBlanksEdit = (props: Props) => {
const {exercise, updateExercise} = props;
return (
<>
<Input
type="text"
label="Prompt"
name="prompt"
required
value={exercise.prompt}
onChange={(value) =>
updateExercise({
prompt: value,
})
}
/>
<Input
type="text"
label="Text"
name="text"
required
value={exercise.text}
onChange={(value) =>
updateExercise({
text: value,
})
}
/>
<h1>Solutions</h1>
<div className="w-full flex flex-wrap -mx-2">
{exercise.solutions.map((solution, index) => (
<div key={solution.id} className="flex sm:w-1/2 lg:w-1/4 px-2">
<Input
type="text"
label={`Solution ${index + 1}`}
name="solution"
required
value={solution.solution}
onChange={(value) =>
updateExercise({
solutions: exercise.solutions.map((sol) => (sol.id === solution.id ? {...sol, solution: value} : sol)),
})
}
/>
</div>
))}
</div>
<h1>Words</h1>
<div className="w-full flex flex-wrap -mx-2">
{exercise.words.map((word, index) => (
<div key={index} className="flex sm:w-1/2 lg:w-1/4 px-2">
<Input
type="text"
label={`Word ${index + 1}`}
name="word"
required
value={typeof word === "string" ? word : ("word" in word ? word.word : "")}
onChange={(value) =>
updateExercise({
words: exercise.words.map((sol, idx) =>
index === idx ? (typeof word === "string" ? value : {...word, word: value}) : sol,
),
})
}
/>
</div>
))}
</div>
</>
);
};
export default FillBlanksEdit;

View File

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

View File

@@ -0,0 +1,130 @@
import React from "react";
import { MatchSentencesExercise } from "@/interfaces/exam";
import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select";
interface Props {
exercise: MatchSentencesExercise;
updateExercise: (data: any) => void;
}
const MatchSentencesEdit = (props: Props) => {
const { exercise, updateExercise } = props;
const selectOptions = exercise.options.map((option) => ({
value: option.id,
label: option.id,
}));
return (
<>
<Input
type="text"
label="Prompt"
name="prompt"
required
value={exercise.prompt}
onChange={(value) =>
updateExercise({
prompt: value,
})
}
/>
<h1>Solutions</h1>
<div className="w-full flex flex-wrap -mx-2">
{exercise.sentences.map((sentence, index) => (
<div key={sentence.id} className="flex flex-col w-full px-2">
<div className="flex w-full">
<Input
type="text"
label={`Sentence ${index + 1}`}
name="sentence"
required
value={sentence.sentence}
onChange={(value) =>
updateExercise({
sentences: exercise.sentences.map((iSol) =>
iSol.id === sentence.id
? {
...iSol,
sentence: value,
}
: iSol
),
})
}
className="px-2"
/>
<div className="w-48 flex items-end px-2">
<Select
value={selectOptions.find(
(o) => o.value === sentence.solution
)}
options={selectOptions}
onChange={(value) => {
updateExercise({
sentences: exercise.sentences.map((iSol) =>
iSol.id === sentence.id
? {
...iSol,
solution: value?.value,
}
: iSol
),
});
}}
/>
</div>
</div>
</div>
))}
<h1>Options</h1>
<div className="w-full flex flex-wrap -mx-2">
{exercise.options.map((option, index) => (
<div key={option.id} className="flex flex-col w-full px-2">
<div className="flex w-full">
<Input
type="text"
label={`Option ${index + 1}`}
name="option"
required
value={option.sentence}
onChange={(value) =>
updateExercise({
options: exercise.options.map((iSol) =>
iSol.id === option.id
? {
...iSol,
sentence: value,
}
: iSol
),
})
}
className="px-2"
/>
<div className="w-48 flex items-end px-2">
<Select
value={{
value: option.id,
label: option.id,
}}
options={[
{
value: option.id,
label: option.id,
},
]}
disabled
onChange={() => {}}
/>
</div>
</div>
</div>
))}
</div>
</div>
</>
);
};
export default MatchSentencesEdit;

View File

@@ -0,0 +1,137 @@
import React from "react";
import Input from "@/components/Low/Input";
import {
MultipleChoiceExercise,
MultipleChoiceQuestion,
} from "@/interfaces/exam";
import Select from "@/components/Low/Select";
interface Props {
exercise: MultipleChoiceExercise;
updateExercise: (data: any) => void;
}
const variantOptions = [
{ value: "text", label: "Text", key: "text" },
{ value: "image", label: "Image", key: "src" },
];
const MultipleChoiceEdit = (props: Props) => {
const { exercise, updateExercise } = props;
return (
<>
<h1>Questions</h1>
<div className="w-full flex-no-wrap -mx-2">
{exercise.questions.map((question: MultipleChoiceQuestion, index) => {
const variantValue = variantOptions.find(
(o) => o.value === question.variant
);
const solutionsOptions = question.options.map((option) => ({
value: option.id,
label: option.id,
}));
const solutionValue = solutionsOptions.find(
(o) => o.value === question.solution
);
return (
<div key={question.id} className="flex w-full px-2 flex-col">
<span>Question ID: {question.id}</span>
<Input
type="text"
label="Prompt"
name="prompt"
required
value={question.prompt}
onChange={(value) =>
updateExercise({
questions: exercise.questions.map((sol) =>
sol.id === question.id ? { ...sol, prompt: value } : sol
),
})
}
/>
<div className="flex w-full">
<div className="w-48 flex items-end px-2">
<Select
value={solutionValue}
options={solutionsOptions}
onChange={(value) => {
updateExercise({
questions: exercise.questions.map((sol) =>
sol.id === question.id
? { ...sol, solution: value?.value }
: sol
),
});
}}
/>
</div>
<div className="w-48 flex items-end px-2">
<Select
value={variantValue}
options={variantOptions}
onChange={(value) => {
updateExercise({
questions: exercise.questions.map((sol) =>
sol.id === question.id
? { ...sol, variant: value?.value }
: sol
),
});
}}
/>
</div>
</div>
<div className="flex w-full flex-wrap -mx-2">
{question.options.map((option) => (
<div
key={option.id}
className="flex sm:w-1/2 lg:w-1/4 px-2 px-2"
>
<Input
type="text"
label={`Option ${option.id}`}
name="option"
required
value={option.text}
onChange={(value) =>
updateExercise({
questions: exercise.questions.map((sol) =>
sol.id === question.id
? {
...sol,
options: sol.options.map((opt) => {
if (
opt.id === option.id &&
variantValue?.key
) {
return {
...opt,
[variantValue.key]: value,
};
}
return opt;
}),
}
: sol
),
})
}
/>
</div>
))}
</div>
</div>
);
})}
</div>
</>
);
};
export default MultipleChoiceEdit;

View File

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

View File

@@ -0,0 +1,71 @@
import React from "react";
import { TrueFalseExercise } from "@/interfaces/exam";
import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select";
interface Props {
exercise: TrueFalseExercise;
updateExercise: (data: any) => void;
}
const options = [
{ value: "true", label: "True" },
{ value: "false", label: "False" },
{ value: "not_given", label: "Not Given" },
];
const TrueFalseEdit = (props: Props) => {
const { exercise, updateExercise } = props;
return (
<>
<Input
type="text"
label="Prompt"
name="prompt"
required
value={exercise.prompt}
onChange={(value) =>
updateExercise({
prompt: value,
})
}
/>
<h1>Questions</h1>
<div className="w-full flex-no-wrap -mx-2">
{exercise.questions.map((question, index) => (
<div key={question.id} className="flex w-full px-2">
<Input
type="text"
label={`Question ${index + 1}`}
name="question"
required
value={question.prompt}
onChange={(value) =>
updateExercise({
questions: exercise.questions.map((sol) =>
sol.id === question.id ? { ...sol, prompt: value } : sol
),
})
}
/>
<div className="w-48 flex items-end px-2">
<Select
value={options.find((o) => o.value === question.solution)}
options={options}
onChange={(value) => {
updateExercise({
questions: exercise.questions.map((sol) =>
sol.id === question.id ? { ...sol, solution: value?.value } : sol
),
});
}}
className="h-18"
/>
</div>
</div>
))}
</div>
</>
);
};
export default TrueFalseEdit;

View File

@@ -0,0 +1,94 @@
import React from "react";
import Input from "@/components/Low/Input";
import { WriteBlanksExercise } from "@/interfaces/exam";
interface Props {
exercise: WriteBlanksExercise;
updateExercise: (data: any) => void;
}
const WriteBlankEdits = (props: Props) => {
const { exercise, updateExercise } = props;
return (
<>
<Input
type="text"
label="Prompt"
name="prompt"
required
value={exercise.prompt}
onChange={(value) =>
updateExercise({
prompt: value,
})
}
/>
<Input
type="text"
label="Text"
name="text"
required
value={exercise.text}
onChange={(value) =>
updateExercise({
text: value,
})
}
/>
<Input
type="text"
label="Max Words"
name="number"
required
value={exercise.maxWords}
onChange={(value) =>
updateExercise({
maxWords: Number(value),
})
}
/>
<h1>Solutions</h1>
<div className="w-full flex flex-wrap -mx-2">
{exercise.solutions.map((solution) => (
<div key={solution.id} className="flex flex-col w-full px-2">
<span>Solution ID: {solution.id}</span>
{/* TODO: Consider adding an add and delete button */}
<div className="flex flex-wrap">
{solution.solution.map((sol, solIndex) => (
<Input
key={`${sol}-${solIndex}`}
type="text"
label={`Solution ${solIndex + 1}`}
name="solution"
required
value={sol}
onChange={(value) =>
updateExercise({
solutions: exercise.solutions.map((iSol) =>
iSol.id === solution.id
? {
...iSol,
solution: iSol.solution.map((iiSol, iiIndex) => {
if (iiIndex === solIndex) {
return value;
}
return iiSol;
}),
}
: iSol
),
})
}
className="sm:w-1/2 lg:w-1/4 px-2"
/>
))}
</div>
</div>
))}
</div>
</>
);
};
export default WriteBlankEdits;

View File

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

View File

@@ -0,0 +1,32 @@
import {EmploymentStatus, EMPLOYMENT_STATUS} from "@/interfaces/user";
import {RadioGroup} from "@headlessui/react";
import clsx from "clsx";
interface Props {
value?: EmploymentStatus;
onChange: (value?: EmploymentStatus) => void;
}
export default function EmploymentStatusInput({value, onChange}: Props) {
return (
<div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Employment Status *</label>
<RadioGroup value={value} onChange={onChange} className="grid grid-cols-2 items-center gap-4 place-items-center">
{EMPLOYMENT_STATUS.map(({status, label}) => (
<RadioGroup.Option value={status} key={status}>
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-40 md:w-48 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
{label}
</span>
)}
</RadioGroup.Option>
))}
</RadioGroup>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import {Gender} from "@/interfaces/user";
import {RadioGroup} from "@headlessui/react";
import clsx from "clsx";
interface Props {
value?: Gender;
onChange: (value?: Gender) => void;
}
export default function GenderInput({value, onChange}: Props) {
return (
<div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Gender *</label>
<RadioGroup value={value} onChange={onChange} className="flex flex-row gap-4 justify-between">
<RadioGroup.Option value="male">
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
Male
</span>
)}
</RadioGroup.Option>
<RadioGroup.Option value="female">
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
Female
</span>
)}
</RadioGroup.Option>
<RadioGroup.Option value="other">
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
Other
</span>
)}
</RadioGroup.Option>
</RadioGroup>
</div>
);
}

View File

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

View File

@@ -0,0 +1,179 @@
import useUsers from "@/hooks/useUsers";
import {Ticket, TicketStatus, TicketStatusLabel, TicketType, TicketTypeLabel} from "@/interfaces/ticket";
import {User} from "@/interfaces/user";
import {USER_TYPE_LABELS} from "@/resources/user";
import axios from "axios";
import moment from "moment";
import {useState} from "react";
import {toast} from "react-toastify";
import ShortUniqueId from "short-unique-id";
import Button from "../Low/Button";
import Input from "../Low/Input";
import Select from "../Low/Select";
import {checkAccess} from "@/utils/permissions";
interface Props {
user: User;
ticket: Ticket;
onClose: () => void;
}
export default function TicketDisplay({user, ticket, onClose}: Props) {
const [subject] = useState(ticket.subject);
const [type, setType] = useState<TicketType>(ticket.type);
const [description] = useState(ticket.description);
const [reporter] = useState(ticket.reporter);
const [reportedFrom] = useState(ticket.reportedFrom);
const [status, setStatus] = useState(ticket.status);
const [assignedTo, setAssignedTo] = useState<string | null>(ticket.assignedTo || null);
const [isLoading, setIsLoading] = useState(false);
const {users} = useUsers();
const submit = () => {
if (!type) return toast.error("Please choose a type!", {toastId: "missing-type"});
setIsLoading(true);
axios
.patch(`/api/tickets/${ticket.id}`, {
subject,
type,
description,
reporter,
reportedFrom,
status,
assignedTo,
})
.then(() => {
toast.success(`The ticket has been updated!`, {toastId: "submitted"});
onClose();
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong, please try again later!", {
toastId: "error",
});
})
.finally(() => setIsLoading(false));
};
const del = () => {
if (!confirm("Are you sure you want to delete this ticket?")) return;
setIsLoading(true);
axios
.delete(`/api/tickets/${ticket.id}`)
.then(() => {
toast.success(`The ticket has been deleted!`, {toastId: "submitted"});
onClose();
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong, please try again later!", {
toastId: "error",
});
})
.finally(() => setIsLoading(false));
};
return (
<form className="flex flex-col gap-4 pt-8">
<Input label="Subject" type="text" name="subject" placeholder="Subject..." value={subject} onChange={(e) => null} disabled />
<div className="-md:flex-col flex w-full items-center gap-4">
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">Status</label>
<Select
options={Object.keys(TicketStatusLabel).map((x) => ({
value: x,
label: TicketStatusLabel[x as keyof typeof TicketStatusLabel],
}))}
value={{value: status, label: TicketStatusLabel[status]}}
onChange={(value) => setStatus((value?.value as TicketStatus) ?? undefined)}
placeholder="Status..."
/>
</div>
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">Type</label>
<Select
options={Object.keys(TicketTypeLabel).map((x) => ({
value: x,
label: TicketTypeLabel[x as keyof typeof TicketTypeLabel],
}))}
value={{value: type, label: TicketTypeLabel[type]}}
onChange={(value) => setType(value!.value as TicketType)}
placeholder="Type..."
/>
</div>
</div>
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">Assignee</label>
<Select
options={[
{value: "me", label: "Assign to me"},
...users
.filter((x) => checkAccess(x, ["admin", "developer", "agent"]))
.map((u) => ({
value: u.id,
label: `${u.name} - ${u.email}`,
})),
]}
disabled={checkAccess(user, ["agent"])}
value={
assignedTo
? {
value: assignedTo,
label: `${users.find((u) => u.id === assignedTo)?.name} - ${users.find((u) => u.id === assignedTo)?.email}`,
}
: null
}
onChange={(value) => (value ? setAssignedTo(value.value === "me" ? user.id : value.value) : setAssignedTo(null))}
placeholder="Assignee..."
isClearable
/>
</div>
<div className="-md:flex-col flex w-full items-center gap-4">
<Input label="Reported From" type="text" name="reportedFrom" onChange={() => null} value={reportedFrom} disabled />
<Input label="Date" type="text" name="date" onChange={() => null} value={moment(ticket.date).format("DD/MM/YYYY - HH:mm")} disabled />
</div>
<div className="-md:flex-col flex w-full items-center gap-4">
<Input label="Reporter's Name" type="text" name="reporter" onChange={() => null} value={reporter.name} disabled />
<Input label="Reporter's E-mail" type="text" name="reporter" onChange={() => null} value={reporter.email} disabled />
<Input
label="Reporter's Type"
type="text"
name="reporterType"
onChange={() => null}
value={USER_TYPE_LABELS[reporter.type]}
disabled
/>
</div>
<textarea
className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8"
placeholder="Write your ticket's description here..."
contentEditable={false}
value={description}
spellCheck
/>
<div className="-md:flex-col-reverse mt-2 flex w-full items-center justify-between gap-4">
<Button type="button" color="red" className="w-full md:max-w-[200px]" variant="outline" onClick={del} isLoading={isLoading}>
Delete
</Button>
<div className="-md:flex-col-reverse flex w-full items-center justify-end gap-4">
<Button type="button" color="red" className="w-full md:max-w-[200px]" variant="outline" onClick={onClose} isLoading={isLoading}>
Cancel
</Button>
<Button type="button" className="w-full md:max-w-[200px]" isLoading={isLoading} onClick={submit}>
Update
</Button>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,116 @@
import {Ticket, TicketType, TicketTypeLabel} from "@/interfaces/ticket";
import {User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore";
import axios from "axios";
import {useState} from "react";
import {toast} from "react-toastify";
import ShortUniqueId from "short-unique-id";
import Button from "../Low/Button";
import Input from "../Low/Input";
import Select from "../Low/Select";
interface Props {
user: User;
page: string;
onClose: () => void;
}
export default function TicketSubmission({user, page, onClose}: Props) {
const [subject, setSubject] = useState("");
const [type, setType] = useState<TicketType>();
const [description, setDescription] = useState("");
const [isLoading, setIsLoading] = useState(false);
const examState = useExamStore((state) => state);
const submit = () => {
if (!type) return toast.error("Please choose a type!", {toastId: "missing-type"});
if (subject.trim() === "")
return toast.error("Please input a subject!", {
toastId: "missing-subject",
});
if (description.trim() === "")
return toast.error("Please describe your ticket!", {
toastId: "missing-desc",
});
setIsLoading(true);
const shortUID = new ShortUniqueId();
const ticket: Ticket = {
id: shortUID.randomUUID(8),
date: new Date().toISOString(),
reporter: {
id: user.id,
email: user.email,
name: user.name,
type: user.type,
},
status: "submitted",
subject,
type,
reportedFrom: page,
description,
examInformation:
page.includes("exam") || page.includes("exercises")
? {
exam: examState.exam?.id || "",
exams: examState.exams.map((x) => x.id),
exerciseIndex: examState.exerciseIndex,
moduleIndex: examState.moduleIndex,
partIndex: examState.partIndex,
questionIndex: examState.questionIndex,
selectedModules: examState.selectedModules,
}
: undefined,
};
axios
.post(`/api/tickets`, ticket)
.then(() => {
toast.success(`Your ticket has been submitted! You will be contacted by e-mail for further discussion.`, {toastId: "submitted"});
onClose();
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong, please try again later!", {
toastId: "error",
});
})
.finally(() => setIsLoading(false));
};
return (
<form className="flex flex-col gap-4 pt-8">
<Input label="Subject" type="text" name="subject" placeholder="Subject..." onChange={(e) => setSubject(e)} />
<div className="-md:flex-col flex w-full items-center gap-4">
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">Type</label>
<Select
options={Object.keys(TicketTypeLabel).map((x) => ({
value: x,
label: TicketTypeLabel[x as keyof typeof TicketTypeLabel],
}))}
onChange={(value) => setType((value?.value as TicketType) ?? undefined)}
placeholder="Type..."
/>
</div>
<Input label="Reporter" type="text" name="reporter" onChange={() => null} value={`${user.name} - ${user.email}`} disabled />
</div>
<textarea
className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8"
onChange={(e) => setDescription(e.target.value)}
placeholder="Write your ticket's description here..."
spellCheck
/>
<div className="mt-2 flex w-full items-center justify-end gap-4">
<Button type="button" color="red" className="w-full max-w-[200px]" variant="outline" onClick={onClose} isLoading={isLoading}>
Cancel
</Button>
<Button type="button" className="w-full max-w-[200px]" isLoading={isLoading} onClick={submit}>
Submit
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,39 @@
import { useCallback } from "react";
const HighlightContent: React.FC<{
html: string;
highlightPhrases: string[],
firstOccurence?: boolean
}> = ({
html,
highlightPhrases,
firstOccurence = false
}) => {
const createHighlightedContent = useCallback(() => {
if (highlightPhrases.length === 0) {
return { __html: html };
}
const escapeRegExp = (string: string) => {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
};
const regex = new RegExp(`(${highlightPhrases.map(escapeRegExp).join('|')})`, 'i');
const globalRegex = new RegExp(`(${highlightPhrases.map(escapeRegExp).join('|')})`, 'gi');
let highlightedHtml = html;
if (firstOccurence) {
highlightedHtml = html.replace(regex, (match) => `<span style="background-color: yellow;">${match}</span>`);
} else {
highlightedHtml = html.replace(globalRegex, (match) => `<span style="background-color: yellow;">${match}</span>`);
}
return { __html: highlightedHtml };
}, [html, highlightPhrases, firstOccurence]);
return <div dangerouslySetInnerHTML={createHighlightedContent()} />;
};
export default HighlightContent;

View File

@@ -0,0 +1,168 @@
import React, { useRef, useEffect, useState, useCallback, ReactNode } from 'react';
import { useSpring, animated } from '@react-spring/web';
import { useDrag } from '@use-gesture/react';
import clsx from 'clsx';
interface InfiniteCarouselProps {
children: React.ReactNode;
height: string;
speed?: number;
gap?: number;
overlay?: ReactNode;
overlayFunc?: (index: number) => void;
overlayClassName?: string;
}
const InfiniteCarousel: React.FC<InfiniteCarouselProps> = ({
children,
height,
speed = 20000,
gap = 16,
overlay = undefined,
overlayFunc = undefined,
overlayClassName = ""
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState<number>(0);
const itemCount = React.Children.count(children);
const [isDragging, setIsDragging] = useState<boolean>(false);
const [itemWidth, setItemWidth] = useState<number>(0);
const [isInfinite, setIsInfinite] = useState<boolean>(true);
const dragStartX = useRef<number>(0);
useEffect(() => {
const handleResize = () => {
if (containerRef.current) {
const containerWidth = containerRef.current.clientWidth;
setContainerWidth(containerWidth);
const firstChild = containerRef.current.firstElementChild?.firstElementChild as HTMLElement;
if (firstChild) {
const childWidth = firstChild.offsetWidth;
setItemWidth(childWidth);
const totalContentWidth = (childWidth + gap) * itemCount - gap;
setIsInfinite(totalContentWidth > containerWidth);
}
}
};
handleResize();
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [gap, itemCount]);
const totalWidth = (itemWidth + gap) * itemCount;
const [{ x }, api] = useSpring(() => ({
from: { x: 0 },
to: { x: -totalWidth },
config: { duration: speed },
loop: true,
}));
const startAnimation = useCallback(() => {
if (isInfinite) {
api.start({
from: { x: x.get() },
to: { x: x.get() - totalWidth },
config: { duration: speed },
loop: true,
});
} else {
api.stop();
api.start({ x: 0, immediate: true });
}
}, [api, x, totalWidth, speed, isInfinite]);
useEffect(() => {
if (containerWidth > 0 && !isDragging) {
startAnimation();
}
}, [containerWidth, isDragging, startAnimation]);
const bind = useDrag(({ down, movement: [mx], first }) => {
if (!isInfinite) return;
if (first) {
setIsDragging(true);
api.stop();
dragStartX.current = x.get();
}
if (down) {
let newX = dragStartX.current + mx;
newX = ((newX % totalWidth) + totalWidth) % totalWidth;
if (newX > 0) newX -= totalWidth;
api.start({ x: newX, immediate: true });
} else {
setIsDragging(false);
startAnimation();
}
}, {
filterTaps: true,
from: () => [x.get(), 0],
});
return (
<div
className="overflow-hidden relative select-none"
style={{ height, touchAction: 'pan-y' }}
ref={containerRef}
{...(isInfinite ? bind() : {})}
>
<animated.div
className="flex"
style={{
display: 'flex',
willChange: 'transform',
transform: isInfinite
? x.to((x) => `translate3d(${x}px, 0, 0)`)
: 'none',
gap: `${gap}px`,
width: 'fit-content',
}}
>
{React.Children.map(children, (child, i) => (
<div
key={i}
className="flex-shrink-0 relative"
>
{overlay !== undefined && overlayFunc !== undefined && (
<div className={clsx('absolute', overlayClassName)} onClick={() => overlayFunc(i)}>
{overlay}
</div>
)}
<div
className="select-none"
style={{ pointerEvents: 'none' }}
>
{child}
</div>
</div>
))}
{isInfinite && React.Children.map(children, (child, i) => (
<div
key={`clone-${i}`}
className="flex-shrink-0 relative"
>
{overlay !== undefined && overlayFunc !== undefined && (
<div className={clsx('absolute', overlayClassName)} onClick={() => overlayFunc(i)}>
{overlay}
</div>
)}
<div
className="select-none"
style={{ pointerEvents: 'none' }}
>
{child}
</div>
</div>
))}
</animated.div>
</div>
);
};
export default InfiniteCarousel;

51
src/components/List.tsx Normal file
View File

@@ -0,0 +1,51 @@
import {Column, flexRender, getCoreRowModel, getSortedRowModel, useReactTable} from "@tanstack/react-table";
export default function List<T>({data, columns}: {data: T[]; columns: any[]}) {
const table = useReactTable({
data,
columns: columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
return (
<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 key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder ? null : (
<>
<div
{...{
className: header.column.getCanSort() ? "cursor-pointer select-none py-4 text-left first:pl-4" : "",
onClick: header.column.getToggleSortingHandler(),
}}>
{flexRender(header.column.columnDef.header, header.getContext())}
{{
asc: " 🔼",
desc: " 🔽",
}[header.column.getIsSorted() as string] ?? null}
</div>
</>
)}
</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,96 @@
import {Module} from "@/interfaces";
import {formatTimeInMinutes} from "@/utils/string";
import clsx from "clsx";
import {useEffect, useRef, useState} from "react";
import {BsPauseFill, BsPlayFill} from "react-icons/bs";
import ProgressBar from "./ProgressBar";
interface Props {
src: string;
color: "red" | "rose" | "purple" | Module;
autoPlay?: boolean;
disabled?: boolean;
onEnd?: () => void;
disablePause?: boolean;
}
export default function AudioPlayer({src, color, autoPlay = false, disabled = false, onEnd, disablePause = false}: Props) {
const [isPlaying, setIsPlaying] = useState(false);
const [duration, setDuration] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
const durationInterval = setInterval(() => {
if (duration > 0) clearInterval(durationInterval);
const seconds = Math.floor(audioPlayerRef?.current?.duration || 0);
if (seconds > 0) setDuration(seconds);
}, 300);
if (duration > 0) clearInterval(durationInterval);
return () => {
clearInterval(durationInterval);
};
}, [duration]);
useEffect(() => {
let playingInterval: NodeJS.Timer | undefined = undefined;
if (isPlaying) {
playingInterval = setInterval(() => setCurrentTime((prev) => prev + 1), 1000);
} else if (playingInterval) {
clearInterval(playingInterval);
}
return () => {
if (playingInterval) clearInterval(playingInterval);
};
}, [isPlaying]);
const togglePlayPause = () => {
const prevValue = isPlaying;
setIsPlaying(!prevValue);
if (!prevValue) {
audioPlayerRef?.current?.play();
} else {
audioPlayerRef?.current?.pause();
}
};
return (
<div className="w-full h-fit flex gap-4 items-center mt-2">
{isPlaying && (
<BsPauseFill
className={clsx("text-mti-gray-cool cursor-pointer w-5 h-5", (disabled || disablePause) && "opacity-60 cursor-not-allowed")}
onClick={disabled || disablePause ? undefined : togglePlayPause}
/>
)}
{!isPlaying && (
<BsPlayFill
className={clsx("text-mti-gray-cool cursor-pointer w-5 h-5", disabled && "opacity-60 cursor-not-allowed")}
onClick={disabled ? undefined : togglePlayPause}
/>
)}
<audio
src={src}
autoPlay={autoPlay}
ref={audioPlayerRef}
preload="metadata"
onEnded={() => {
setIsPlaying(false);
setCurrentTime(0);
if (onEnd) onEnd();
}}
/>
<div className="flex flex-col gap-2 w-full relative">
<div className="absolute w-full flex justify-between -top-5 text-xs px-1">
<span>{formatTimeInMinutes(currentTime)}</span>
<span>{formatTimeInMinutes(duration)}</span>
</div>
<ProgressBar label="" color={color} useColor percentage={(currentTime * 100) / duration} className="h-3 w-full" />
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import {Module} from "@/interfaces";
import clsx from "clsx";
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
interface Props {
module: Module;
children: string;
}
export default function Badge({module, children}: Props) {
return (
<div
key={module}
className={clsx(
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl",
module === "reading" && "bg-ielts-reading",
module === "listening" && "bg-ielts-listening",
module === "writing" && "bg-ielts-writing",
module === "speaking" && "bg-ielts-speaking",
module === "level" && "bg-ielts-level",
)}>
{module === "reading" && <BsBook className="w-4 h-4" />}
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
{module === "writing" && <BsPen className="w-4 h-4" />}
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
{module === "level" && <BsClipboard className="w-4 h-4" />}
<span className="text-sm">{children}</span>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,64 @@
import { Fragment, useState } from "react";
import { Combobox, Transition } from "@headlessui/react";
import { BsChevronExpand } from "react-icons/bs";
import moment from "moment-timezone";
interface Props {
value?: string;
onChange?: (value: string) => void;
disabled?: boolean;
}
export default function TimezoneSelect({
value,
disabled = false,
onChange,
}: Props) {
const [query, setQuery] = useState("");
const timezones = moment.tz.names();
const filteredTimezones = query === "" ? timezones : timezones.filter((x) => x.toLowerCase().includes(query.toLowerCase()));
return (
<>
<Combobox value={value} onChange={onChange} disabled={disabled}>
<div className="relative mt-1">
<div className="relative w-full cursor-default overflow-hidden ">
<Combobox.Input
className="py-6 w-full px-8 text-sm font-normal placeholder:text-mti-gray-cool bg-white disabled:bg-mti-gray-platinum/40 rounded-full border border-mti-gray-platinum focus:outline-none"
onChange={(e) => setQuery(e.target.value)}
/>
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-8">
<BsChevronExpand />
</Combobox.Button>
</div>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
afterLeave={() => setQuery("")}
>
<Combobox.Options className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-xl bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
{filteredTimezones.map((timezone: string) => (
<Combobox.Option
key={timezone}
value={timezone}
className={({ active }) =>
`relative cursor-default select-none py-2 pl-10 pr-4 ${
active
? "bg-mti-purple-light text-white"
: "text-gray-900"
}`
}
>
{timezone}
</Combobox.Option>
))}
</Combobox.Options>
</Transition>
</div>
</Combobox>
</>
);
}

View File

@@ -0,0 +1,77 @@
import { Invite } from "@/interfaces/invite";
import { User } from "@/interfaces/user";
import axios from "axios";
import { useState } from "react";
import { BsArrowRepeat } from "react-icons/bs";
import { toast } from "react-toastify";
interface Props {
invite: Invite;
users: User[];
reload: () => void;
}
export default function InviteCard({ invite, users, reload }: Props) {
const [isLoading, setIsLoading] = useState(false);
const inviter = users.find((u) => u.id === invite.from);
const name = !inviter
? null
: inviter.type === "corporate"
? inviter.corporateInformation?.companyInformation?.name || inviter.name
: inviter.name;
const decide = (decision: "accept" | "decline") => {
if (!confirm(`Are you sure you want to ${decision} this invite?`)) return;
setIsLoading(true);
axios
.get(`/api/invites/${decision}/${invite.id}`)
.then(() => {
toast.success(
`Successfully ${decision === "accept" ? "accepted" : "declined"} the invite!`,
{ toastId: "success" },
);
reload();
})
.catch((e) => {
toast.success(`Something went wrong, please try again later!`, {
toastId: "error",
});
reload();
})
.finally(() => setIsLoading(false));
};
return (
<div className="border-mti-gray-anti-flash flex min-w-[200px] flex-col gap-6 rounded-xl border p-4 text-black">
<span>Invited by {name}</span>
<div className="flex items-center gap-2">
<button
onClick={() => decide("accept")}
disabled={isLoading}
className="bg-mti-green-ultralight hover:bg-mti-green-light w-24 rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed"
>
{!isLoading && "Accept"}
{isLoading && (
<div className="flex items-center justify-center">
<BsArrowRepeat className="animate-spin text-white" size={25} />
</div>
)}
</button>
<button
onClick={() => decide("decline")}
disabled={isLoading}
className="bg-mti-red-ultralight hover:bg-mti-red-light w-24 rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed"
>
{!isLoading && "Decline"}
{isLoading && (
<div className="flex items-center justify-center">
<BsArrowRepeat className="animate-spin text-white" size={25} />
</div>
)}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,121 @@
import {Module} from "@/interfaces";
import {writingMarking} from "@/utils/score";
import {Menu} from "@headlessui/react";
import {Dispatch, SetStateAction} from "react";
import {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
type Levels = {[key in Module]: number};
interface Props {
levels: Levels;
setLevels: Dispatch<SetStateAction<Levels>>;
}
export default function ModuleLevelSelector({levels, setLevels}: Props) {
return (
<div className="flex flex-col gap-32 w-full">
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-16">
<div className="w-full flex flex-col gap-3.5 relative">
<span className="text-sm text-mti-gray-dim">
<span className="font-bold">Reading</span> level
</span>
<Menu>
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
<BsBook className="text-ielts-reading" size={34} />
<span className="text-mti-gray-cool text-sm">
{levels.reading === -1 ? "Select your reading level" : `Level ${levels.reading}`}
</span>
<BsChevronDown className="text-mti-gray-cool" size={12} />
</Menu.Button>
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
{Object.values(writingMarking).map((x) => (
<Menu.Item key={x}>
<span
onClick={() => setLevels((prev) => ({...prev, reading: x}))}
className="w-full py-4 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
Level {x}
</span>
</Menu.Item>
))}
</Menu.Items>
</Menu>
</div>
<div className="w-full flex flex-col gap-3.5 relative">
<span className="text-sm text-mti-gray-dim">
<span className="font-bold">Listening</span> level
</span>
<Menu>
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
<BsHeadphones className="text-ielts-listening" size={34} />
<span className="text-mti-gray-cool text-sm">
{levels.listening === -1 ? "Select your listening level" : `Level ${levels.listening}`}
</span>
<BsChevronDown className="text-mti-gray-cool" size={12} />
</Menu.Button>
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-50 drop-shadow-lg rounded-2xl">
{Object.values(writingMarking).map((x) => (
<Menu.Item key={x}>
<span
onClick={() => setLevels((prev) => ({...prev, listening: x}))}
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
Level {x}
</span>
</Menu.Item>
))}
</Menu.Items>
</Menu>
</div>
<div className="w-full flex flex-col gap-3.5 relative">
<span className="text-sm text-mti-gray-dim">
<span className="font-bold">Writing</span> level
</span>
<Menu>
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
<BsPen className="text-ielts-writing" size={34} />
<span className="text-mti-gray-cool text-sm">
{levels.writing === -1 ? "Select your writing level" : `Level ${levels.writing}`}
</span>
<BsChevronDown className="text-mti-gray-cool" size={12} />
</Menu.Button>
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
{Object.values(writingMarking).map((x) => (
<Menu.Item key={x}>
<span
onClick={() => setLevels((prev) => ({...prev, writing: x}))}
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
Level {x}
</span>
</Menu.Item>
))}
</Menu.Items>
</Menu>
</div>
<div className="w-full flex flex-col gap-3.5 relative">
<span className="text-sm text-mti-gray-dim">
<span className="font-bold">Speaking</span> level
</span>
<Menu>
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
<BsMegaphone className="text-ielts-speaking" size={34} />
<span className="text-mti-gray-cool text-sm">
{levels.speaking === -1 ? "Select your speaking level" : `Level ${levels.speaking}`}
</span>
<BsChevronDown className="text-mti-gray-cool" size={12} />
</Menu.Button>
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
{Object.values(writingMarking).map((x) => (
<Menu.Item key={x}>
<span
onClick={() => setLevels((prev) => ({...prev, speaking: x}))}
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
Level {x}
</span>
</Menu.Item>
))}
</Menu.Items>
</Menu>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,74 @@
import {Module} from "@/interfaces";
import useExamStore from "@/stores/examStore";
import {moduleLabels} from "@/utils/moduleUtils";
import clsx from "clsx";
import {motion} from "framer-motion";
import {ReactNode, useEffect, useState} from "react";
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
import ProgressBar from "../Low/ProgressBar";
import TimerEndedModal from "../TimerEndedModal";
import Timer from "./Timer";
interface Props {
minTimer: number;
module: Module;
label?: string;
exerciseIndex: number;
totalExercises: number;
disableTimer?: boolean;
partLabel?: string;
showTimer?: boolean;
}
export default function ModuleTitle({
minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false, partLabel, showTimer = true
}: Props) {
const moduleIcon: {[key in Module]: ReactNode} = {
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,
writing: <BsPen className="text-ielts-writing w-6 h-6" />,
speaking: <BsMegaphone className="text-ielts-speaking w-6 h-6" />,
level: <BsClipboard className="text-ielts-level w-6 h-6" />,
};
return (
<>
{showTimer && <Timer minTimer={minTimer} disableTimer={disableTimer} />}
<div className="w-full">
{partLabel && (
<div className="text-3xl space-y-4">
{partLabel.split("\n\n").map((line, index) => {
if (index == 0)
return (
<p key={index} className="font-bold">
{line}
</p>
);
else
return (
<p key={index} className="text-2xl font-semibold">
{line}
</p>
);
})}
</div>
)}
<div className={clsx("flex gap-6 w-full h-fit items-center", partLabel ? "mt-10" : "mt-5")}>
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg">{moduleIcon[module]}</div>
<div className="flex flex-col gap-3 w-full">
<div className="w-full flex justify-between">
<span className="text-base font-semibold">
{moduleLabels[module]} exam {label && `- ${label}`}
</span>
<span className="text-sm font-semibold self-end">
Question {exerciseIndex}/{totalExercises}
</span>
</div>
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,101 @@
import {Session} from "@/hooks/useSessions";
import useExamStore from "@/stores/examStore";
import {sortByModuleName} from "@/utils/moduleUtils";
import axios from "axios";
import clsx from "clsx";
import {capitalize} from "lodash";
import moment from "moment";
import {useState} from "react";
import {BsArrowRepeat, BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
import {toast} from "react-toastify";
export default function SessionCard({
session,
reload,
loadSession,
}: {
session: Session;
reload: () => void;
loadSession: (session: Session) => Promise<void>;
}) {
const [isLoading, setIsLoading] = useState(false);
const deleteSession = async () => {
if (!confirm("Are you sure you want to delete this session?")) return;
setIsLoading(true);
await axios
.delete(`/api/sessions/${session.sessionId}`)
.then(() => {
toast.success(`Successfully delete session "${session.sessionId}"`);
})
.catch((e) => {
console.log(e);
toast.error("Something went wrong, please try again later");
})
.finally(() => {
reload();
setIsLoading(false);
});
};
return (
<div className="border-mti-gray-anti-flash flex w-64 flex-col gap-3 rounded-xl border p-4 text-black">
<span className="flex gap-1">
<b>ID:</b>
{session.sessionId}
</span>
<span className="flex gap-1">
<b>Date:</b>
{moment(session.date).format("DD/MM/YYYY - HH:mm")}
</span>
<div className="flex w-full items-center justify-between">
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-center justify-center gap-2">
{session.selectedModules.sort(sortByModuleName).map((module) => (
<div
key={module}
data-tip={capitalize(module)}
className={clsx(
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
module === "reading" && "bg-ielts-reading",
module === "listening" && "bg-ielts-listening",
module === "writing" && "bg-ielts-writing",
module === "speaking" && "bg-ielts-speaking",
module === "level" && "bg-ielts-level",
)}>
{module === "reading" && <BsBook className="h-4 w-4" />}
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
{module === "writing" && <BsPen className="h-4 w-4" />}
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
{module === "level" && <BsClipboard className="h-4 w-4" />}
</div>
))}
</div>
</div>
<div className="flex items-center gap-2 w-full">
<button
onClick={async () => await loadSession(session)}
disabled={isLoading}
className="bg-mti-green-ultralight w-full hover:bg-mti-green-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
{!isLoading && "Resume"}
{isLoading && (
<div className="flex items-center justify-center">
<BsArrowRepeat className="animate-spin text-white" size={25} />
</div>
)}
</button>
<button
onClick={deleteSession}
disabled={isLoading}
className="bg-mti-red-ultralight w-full hover:bg-mti-red-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
{!isLoading && "Delete"}
{isLoading && (
<div className="flex items-center justify-center">
<BsArrowRepeat className="animate-spin text-white" size={25} />
</div>
)}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,80 @@
import useExamStore from "@/stores/examStore";
import { useEffect, useState } from "react";
import { motion } from "framer-motion";
import TimerEndedModal from "../TimerEndedModal";
import clsx from "clsx";
import { BsStopwatch } from "react-icons/bs";
interface Props {
minTimer: number;
disableTimer?: boolean;
standalone?: boolean;
}
const Timer: React.FC<Props> = ({minTimer, disableTimer, standalone = false}) => {
const [timer, setTimer] = useState(minTimer * 60);
const [showModal, setShowModal] = useState(false);
const [warningMode, setWarningMode] = useState(false);
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
const { timeSpent } = useExamStore((state) => state);
useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]);
useEffect(() => {
if (!disableTimer) {
const timerInterval = setInterval(() => setTimer((prev) => prev - 1), 1000);
return () => {
clearInterval(timerInterval);
};
}
}, [disableTimer, minTimer]);
useEffect(() => {
if (timer <= 0) setShowModal(true);
}, [timer]);
useEffect(() => {
if (timer < 300 && !warningMode) setWarningMode(true);
}, [timer, warningMode]);
return (
<>
<TimerEndedModal
isOpen={showModal}
onClose={() => {
setHasExamEnded(true);
setShowModal(false);
}}
/>
<motion.div
className={clsx(
"absolute right-6 bg-mti-gray-seasalt px-4 py-3 flex items-center gap-2 rounded-full text-mti-gray-davy",
standalone ? "top-6" : "top-4",
warningMode && !disableTimer && "bg-mti-red-light text-mti-gray-seasalt",
)}
initial={{ scale: warningMode && !disableTimer ? 0.8 : 1 }}
animate={{ scale: warningMode && !disableTimer ? 1.1 : 1 }}
transition={{ repeat: Infinity, repeatType: "reverse", duration: 0.5, ease: "easeInOut" }}>
<BsStopwatch className="w-6 h-6" />
<span className="text-base font-semibold w-12">
{timer > 0 && (
<>
{Math.floor(timer / 60)
.toString(10)
.padStart(2, "0")}
:
{Math.floor(timer % 60)
.toString(10)
.padStart(2, "0")}
</>
)}
{timer <= 0 && <>00:00</>}
</span>
</motion.div>
</>
);
}
export default Timer;

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

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

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

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

View File

@@ -0,0 +1,24 @@
import clsx from "clsx";
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
const ModuleBadge: React.FC<{ module: string; level?: number }> = ({ module, level }) => (
<div
className={clsx(
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl",
module === "reading" && "bg-ielts-reading",
module === "listening" && "bg-ielts-listening",
module === "writing" && "bg-ielts-writing",
module === "speaking" && "bg-ielts-speaking",
module === "level" && "bg-ielts-level",
)}>
{module === "reading" && <BsBook className="w-4 h-4" />}
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
{module === "writing" && <BsPen className="w-4 h-4" />}
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
{module === "level" && <BsClipboard className="w-4 h-4" />}
{/* do not switch to level && it will convert the 0.0 to 0*/}
{level !== undefined && (<span className="text-sm">{level.toFixed(1)}</span>)}
</div>
);
export default ModuleBadge;

View File

@@ -1,77 +1,219 @@
import {Type} from "@/interfaces/user";
import axios from "axios";
import { User } from "@/interfaces/user";
import Link from "next/link";
import {useRouter} from "next/router";
import {Button} from "primereact/button";
import {Menubar} from "primereact/menubar";
import {MenuItem} from "primereact/menuitem";
import FocusLayer from "@/components/FocusLayer";
import { preventNavigation } from "@/utils/navigation.disabled";
import { useRouter } from "next/router";
import { BsList, BsQuestionCircle, BsQuestionCircleFill } from "react-icons/bs";
import clsx from "clsx";
import moment from "moment";
import MobileMenu from "./MobileMenu";
import { useEffect, useState } from "react";
import { Type } from "@/interfaces/user";
import { USER_TYPE_LABELS } from "@/resources/user";
import useGroups from "@/hooks/useGroups";
import { isUserFromCorporate } from "@/utils/groups";
import Button from "./Low/Button";
import Modal from "./Modal";
import Input from "./Low/Input";
import TicketSubmission from "./High/TicketSubmission";
import { Module } from "@/interfaces";
import Badge from "./Low/Badge";
import {
BsArrowRepeat,
BsBook,
BsCheck,
BsCheckCircle,
BsClipboard,
BsHeadphones,
BsMegaphone,
BsPen,
BsXCircle,
} from "react-icons/bs";
interface Props {
profilePicture: string;
userType: Type;
timer?: number;
showExamEnd?: boolean;
user: User;
navDisabled?: boolean;
focusMode?: boolean;
onFocusLayerMouseEnter?: () => void;
path: string;
}
/* eslint-disable @next/next/no-img-element */
export default function Navbar({profilePicture, userType, timer, showExamEnd = false}: Props) {
const router = useRouter();
export default function Navbar({
user,
path,
navDisabled = false,
focusMode = false,
onFocusLayerMouseEnter,
}: Props) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
const [isTicketOpen, setIsTicketOpen] = useState(false);
const logout = async () => {
axios.post("/api/logout").finally(() => {
router.push("/login");
});
};
const router = useRouter();
const items: MenuItem[] = [
{
label: "Home",
icon: "pi pi-fw pi-home",
url: "/",
},
{
label: "Account",
icon: "pi pi-fw pi-user",
url: "/profile",
},
{
label: "Exam",
icon: "pi pi-fw pi-plus-circle",
url: "/exam",
},
{
label: "Users",
icon: "pi pi-fw pi-users",
items: [
...(userType === "student" ? [] : [{label: "List", icon: "pi pi-fw pi-users", url: "/users"}]),
{label: "Stats", icon: "pi pi-fw pi-chart-pie", url: "/stats"},
{label: "History", icon: "pi pi-fw pi-history", url: "/history"},
],
},
{
label: "Logout",
icon: "pi pi-fw pi-power-off",
command: logout,
},
];
const disableNavigation = preventNavigation(navDisabled, focusMode);
const endTimer = timer && (
<span className="pr-2 font-semibold">
{Math.floor(timer / 60) < 10 ? "0" : ""}
{Math.floor(timer / 60)}:{timer % 60 < 10 ? "0" : ""}
{timer % 60}
</span>
);
const expirationDateColor = (date: Date) => {
const momentDate = moment(date);
const today = moment(new Date());
const endNewExam = (
<Link href="/exam" className="pr-2">
<Button text label="Exam" severity="secondary" size="small" />
</Link>
);
if (today.add(1, "days").isAfter(momentDate))
return "!bg-mti-red-ultralight border-mti-red-light";
if (today.add(3, "days").isAfter(momentDate))
return "!bg-mti-rose-ultralight border-mti-rose-light";
if (today.add(7, "days").isAfter(momentDate))
return "!bg-mti-orange-ultralight border-mti-orange-light";
};
return (
<div className="bg-neutral-100 z-10 w-full p-2">
<Menubar model={items} end={showExamEnd ? endNewExam : endTimer} />
</div>
);
const showExpirationDate = () => {
if (!user.subscriptionExpirationDate) return false;
const momentDate = moment(user.subscriptionExpirationDate);
const today = moment(new Date());
return today.add(7, "days").isAfter(momentDate);
};
useEffect(() => {
if (user.type !== "student" && user.type !== "teacher")
return setDisablePaymentPage(false);
isUserFromCorporate(user.id).then((result) =>
setDisablePaymentPage(result)
);
}, [user]);
const badges = [
{
module: "reading",
icon: () => <BsBook className="h-4 w-4 text-white" />,
achieved: user.levels.reading >= user.desiredLevels.reading,
},
{
module: "listening",
icon: () => <BsHeadphones className="h-4 w-4 text-white" />,
achieved: user.levels.listening >= user.desiredLevels.listening,
},
{
module: "writing",
icon: () => <BsPen className="h-4 w-4 text-white" />,
achieved: user.levels.writing >= user.desiredLevels.writing,
},
{
module: "speaking",
icon: () => <BsMegaphone className="h-4 w-4 text-white" />,
achieved: user.levels.speaking >= user.desiredLevels.speaking,
},
{
module: "level",
icon: () => <BsClipboard className="h-4 w-4 text-white" />,
achieved: user.levels.level >= user.desiredLevels.level,
},
];
return (
<>
<Modal
isOpen={isTicketOpen}
onClose={() => setIsTicketOpen(false)}
title="Submit a ticket"
>
<TicketSubmission
user={user}
page={router.asPath}
onClose={() => setIsTicketOpen(false)}
/>
</Modal>
{user && (
<MobileMenu
disableNavigation={disableNavigation}
path={path}
isOpen={isMenuOpen}
onClose={() => setIsMenuOpen(false)}
user={user}
/>
)}
<header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4">
<Link
href={disableNavigation ? "" : "/"}
className=" flex items-center gap-8 md:px-8"
>
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" />
<h1 className="-md:hidden w-1/6 text-2xl font-bold">EnCoach</h1>
</Link>
<div className="flex items-center justify-end gap-4 md:mr-8 md:w-5/6">
{user.type === "student" &&
badges.map((badge) => (
<div
key={badge.module}
className={`${badge.achieved ? `bg-ielts-${badge.module}`: 'bg-mti-gray-anti-flash'} flex h-8 w-8 items-center justify-center rounded-full`}
>
{badge.icon()}
</div>
))}
{/* OPEN TICKET SYSTEM */}
<button
className={clsx(
"border-mti-purple-light tooltip tooltip-bottom flex h-8 w-8 flex-col items-center justify-center rounded-full border p-1",
"hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white z-20"
)}
data-tip="Submit a help/feedback ticket"
onClick={() => setIsTicketOpen(true)}
>
<BsQuestionCircleFill />
</button>
{showExpirationDate() && (
<Link
href={
!!user.subscriptionExpirationDate && !disablePaymentPage
? "/payment"
: ""
}
data-tip="Expiry date"
className={clsx(
"flex w-fit cursor-pointer justify-center rounded-full border px-6 py-2 text-sm font-normal focus:outline-none",
"tooltip tooltip-bottom transition duration-300 ease-in-out",
!user.subscriptionExpirationDate
? "bg-mti-green-ultralight border-mti-green-light"
: expirationDateColor(user.subscriptionExpirationDate),
"border-mti-gray-platinum bg-white"
)}
>
{!user.subscriptionExpirationDate && "Unlimited"}
{user.subscriptionExpirationDate &&
moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
</Link>
)}
<Link
href={disableNavigation ? "" : "/profile"}
className="-md:hidden flex items-center justify-end gap-6"
>
<img
src={user.profilePicture}
alt={user.name}
className="h-10 w-10 rounded-full object-cover"
/>
<span className="-md:hidden text-right">
{user.type === "corporate"
? `${user.corporateInformation?.companyInformation.name} |`
: ""}{" "}
{user.name} | {USER_TYPE_LABELS[user.type]}
</span>
</Link>
<div
className="cursor-pointer md:hidden"
onClick={() => setIsMenuOpen(true)}
>
<BsList className="text-mti-purple-light h-8 w-8" />
</div>
</div>
{focusMode && (
<FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
)}
</header>
</>
);
}

View File

@@ -0,0 +1,145 @@
import React, {ChangeEvent} from "react";
import {BsUpload, BsDownload, BsTrash, BsArrowRepeat, BsXCircleFill} from "react-icons/bs";
import {FilesStorage} from "@/interfaces/storage.files";
import axios from "axios";
interface Asset {
file: string | File;
complete: boolean;
}
const PaymentAssetManager = (props: {
asset: string | undefined;
permissions: "read" | "write";
type: FilesStorage;
reload: () => void;
paymentId: string;
}) => {
const {asset, permissions, type, paymentId} = props;
const fileInputRef = React.useRef<HTMLInputElement>(null);
const fileInputReplaceRef = React.useRef<HTMLInputElement>(null);
const [managingAsset, setManagingAsset] = React.useState<Asset>({
file: asset || "",
complete: asset ? true : false,
});
const {file, complete} = managingAsset;
const deleteAsset = () => {
if (confirm("Are you sure you want to delete this document?")) {
axios
.delete(`/api/payments/files/${type}/${paymentId}`)
.then((response) => {
if (response.status === 200) {
console.log("File deleted successfully!");
setManagingAsset({
file: "",
complete: false,
});
return;
}
console.error("File deletion failed");
})
.catch((error) => {
console.error("Error occurred during file deletion:", error);
})
.finally(props.reload);
}
};
const renderFileInput = (onChange: any, ref: React.RefObject<HTMLInputElement>) => (
<input type="file" ref={ref} style={{display: "none"}} onChange={onChange} multiple={false} accept="application/pdf" />
);
const handleFileChange = async (e: Event, method: "post" | "patch") => {
const newFile = (e.target as HTMLInputElement).files?.[0];
if (newFile) {
setManagingAsset({
file: newFile,
complete: false,
});
const formData = new FormData();
formData.append("file", newFile);
axios[method](`/api/payments/files/${type}/${paymentId}`, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
})
.then((response) => {
if (response.status === 200) {
console.log("File uploaded successfully!");
console.log("Uploaded File URL:", response.data.ref);
// Further actions upon successful upload
setManagingAsset({
file: response.data.ref,
complete: true,
});
return;
}
console.error("File upload failed");
})
.catch((error) => {
console.error("Error occurred during file upload:", error);
})
.finally(props.reload);
}
};
const downloadAsset = () => {
axios
.get(`/api/payments/files/${type}/${paymentId}`)
.then((response) => {
if (response.status === 200) {
console.log("Uploaded File URL:", response.data.url);
const link = document.createElement("a");
link.download = response.data.filename;
link.href = response.data.url;
link.click();
return;
}
console.error("Failed to download file");
})
.catch((error) => {
console.error("Error occurred during file upload:", error);
});
};
if (permissions === "read") {
if (file) return <BsDownload onClick={downloadAsset} />;
return null;
}
if (file) {
if (complete) {
return (
<>
<BsDownload onClick={downloadAsset} />
<BsArrowRepeat onClick={() => fileInputReplaceRef.current?.click()} />
<BsTrash onClick={deleteAsset} />
{renderFileInput((e: Event) => handleFileChange(e, "patch"), fileInputReplaceRef)}
{renderFileInput((e: Event) => handleFileChange(e, "post"), fileInputRef)}
</>
);
}
return <span className="loading loading-infinity w-8" />;
}
return permissions === "write" ? (
<>
<BsUpload onClick={() => fileInputRef.current?.click()} />
{renderFileInput((e: Event) => handleFileChange(e, "post"), fileInputRef)}
</>
) : (
<BsXCircleFill />
);
};
export default PaymentAssetManager;

View File

@@ -0,0 +1,77 @@
import {PaymentIntention} from "@/interfaces/paymob";
import {DurationUnit} from "@/interfaces/paypal";
import {User} from "@/interfaces/user";
import axios from "axios";
import {useRouter} from "next/router";
import {useState} from "react";
import Button from "./Low/Button";
import Input from "./Low/Input";
import Modal from "./Modal";
interface Props {
user: User;
currency: string;
price: number;
setIsPaymentLoading: (v: boolean) => void;
duration: number;
duration_unit: DurationUnit;
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
}
export default function PaymobPayment({user, price, setIsPaymentLoading, currency, duration, duration_unit, onSuccess}: Props) {
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const handleCardPayment = async () => {
try {
setIsPaymentLoading(true);
const paymentIntention: PaymentIntention = {
amount: price * 1000,
currency: "OMR",
items: [],
payment_methods: [],
customer: {
email: user.email,
first_name: user.name.split(" ")[0],
last_name: [...user.name.split(" ")].pop() || "N/A",
extras: {
re: user.id,
},
},
billing_data: {
apartment: "N/A",
building: "N/A",
country: user.demographicInformation?.country || "N/A",
email: user.email,
first_name: user.name.split(" ")[0],
last_name: [...user.name.split(" ")].pop() || "N/A",
floor: "N/A",
phone_number: user.demographicInformation?.phone || "N/A",
state: "N/A",
street: "N/A",
},
extras: {
userID: user.id,
duration,
duration_unit,
},
};
const response = await axios.post<{iframeURL: string}>(`/api/paymob`, paymentIntention);
router.push(response.data.iframeURL);
} catch (error) {
console.error("Error starting card payment process:", error);
}
};
return (
<>
<Button isLoading={isLoading} onClick={handleCardPayment}>
Select
</Button>
</>
);
}

View File

@@ -0,0 +1,82 @@
import React from "react";
import {Permission} from "@/interfaces/permissions";
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable, Row} from "@tanstack/react-table";
import Link from "next/link";
import {convertCamelCaseToReadable} from "@/utils/string";
interface Props {
permissions: Permission[];
}
const columnHelper = createColumnHelper<Permission>();
const defaultColumns = [
columnHelper.accessor("type", {
header: () => <span>Type</span>,
cell: ({row, getValue}) => (
<Link
href={`/permissions/${row.original.id}`}
key={row.id}
className="underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer">
{convertCamelCaseToReadable(getValue() as string)}
</Link>
),
}),
];
export default function PermissionList({permissions}: Props) {
const table = useReactTable({
data: permissions,
columns: defaultColumns,
getCoreRowModel: getCoreRowModel(),
});
const groupedData: {[key: string]: Row<Permission>[]} = table.getRowModel().rows.reduce((groups: {[key: string]: Row<Permission>[]}, row) => {
const parent = row.original.topic;
if (!groups[parent]) {
groups[parent] = [];
}
groups[parent].push(row);
return groups;
}, {});
return (
<div className="w-full h-full">
<div className="w-full flex flex-col gap-2">
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="py-4 px-4 text-left" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{Object.keys(groupedData).map((parent) => (
<React.Fragment key={parent}>
<tr>
<td className="px-2 py-2 items-center w-fit">
<strong>{parent}</strong>
</td>
</tr>
{groupedData[parent].map((row, i) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2 items-center w-fit" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</React.Fragment>
))}
</tbody>
</table>
</div>
</div>
);
}

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