Compare commits

...

107 Commits

Author SHA1 Message Date
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
b7ae9fb837 Merge branch 'develop' 2024-01-17 14:29:33 +00:00
Tiago Ribeiro
9baf3109c9 Merge branch 'develop' 2024-01-14 07:44:23 +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
119 changed files with 7136 additions and 3125 deletions

BIN
public/audio/error.mp3 Normal file

Binary file not shown.

View File

@@ -1,5 +1,5 @@
import {EmploymentStatus, EMPLOYMENT_STATUS, Gender, User} from "@/interfaces/user";
import {FormEvent, useState} from "react";
import {FormEvent, useEffect, useState} from "react";
import countryCodes from "country-codes-list";
import {RadioGroup} from "@headlessui/react";
import Input from "./Low/Input";
@@ -12,6 +12,8 @@ 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;
@@ -25,6 +27,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
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>();
@@ -43,6 +46,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
employment: user.type === "corporate" ? undefined : employment,
position: user.type === "corporate" ? position : undefined,
passport_id,
timezone,
},
agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined,
})
@@ -94,6 +98,12 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
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 />

View File

@@ -14,6 +14,7 @@ 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;
@@ -36,7 +37,7 @@ export default function Diagnostic({onFinish}: Props) {
};
const selectExam = () => {
const examPromises = MODULE_ARRAY.map((module) => getExam(module, true));
const examPromises = MODULE_ARRAY.map((module) => getExam(module, true, "partial"));
Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) {
@@ -90,140 +91,44 @@ export default function Diagnostic({onFinish}: Props) {
</div>
</div>
</div>
<div className="flex flex-col items-center justify-center gap-8 w-full">
<h2 className="font-semibold text-xl">What is your current IELTS level?</h2>
<div className="flex flex-col gap-32 w-full mb-20">
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-16 mb-24">
<div className="w-full flex flex-col gap-3.5 relative">
<span className="text-sm text-mti-gray-dim">
<span className="font-bold">Reading</span> level
</span>
<Menu>
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
<BsBook className="text-ielts-reading" size={34} />
<span className="text-mti-gray-cool text-sm">
{levels.reading === -1 ? "Select your reading level" : `Level ${levels.reading}`}
</span>
<BsChevronDown className="text-mti-gray-cool" size={12} />
</Menu.Button>
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
{Object.values(writingMarking).map((x) => (
<Menu.Item key={x}>
<span
onClick={() => setLevels((prev) => ({...prev, reading: x}))}
className="w-full py-4 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
Level {x}
</span>
</Menu.Item>
))}
</Menu.Items>
</Menu>
</div>
<div className="w-full flex flex-col gap-3.5 relative">
<span className="text-sm text-mti-gray-dim">
<span className="font-bold">Listening</span> level
</span>
<Menu>
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
<BsHeadphones className="text-ielts-listening" size={34} />
<span className="text-mti-gray-cool text-sm">
{levels.listening === -1 ? "Select your listening level" : `Level ${levels.listening}`}
</span>
<BsChevronDown className="text-mti-gray-cool" size={12} />
</Menu.Button>
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
{Object.values(writingMarking).map((x) => (
<Menu.Item key={x}>
<span
onClick={() => setLevels((prev) => ({...prev, listening: x}))}
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
Level {x}
</span>
</Menu.Item>
))}
</Menu.Items>
</Menu>
</div>
<div className="w-full flex flex-col gap-3.5 relative">
<span className="text-sm text-mti-gray-dim">
<span className="font-bold">Writing</span> level
</span>
<Menu>
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
<BsPen className="text-ielts-writing" size={34} />
<span className="text-mti-gray-cool text-sm">
{levels.writing === -1 ? "Select your writing level" : `Level ${levels.writing}`}
</span>
<BsChevronDown className="text-mti-gray-cool" size={12} />
</Menu.Button>
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
{Object.values(writingMarking).map((x) => (
<Menu.Item key={x}>
<span
onClick={() => setLevels((prev) => ({...prev, writing: x}))}
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
Level {x}
</span>
</Menu.Item>
))}
</Menu.Items>
</Menu>
</div>
<div className="w-full flex flex-col gap-3.5 relative">
<span className="text-sm text-mti-gray-dim">
<span className="font-bold">Speaking</span> level
</span>
<Menu>
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
<BsMegaphone className="text-ielts-speaking" size={34} />
<span className="text-mti-gray-cool text-sm">
{levels.speaking === -1 ? "Select your speaking level" : `Level ${levels.speaking}`}
</span>
<BsChevronDown className="text-mti-gray-cool" size={12} />
</Menu.Button>
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
{Object.values(writingMarking).map((x) => (
<Menu.Item key={x}>
<span
onClick={() => setLevels((prev) => ({...prev, speaking: x}))}
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
Level {x}
</span>
</Menu.Item>
))}
</Menu.Items>
</Menu>
</div>
</div>
<div className="md:self-end flex -md:flex-col justify-between w-full gap-8 absolute bottom-8 left-0 px-4 md:px-8">
<div className="w-full tooltip" data-tip="Your screen size is too small to perform a diagnostic test">
<Button
color="purple"
variant="outline"
className="group flex items-center justify-center gap-6 relative md:max-w-[400px] w-full md:hidden"
disabled>
<BsQuestionSquare className="text-mti-purple-light transition duration-300 ease-in-out" size={20} />
<span>Perform diagnostic test instead</span>
</Button>
</div>
<Button
onClick={() => updateUser(selectExam)}
color="purple"
variant="outline"
className="group flex items-center justify-center gap-6 relative md:max-w-[400px] w-full -md:hidden"
disabled={!focus}>
<BsQuestionSquare
className="text-mti-purple-light group-hover:text-white transition duration-300 ease-in-out"
size={20}
onClick={() => updateUser(selectExam)}
/>
<span onClick={() => updateUser(selectExam)}>Perform diagnostic test instead</span>
</Button>
<Button color="purple" className="md:max-w-[400px] w-full" onClick={() => updateUser(onFinish)} disabled={isNextDisabled()}>
Next Step
</Button>
</div>
<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

@@ -5,6 +5,8 @@ import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill}
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), {
@@ -14,9 +16,11 @@ const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mo
export default function InteractiveSpeaking({
id,
title,
examID,
text,
type,
prompts,
userSolutions,
updateIndex,
onNext,
onBack,
@@ -24,20 +28,109 @@ export default function InteractiveSpeaking({
const [recordingDuration, setRecordingDuration] = useState(0);
const [isRecording, setIsRecording] = useState(false);
const [mediaBlob, setMediaBlob] = useState<string>();
const [promptIndex, setPromptIndex] = useState(0);
const [answers, setAnswers] = useState<{prompt: string; blob: string}[]>([]);
const [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 saveToStorage = async (previousURL?: string) => {
if (mediaBlob && mediaBlob.startsWith("blob")) {
const blobBuffer = await downloadBlob(mediaBlob);
const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"});
const seed = Math.random().toString().replace("0.", "");
const formData = new FormData();
formData.append("audio", audioFile, `${seed}.wav`);
formData.append("root", "speaking_recordings");
const config = {
headers: {
"Content-Type": "audio/wav",
},
};
const response = await axios.post<{path: string}>("/api/storage/insert", formData, config);
if (previousURL && !previousURL.startsWith("blob")) await axios.post("/api/storage/delete", {path: previousURL});
return response.data.path;
}
return undefined;
};
const back = async () => {
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: 1, total: 1, 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);
onNext({
exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 1, total: 1, missing: 0},
type,
});
};
useEffect(() => {
if (updateIndex) updateIndex(promptIndex);
}, [promptIndex, updateIndex]);
if (userSolutions.length > 0 && answers.length === 0) {
console.log(userSolutions);
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(() => {
console.log({answers});
}, [answers]);
useEffect(() => {
if (updateIndex) updateIndex(questionIndex);
}, [questionIndex, updateIndex]);
useEffect(() => {
if (hasExamEnded) {
const answer = {
questionIndex,
prompt: prompts[questionIndex].text,
blob: mediaBlob!,
};
onNext({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 1, total: 1, missing: 0},
type,
});
@@ -59,19 +152,38 @@ export default function InteractiveSpeaking({
}, [isRecording]);
useEffect(() => {
if (promptIndex === answers.length - 1) {
setMediaBlob(answers[promptIndex].blob);
if (questionIndex <= answers.length - 1) {
const blob = answers.find((x) => x.questionIndex === questionIndex)?.blob;
setMediaBlob(blob);
}
}, [answers, promptIndex]);
}, [answers, questionIndex]);
const saveAnswer = async (index: number) => {
const previousURL = answers.find((x) => x.questionIndex === questionIndex)?.blob;
const audioPath = await saveToStorage(previousURL);
const saveAnswer = () => {
const answer = {
prompt: prompts[promptIndex].text,
blob: mediaBlob!,
questionIndex,
prompt: prompts[questionIndex].text,
blob: audioPath ? audioPath : mediaBlob!,
};
setAnswers((prev) => [...prev, answer]);
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: 1, total: 1, missing: 0},
module: "speaking",
exam: examID,
type,
},
]);
return answer;
};
return (
@@ -82,8 +194,8 @@ export default function InteractiveSpeaking({
</div>
{prompts && prompts.length > 0 && (
<div className="flex flex-col gap-4 w-full items-center">
<video key={promptIndex} autoPlay controls className="max-w-3xl rounded-xl">
<source src={prompts[promptIndex].video_url} />
<video key={questionIndex} autoPlay controls className="max-w-3xl rounded-xl">
<source src={prompts[questionIndex].video_url} />
</video>
</div>
)}
@@ -91,13 +203,13 @@ export default function InteractiveSpeaking({
<ReactMediaRecorder
audio
key={promptIndex}
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" && (
{status === "idle" && !mediaBlob && (
<>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
{status === "idle" && (
@@ -176,9 +288,9 @@ export default function InteractiveSpeaking({
</div>
</>
)}
{status === "stopped" && mediaBlobUrl && (
{((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && (
<>
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
<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"
@@ -208,44 +320,11 @@ export default function InteractiveSpeaking({
/>
<div className="self-end flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() =>
onBack({
exercise: id,
solutions: answers,
score: {correct: 1, total: 1, missing: 0},
type,
})
}
className="max-w-[200px] self-end w-full">
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
Back
</Button>
<Button
color="purple"
disabled={!mediaBlob}
onClick={() => {
saveAnswer();
if (promptIndex + 1 < prompts.length) {
setPromptIndex((prev) => prev + 1);
return;
}
onNext({
exercise: id,
solutions: [
...answers,
{
prompt: prompts[promptIndex].text,
blob: mediaBlob!,
},
],
score: {correct: 1, total: 1, missing: 0},
type,
});
}}
className="max-w-[200px] self-end w-full">
{promptIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
<Button 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

@@ -59,10 +59,18 @@ export default function MultipleChoice({
onBack,
}: MultipleChoiceExercise & CommonProps) {
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
const [questionIndex, setQuestionIndex] = useState(0);
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
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
@@ -91,16 +99,20 @@ export default function MultipleChoice({
if (questionIndex === questions.length - 1) {
onNext({exercise: id, solutions: answers, score: calculateScore(), type});
} else {
setQuestionIndex((prev) => prev + 1);
setQuestionIndex(questionIndex + 1);
}
scrollToTop();
};
const back = () => {
if (questionIndex === 0) {
onBack({exercise: id, solutions: answers, score: calculateScore(), type});
} else {
setQuestionIndex((prev) => prev - 1);
setQuestionIndex(questionIndex - 1);
}
scrollToTop();
};
return (

View File

@@ -5,28 +5,58 @@ import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill}
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 Speaking({id, title, text, video_url, type, prompts, onNext, onBack}: SpeakingExercise & CommonProps) {
export default function Speaking({id, title, text, video_url, type, prompts, 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 hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
if (hasExamEnded) {
onNext({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
});
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]);
@@ -43,6 +73,32 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
};
}, [isRecording]);
const next = async () => {
setIsLoading(true);
const storagePath = await saveToStorage();
setIsLoading(false);
onNext({
exercise: id,
solutions: storagePath ? [{id, solution: storagePath}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
});
};
const back = async () => {
setIsLoading(true);
const storagePath = await saveToStorage();
setIsLoading(false);
onBack({
exercise: id,
solutions: storagePath ? [{id, solution: storagePath}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
});
};
return (
<div className="flex flex-col h-full w-full gap-9">
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
@@ -89,7 +145,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
<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" && (
{status === "idle" && !mediaBlob && (
<>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
{status === "idle" && (
@@ -168,9 +224,9 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
</div>
</>
)}
{status === "stopped" && mediaBlobUrl && (
{((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && (
<>
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
<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"
@@ -200,32 +256,10 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
/>
<div className="self-end flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() =>
onBack({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
})
}
className="max-w-[200px] self-end w-full">
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
Back
</Button>
<Button
color="purple"
disabled={!mediaBlob}
onClick={() =>
onNext({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
})
}
className="max-w-[200px] self-end w-full">
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
Next
</Button>
</div>

View File

@@ -22,9 +22,31 @@ export default function Writing({
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: 1, total: 1, missing: 0}, type},
]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [saveTimer]);
useEffect(() => {
if (localStorage.getItem("enable_paste")) return;
@@ -132,7 +154,14 @@ export default function Writing({
<Button
color="purple"
disabled={!isSubmitEnabled}
onClick={() => onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type})}
onClick={() =>
onNext({
exercise: id,
solutions: [{id, solution: inputText.replaceAll(/\s{2,}/g, " ")}],
score: {correct: 1, total: 1, missing: 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Next
</Button>

View File

@@ -22,6 +22,7 @@ import InteractiveSpeaking from "./InteractiveSpeaking";
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false});
export interface CommonProps {
examID?: string;
updateIndex?: (internalIndex: number) => void;
onNext: (userSolutions: UserSolution) => void;
onBack: (userSolutions: UserSolution) => void;
@@ -29,17 +30,18 @@ export interface CommonProps {
export const renderExercise = (
exercise: Exercise,
examID: string,
onNext: (userSolutions: UserSolution) => void,
onBack: (userSolutions: UserSolution) => void,
updateIndex?: (internalIndex: number) => void,
) => {
switch (exercise.type) {
case "fillBlanks":
return <FillBlanks key={exercise.id} {...(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} />;
return <TrueFalse key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
case "matchSentences":
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
case "multipleChoice":
return (
<MultipleChoice
@@ -48,19 +50,21 @@ export const renderExercise = (
updateIndex={updateIndex}
onNext={onNext}
onBack={onBack}
examID={examID}
/>
);
case "writeBlanks":
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
case "writing":
return <Writing key={exercise.id} {...(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 key={exercise.id} {...(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}
updateIndex={updateIndex}
onNext={onNext}
onBack={onBack}

View File

@@ -0,0 +1,254 @@
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";
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) => ["admin", "developer", "agent"].includes(x.type))
.map((u) => ({
value: u.id,
label: `${u.name} - ${u.email}`,
})),
]}
disabled={user.type === "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,101 @@
import {Ticket, TicketType, TicketTypeLabel} from "@/interfaces/ticket";
import {User} from "@/interfaces/user";
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 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,
};
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

@@ -4,7 +4,7 @@ import {BsArrowRepeat} from "react-icons/bs";
interface Props {
children: ReactNode;
color?: "rose" | "purple" | "red" | "green" | "gray";
color?: "rose" | "purple" | "red" | "green" | "gray" | "pink";
variant?: "outline" | "solid";
className?: string;
disabled?: boolean;
@@ -49,6 +49,11 @@ export default function Button({
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 (

View File

@@ -42,7 +42,9 @@ export default function CountrySelect({value, disabled = false, onChange}: Props
displayValue={(code: string) => {
const country = countries[code as unknown as keyof TCountries];
return `${countryCodes.findOne("countryCode" as any, code).flag} ${country.name} (+${country.phone})`;
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">

View File

@@ -5,12 +5,14 @@ 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, useColor = false, className, textClassName}: Props) {
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",
@@ -30,6 +32,9 @@ export default function ProgressBar({label, percentage, color, useColor = false,
!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])}

View File

@@ -0,0 +1,61 @@
import clsx from "clsx";
import {ComponentProps, useEffect, useState} from "react";
import ReactSelect from "react-select";
interface Option {
[key: string]: any;
value: string;
label: string;
}
interface Props {
defaultValue?: Option;
value?: Option | null;
options: Option[];
disabled?: boolean;
placeholder?: string;
onChange: (value: Option | null) => void;
isClearable?: boolean;
}
export default function Select({value, defaultValue, options, placeholder, disabled, onChange, isClearable}: Props) {
const [target, setTarget] = useState<HTMLElement>();
useEffect(() => {
if (document) setTarget(document.body);
}, []);
return (
<ReactSelect
className={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",
)}
options={options}
value={value}
onChange={onChange}
placeholder={placeholder}
menuPortalTarget={target}
defaultValue={defaultValue}
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,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,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

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

View File

@@ -11,7 +11,7 @@ interface Props {
export default function Modal({isOpen, title, onClose, children}: Props) {
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={onClose}>
<Dialog as="div" className="relative z-[200]" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"

View File

@@ -3,7 +3,7 @@ import Link from "next/link";
import FocusLayer from "@/components/FocusLayer";
import {preventNavigation} from "@/utils/navigation.disabled";
import {useRouter} from "next/router";
import {BsList} from "react-icons/bs";
import {BsList, BsQuestionCircle, BsQuestionCircleFill} from "react-icons/bs";
import clsx from "clsx";
import moment from "moment";
import MobileMenu from "./MobileMenu";
@@ -12,6 +12,10 @@ 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";
interface Props {
user: User;
@@ -25,9 +29,9 @@ interface Props {
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 disableNavigation = preventNavigation(navDisabled, focusMode);
const router = useRouter();
const expirationDateColor = (date: Date) => {
const momentDate = moment(date);
@@ -48,44 +52,59 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
};
useEffect(() => {
if (user.type !== "student" && user.type !== "teacher") setDisablePaymentPage(false);
if (user.type !== "student" && user.type !== "teacher") return setDisablePaymentPage(false);
isUserFromCorporate(user.id).then((result) => setDisablePaymentPage(result));
}, [user]);
return (
<>
<Modal isOpen={isTicketOpen} onClose={() => setIsTicketOpen(false)} title="Submit a ticket">
<TicketSubmission user={user} page={window.location.href} onClose={() => setIsTicketOpen(false)} />
</Modal>
{user && <MobileMenu path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />}
<header className="w-full bg-transparent py-2 md:py-4 -md:justify-between md:gap-12 flex items-center relative -md:px-4">
<Link href={disableNavigation ? "" : "/"} className=" md:px-8 flex gap-8 items-center">
<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="font-bold text-2xl w-1/6 -md:hidden">EnCoach</h1>
<h1 className="-md:hidden w-1/6 text-2xl font-bold">EnCoach</h1>
</Link>
<div className="flex justify-end -md:items-center gap-4 md:w-5/6 md:mr-8">
<div className="flex items-center justify-end gap-4 md:mr-8 md:w-5/6">
{/* OPEN TICKET SYSTEM */}
<button
className={clsx(
"border-mti-purple-light tooltip tooltip-bottom flex h-8 w-8 flex-col items-center justify-center rounded-full border p-1",
"hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white",
)}
data-tip="Submit a help/feedback ticket"
onClick={() => setIsTicketOpen(true)}>
<BsQuestionCircleFill />
</button>
{showExpirationDate() && (
<Link
href={disablePaymentPage ? "/payment" : ""}
data-tip="Expiry date"
className={clsx(
"py-2 px-6 w-fit flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out tooltip tooltip-bottom",
"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),
"bg-white border-mti-gray-platinum",
"border-mti-gray-platinum bg-white",
)}>
{!user.subscriptionExpirationDate && "Unlimited"}
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
</Link>
)}
<Link href={disableNavigation ? "" : "/profile"} className="flex gap-6 items-center justify-end -md:hidden">
<img src={user.profilePicture} alt={user.name} className="w-10 h-10 rounded-full object-cover" />
<span className="text-right -md:hidden">
<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 w-8 h-8" />
<BsList className="text-mti-purple-light h-8 w-8" />
</div>
</div>
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}

View File

@@ -11,11 +11,12 @@ interface Props {
price: number;
duration: number;
duration_unit: DurationUnit;
loadScript?: boolean;
setIsLoading: (isLoading: boolean) => void;
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
}
export default function PayPalPayment({clientID, price, currency, duration, duration_unit, setIsLoading, onSuccess}: Props) {
export default function PayPalPayment({clientID, price, currency, duration, duration_unit, loadScript, setIsLoading, onSuccess}: Props) {
const createOrder = async (data: CreateOrderData, actions: CreateOrderActions): Promise<string> => {
setIsLoading(true);
@@ -45,7 +46,7 @@ export default function PayPalPayment({clientID, price, currency, duration, dura
setIsLoading(false);
};
return (
return loadScript ? (
<PayPalScriptProvider
options={{
clientId: clientID,
@@ -60,7 +61,17 @@ export default function PayPalPayment({clientID, price, currency, duration, dura
createOrder={createOrder}
onApprove={onApprove}
onCancel={onCancel}
onError={onError}></PayPalButtons>
onError={onError}
/>
</PayPalScriptProvider>
) : (
<PayPalButtons
className="w-full"
style={{layout: "vertical"}}
createOrder={createOrder}
onApprove={onApprove}
onCancel={onCancel}
onError={onError}
/>
);
}

View File

@@ -12,6 +12,7 @@ interface Props {
icon: ReactElement;
value: string | number;
label: string;
tooltip?: string;
}[];
children?: ReactElement;
}
@@ -48,7 +49,10 @@ export default function ProfileSummary({user, items}: Props) {
<div className="flex justify-between w-full mt-8 -md:hidden">
{items.map((item) => (
<div className="flex gap-4 items-center" key={item.label}>
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
<div
className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl relative group tooltip tooltip-bottom"
data-tip={item.tooltip}
>
{item.icon}
</div>
<div className="flex flex-col">

View File

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

View File

@@ -8,6 +8,8 @@ import axios from "axios";
import {speakingReverseMarking} from "@/utils/score";
import {Tab} from "@headlessui/react";
import clsx from "clsx";
import Modal from "../Modal";
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
@@ -22,9 +24,10 @@ export default function InteractiveSpeaking({
onBack,
}: InteractiveSpeakingExercise & CommonProps) {
const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
const [diffNumber, setDiffNumber] = useState<0 | 1 | 2 | 3>(0);
useEffect(() => {
if (userSolutions && userSolutions.length > 0) {
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
Promise.all(userSolutions[0].solution.map((x) => axios.post(`/api/speaking`, {path: x.answer}, {responseType: "arraybuffer"}))).then(
(values) => {
setSolutionsURL(
@@ -42,6 +45,44 @@ export default function InteractiveSpeaking({
return (
<>
<Modal title={`Correction (Prompt ${diffNumber})`} isOpen={diffNumber !== 0} onClose={() => setDiffNumber(0)}>
<>
{userSolutions &&
userSolutions.length > 0 &&
diffNumber !== 0 &&
userSolutions[0].evaluation &&
userSolutions[0].evaluation[`transcript_${diffNumber}`] &&
userSolutions[0].evaluation[`fixed_text_${diffNumber}`] && (
<div className="w-full h-full rounded-xl overflow-hidden flex flex-col mt-4">
<div className="w-full grid grid-cols-2 bg-neutral-100">
<span className="p-3 font-medium text-lg border-r border-r-neutral-200">Transcript</span>
<span className="p-3 font-medium text-lg border-l border-l-neutral-200">Recommended Improvements</span>
</div>
<ReactDiffViewer
styles={{
contentText: {
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
padding: "32px 28px",
},
marker: {display: "none"},
diffRemoved: {padding: "32px 28px"},
diffAdded: {padding: "32px 28px"},
wordRemoved: {padding: "0px", display: "initial"},
wordAdded: {padding: "0px", display: "initial"},
wordDiff: {padding: "0px", display: "initial"},
}}
oldValue={userSolutions[0].evaluation[`transcript_${diffNumber}`]?.replaceAll("\\n", "\n")}
newValue={userSolutions[0].evaluation[`fixed_text_${diffNumber}`]?.replaceAll("\\n", "\n")}
splitView
hideLineNumbers
showDiffOnly={false}
/>
</div>
)}
</>
</Modal>
<div className="flex flex-col h-full w-full gap-8 mb-20">
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
<div className="flex flex-col gap-3">
@@ -67,10 +108,23 @@ export default function InteractiveSpeaking({
{solutionsURL.map((x, index) => (
<div
key={index}
className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex flex-col gap-4">
<div className="flex gap-8 items-center justify-center py-8">
<Waveform audio={x} waveColor="#FCDDEC" progressColor="#EF5DA8" />
</div>
{userSolutions &&
userSolutions.length > 0 &&
userSolutions[0].evaluation &&
userSolutions[0].evaluation[`transcript_${(index + 1) as 1 | 2 | 3}`] &&
userSolutions[0].evaluation[`fixed_text_${(index + 1) as 1 | 2 | 3}`] && (
<Button
className="w-full max-w-[180px] !py-2 self-center"
color="pink"
variant="outline"
onClick={() => setDiffNumber((index + 1) as 1 | 2 | 3)}>
View Correction
</Button>
)}
</div>
))}
</div>
@@ -185,7 +239,11 @@ export default function InteractiveSpeaking({
onNext({
exercise: id,
solutions: userSolutions,
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
score: {
total: 100,
missing: 0,
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
},
type,
})
}

View File

@@ -8,14 +8,18 @@ import axios from "axios";
import {speakingReverseMarking} from "@/utils/score";
import {Tab} from "@headlessui/react";
import clsx from "clsx";
import Modal from "../Modal";
import {BsQuestionCircleFill} from "react-icons/bs";
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
export default function Speaking({id, type, title, video_url, text, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
const [solutionURL, setSolutionURL] = useState<string>();
const [showDiff, setShowDiff] = useState(false);
useEffect(() => {
if (userSolutions && userSolutions.length > 0) {
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => {
const blob = new Blob([data], {type: "audio/wav"});
const url = URL.createObjectURL(blob);
@@ -27,6 +31,42 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
return (
<>
<Modal title="Correction" isOpen={showDiff} onClose={() => setShowDiff(false)}>
<>
{userSolutions &&
userSolutions.length > 0 &&
userSolutions[0].evaluation?.transcript_1 &&
userSolutions[0].evaluation?.fixed_text_1 && (
<div className="w-full h-full rounded-xl overflow-hidden flex flex-col mt-4">
<div className="w-full grid grid-cols-2 bg-neutral-100">
<span className="p-3 font-medium text-lg border-r border-r-neutral-200">Transcript</span>
<span className="p-3 font-medium text-lg border-l border-l-neutral-200">Recommended Improvements</span>
</div>
<ReactDiffViewer
styles={{
contentText: {
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
padding: "32px 28px",
},
marker: {display: "none"},
diffRemoved: {padding: "32px 28px"},
diffAdded: {padding: "32px 28px"},
wordRemoved: {padding: "0px", display: "initial"},
wordAdded: {padding: "0px", display: "initial"},
wordDiff: {padding: "0px", display: "initial"},
}}
oldValue={userSolutions[0].evaluation.transcript_1.replaceAll("\\n", "\n")}
newValue={userSolutions[0].evaluation.fixed_text_1.replaceAll("\\n", "\n")}
splitView
hideLineNumbers
showDiffOnly={false}
/>
</div>
)}
</>
</Modal>
<div className="flex flex-col h-full w-full gap-8 mb-20">
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
<div className="flex flex-col gap-3">
@@ -65,10 +105,19 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
</div>
</div>
<div className="w-full h-full flex flex-col gap-8">
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
<div className="w-full h-full flex flex-col gap-8 relative">
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center relative">
<div className="flex gap-8 items-center justify-center py-8">
{solutionURL && <Waveform audio={solutionURL} waveColor="#FCDDEC" progressColor="#EF5DA8" />}
{userSolutions &&
userSolutions.length > 0 &&
userSolutions[0].evaluation?.transcript_1 &&
userSolutions[0].evaluation?.fixed_text_1 && (
<Button className="w-full max-w-[180px] !py-2" color="pink" variant="outline" onClick={() => setShowDiff(true)}>
View Correction
</Button>
)}
</div>
</div>
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
@@ -152,7 +201,11 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
onNext({
exercise: id,
solutions: userSolutions,
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
score: {
total: 100,
missing: 0,
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
},
type,
})
}

View File

@@ -76,7 +76,7 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
</div>
</>
)}
{showDiff && (
{showDiff && userSolutions[0].evaluation && (
<>
<span>Correction:</span>
<div className="w-full h-full max-h-[320px] overflow-y-scroll scrollbar-hide cursor-text border-2 overflow-x-hidden border-mti-gray-platinum bg-white rounded-3xl">
@@ -191,7 +191,11 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
onNext({
exercise: id,
solutions: userSolutions,
score: {total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
score: {
total: 100,
missing: 0,
correct: userSolutions[0]?.evaluation ? writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
},
type,
})
}

View File

@@ -243,7 +243,9 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
options={CURRENCIES_OPTIONS}
value={CURRENCIES_OPTIONS.find((c) => c.value === paymentCurrency)}
onChange={(value) => setPaymentCurrency(value?.value)}
menuPortalTarget={document?.body}
styles={{
menuPortal: (base) => ({...base, zIndex: 9999}),
control: (styles) => ({
...styles,
paddingLeft: "4px",
@@ -282,8 +284,10 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
value: referralAgent,
label: referralAgentLabel,
}}
menuPortalTarget={document?.body}
onChange={(value) => setReferralAgent(value?.value)}
styles={{
menuPortal: (base) => ({...base, zIndex: 9999}),
control: (styles) => ({
...styles,
paddingLeft: "4px",
@@ -314,7 +318,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
type="number"
defaultValue={commissionValue || 0}
className="col-span-3"
disabled={disabled}
disabled={disabled || loggedInUser.type === "agent"}
/>
</>
) : (
@@ -520,6 +524,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<Select
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
options={USER_STATUS_OPTIONS}
menuPortalTarget={document?.body}
value={USER_STATUS_OPTIONS.find((o) => o.value === status)}
onChange={(value) => setStatus(value?.value as typeof user.status)}
styles={{
@@ -532,6 +537,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
outline: "none",
},
}),
menuPortal: (base) => ({...base, zIndex: 9999}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
@@ -546,6 +552,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<Select
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
options={USER_TYPE_OPTIONS}
menuPortalTarget={document?.body}
value={USER_TYPE_OPTIONS.find((o) => o.value === type)}
onChange={(value) => setType(value?.value as typeof user.type)}
styles={{
@@ -558,6 +565,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
outline: "none",
},
}),
menuPortal: (base) => ({...base, zIndex: 9999}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
@@ -574,17 +582,17 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<div className="flex gap-4 justify-between mt-4 w-full">
<div className="self-start flex gap-4 justify-start items-center w-full">
{onViewCorporate && (
{onViewCorporate && ["student", "teacher"].includes(user.type) && (
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewCorporate}>
View Corporate
</Button>
)}
{onViewStudents && (
{onViewStudents && ["corporate", "teacher"].includes(user.type) && (
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewStudents}>
View Students
</Button>
)}
{onViewTeachers && (
{onViewTeachers && ["student", "corporate"].includes(user.type) && (
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewTeachers}>
View Teachers
</Button>

View File

@@ -1,13 +1,13 @@
{
"type": "service_account",
"project_id": "mti-ielts",
"private_key_id": "22b783a14c760d1215a8d1f5de0fa40a33a840e7",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDoNkd7s/izUBRb\nlmJYWl0xk4X9wEVJU4LKA4HPeha8RFDse4T4suVP08oCP9ODSXF5A83+IqXNMs/N\na7PtFABBAx433JrB7I4NsAUrDSjI4LeYEIqh6YzHsQvBU53HAmPChX525S4i0IBy\ncNnyXut0nmlHz5ZwCPXgqg4eN44C+m0f7sxzivcnPth/zLupnMiDAHFZrxQolWO2\n6JfozMWGw0TmCkUxngzeGBMVYmsGiKRIxEi3MWeuwjYjGO4nR1krEUlcpjCbx4UX\nxYXicJb17HOs9LTcSh9bpDWZPHKXR48hxd2cMLr+XQzw7Otwu2p8fEUOJ+CiTyNz\nlkN9p7OhAgMBAAECggEAB5DsMZdGu1X4wdazr+AK4RCG2UKkZ0wbqvgkCMX4O2xo\n7BmmtqFCmEAk+P+KJWEVW81wTu9jUl0tWOrBVzBThUrEF2seVkL+SmshsfpI6cmr\npb5lO/sTgZau1L7kGU3GQRpvKVHUl+EODFyJt2xZFOjL8qFsjAw4sbgsw1aJT6a4\nFilm6Gapi1qSKOPSlXVmi0NJ9DUtNbKaQK8/coqEJRizeXs9MORvzyKQaV8PBmWI\noEnkxahKOD48U2kmI7rT9/YsCuaP2BlGdLxvANXLjAKcrDccVZkYEH82tPtCicED\noow3i956HPdWSXQgUOU65MfGccjOmqGaGa4zUTICyQKBgQD6zLMwL9YS+n9EKZaK\nEbzRybN2d+eKbXyDJzkDi6FnSGVre2ndShsimoOtwZDLmOF/XhN79YOLJVbI124p\npAWO+WxAfe9Xy3iFEBmL4kSREA873Sd8EN5OfYS2DsN7IbjZkoaLuM8QlyXL9ZRS\nBJDVGjx+wFKRjnClcBNbVMMXiQKBgQDtBumKZS0ZCtJuBeuwLGJ1ZJtYECykIrsD\nUtQ7zxwXJzPGqZ2c5JLpHdDm/bb9nllpLsh4SpDRqxFa2H2FF8x5KWaS7JQUsS8e\ner6x5wUt6wAJqV/ZvttVrLZCa8VYn+K7bTANnkPNJZHTqBTJbxkXMDTtkwWXUN2z\nQP3N9lodWQKBgFBHiewYw9ubV3WIImnbt6cne0ymoPUMitioi3V5Epcu81fuTzrI\nZ9sxvoi19xVUwIm2oWICerLlptvvKZImsKjNajtSlHRz6wYc2zCNowkULOwqpGLw\nO1jAkOR94VDewH7UikDbTVywJSceWvXOBFZSaZ7hDQ0OnTw3ndqUTUaRAoGAd2BG\n2PPyDa28o7sJpBYGlJdSAb1LrnLre1YJHAJIZITS99hPUEhykUP6BYx80CkjYO01\n/BeZ7m9Y80cbmJ+O1Or8BT1vqyg90f0B8/mlSyYTQ8pxQupz7ydoN/WtU+BawgjQ\n7drqzPSCCHab2YPBwEMANTMZ2sbYkcJG0aekZSkCgYBbnFJm8kUy57isxHyvrci+\nR30KQl2Y9okPytF8PpLH+yNjLDoduTOHL/hZoFC0M4Gklx4wPKpsEhImIrWmG9VC\n0UrQC6TT1WoY6/S3YehVmTXo/nBPD1XTUcbF/xxUrWDjmMjnt1IlXBbIzUPD3U4P\niRXzHnXb7yi+/iRxSDts2w==\n-----END PRIVATE KEY-----\n",
"client_email": "firebase-adminsdk-dyg6p@mti-ielts.iam.gserviceaccount.com",
"client_id": "104980563453519094431",
"project_id": "storied-phalanx-349916",
"private_key_id": "c9e05f6fe413b1031a71f981160075ff4b044444",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDdgavFB63nMHyb\n38ncwijTrUmqU9UyzNJ8wlZCWAWuoz25Gng988fkKNDXnHY+ap9esHyNYg9IdSA7\nAuZeHpzTZmKiWZzFWq61KWSTgIn1JwKHGHJJdmVhTYfCe9I51cFLa5q2lTFzJ0ce\nbP7/X/7kw53odgva+M8AhDTbe60akpemgZc+LFwO0Abm7erH2HiNyjoNZzNw525L\n933PCaQwhZan04s1u0oRdVlBIBwMk+J0ojgVEpUiJOzF7gkN+UpDXujalLYdlR4q\nhkGgScXQhDYJkECC3GuvOnEo1YXGNjW9D73S6sSH+Lvqta4wW1+sTn0kB6goiQBI\n7cA1G6x3AgMBAAECggEAZPMwAX/adb7XS4LWUNH8IVyccg/63kgSteErxtiu3kRv\nYOj7W+C6fPVNGLap/RBCybjNSvIh3PfkVICh1MtG1eGXmj4VAKyvaskOmVq/hQbe\nVAuEKo7W7V2UPcKIsOsGSQUlYYjlHIIOG4O5Q1HQrRmp4cPK62Txkl6uaEkZPz4u\nbvIK2BJI8aHRwxE3Phw09blwlLqQQQ8nrhK29x5puaN+ft++IlzIOVsLz+n4kTdB\n6qkG/dhenn3K8o3+NkmSN6eNRbdJd36zXTo4Oatbvqb7r0E8vYn/3Llawo2X75zn\nec7jMHrOmcwtiu9H3PsrTWtzdSjxPHy0UtEn1HWK4QKBgQD+c/V8tAvbaUGVoZf6\ntKtDSKF6IHuY2vUO33v950mVdjrTursqOG2d+SLfSnKpc+sjDlj7/S5u4uRP+qUN\ng1rb2U7oIA7tsDa2ZTSkIx6HkPUzS+fBOxELLrbgMoJ2RLzgkiPhS95YgXJ/rYG5\nWQTehzCT5roes0RvtgM0gl3EhQKBgQDe2m7PRIU4g3RJ8HTx92B4ja8W9FVCYDG5\nPOAdZB8WB6Bvu4BJHBDLr8vDi930pKj+vYObRqBDQuILW4t8wZQJ834dnoq6EpUz\nhbVEURVBP4A/nEHrQHfq0Lp+cxThy2rw7obRQOLPETtC7p3WFgSHT6PRTcpGzCCX\n+76a30yrywKBgC/5JNtyBppDaf4QDVtTHMb+tpMT9LmI7pLzR6lDJfhr5gNtPURk\nhyY1hoGaw6t3E2n0lopL3alCVdFObDfz//lbKylQggAGLQqOYjJf/K2KgvA862Df\nBgOZtxjl7PrnUsT0SJd9elotbazsxXxwcB6UVnBMG+MV4V0+b7RCr/MRAoGBAIfp\nTcVIs7roqOZjKN9dEE/VkR/9uXW2tvyS/NfP9Ql5c0ZRYwazgCbJOwsyZRZLyek6\naWYsp5b91mA435QhdwiuoI6t30tmA+qdNBTLIpxdfvjMcoNoGPpzfBmcU/L1HW58\n+mnqGalRiAPlBQvI99ASKQWAXMnaulIWrYNEhj0LAoGBALi+QZ2pp+hDeC59ezWr\nbP1zbbONceHKGgJcevChP2k1OJyIOIqmBYeTuM4cPc5ofZYQNaMC31cs8SVeSRX1\nNTxQZmvCjMyTe/WYWYNFXdgkVz4egFXbeochCGzMYo57HV1PCkPBrARRZO8OfdDD\n8sDu//ohb7nCzceEI0DnWs13\n-----END PRIVATE KEY-----\n",
"client_email": "firebase-adminsdk-3ml0u@storied-phalanx-349916.iam.gserviceaccount.com",
"client_id": "114163760341944984396",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-dyg6p%40mti-ielts.iam.gserviceaccount.com",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-3ml0u%40storied-phalanx-349916.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}

View File

@@ -10,7 +10,7 @@ export const PERMISSIONS = {
developer: ["developer"],
},
deleteUser: {
student: ["teacher", "corporate", "developer", "admin"],
student: ["corporate", "developer", "admin"],
teacher: ["corporate", "developer", "admin"],
corporate: ["admin", "developer"],
admin: ["developer", "admin"],
@@ -18,8 +18,8 @@ export const PERMISSIONS = {
developer: ["developer"],
},
updateUser: {
student: ["teacher", "corporate", "developer", "admin"],
teacher: ["corporate", "developer", "admin"],
student: ["developer", "admin"],
teacher: ["developer", "admin"],
corporate: ["admin", "developer"],
admin: ["developer", "admin"],
agent: ["developer", "admin"],

View File

@@ -7,13 +7,22 @@ import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils";
import moment from "moment";
import {useEffect, useState} from "react";
import {BsArrowLeft, BsBriefcaseFill, BsGlobeCentralSouthAsia, BsPerson, BsPersonFill, BsPencilSquare, BsBank, BsCurrencyDollar} from "react-icons/bs";
import {
BsArrowLeft,
BsBriefcaseFill,
BsGlobeCentralSouthAsia,
BsPerson,
BsPersonFill,
BsPencilSquare,
BsBank,
BsCurrencyDollar,
} from "react-icons/bs";
import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups";
import IconCard from "./IconCard";
import useFilterStore from "@/stores/listFilterStore";
import {useRouter} from "next/router";
import usePaymentStatusUsers from '@/hooks/usePaymentStatusUsers';
import usePaymentStatusUsers from "@/hooks/usePaymentStatusUsers";
interface Props {
user: User;
@@ -27,7 +36,7 @@ export default function AdminDashboard({user}: Props) {
const {stats} = useStats(user.id);
const {users, reload} = useUsers();
const {groups} = useGroups();
const { pending, done } = usePaymentStatusUsers();
const {pending, done} = usePaymentStatusUsers();
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter();
@@ -148,7 +157,7 @@ export default function AdminDashboard({user}: Props) {
</>
);
const CorporatePaidStatusList = ({ paid }: {paid: Boolean}) => {
const CorporatePaidStatusList = ({paid}: {paid: Boolean}) => {
const list = paid ? done : pending;
const filter = (x: User) => x.type === "corporate" && list.includes(x.id);
@@ -161,7 +170,9 @@ export default function AdminDashboard({user}: Props) {
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">{paid ? 'Payment Done' : 'Pending Payment'} ({list.length})</h2>
<h2 className="text-2xl font-semibold">
{paid ? "Payment Done" : "Pending Payment"} ({list.length})
</h2>
</div>
<UserList user={user} filters={[filter]} />
</>
@@ -290,13 +301,7 @@ export default function AdminDashboard({user}: Props) {
}
color="rose"
/>
<IconCard
onClick={() => setPage("paymentdone")}
Icon={BsCurrencyDollar}
label="Payment Done"
value={done.length}
color="purple"
/>
<IconCard onClick={() => setPage("paymentdone")} Icon={BsCurrencyDollar} label="Payment Done" value={done.length} color="purple" />
<IconCard
onClick={() => setPage("paymentpending")}
Icon={BsCurrencyDollar}
@@ -323,7 +328,9 @@ export default function AdminDashboard({user}: Props) {
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter((x) => x.type === "corporate")
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.sort((a, b) => {
return dateSorter(a, b, "desc", "registrationDate");
})
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}

View File

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

View File

@@ -19,6 +19,8 @@ import {toast} from "react-toastify";
import {uuidv4} from "@firebase/util";
import {Assignment} from "@/interfaces/results";
import Checkbox from "@/components/Low/Checkbox";
import {InstructorGender, Variant} from "@/interfaces/exam";
import Select from "@/components/Low/Select";
interface Props {
isCreating: boolean;
@@ -34,12 +36,12 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
const [assignees, setAssignees] = useState<string[]>(assignment?.assignees || []);
const [name, setName] = useState(assignment?.name || generate({minLength: 6, maxLength: 8, min: 2, max: 3, join: " ", formatter: capitalize}));
const [isLoading, setIsLoading] = useState(false);
const [startDate, setStartDate] = useState<Date | null>(
assignment ? moment(assignment.startDate).toDate() : moment().hours(0).minutes(0).add(1, "day").toDate(),
);
const [startDate, setStartDate] = useState<Date | null>(assignment ? moment(assignment.startDate).toDate() : new Date());
const [endDate, setEndDate] = useState<Date | null>(
assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(),
);
const [variant, setVariant] = useState<Variant>("full");
const [instructorGender, setInstructorGender] = useState<InstructorGender>(assignment?.instructorGender || "varied");
// creates a new exam for each assignee or just one exam for all assignees
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
@@ -62,6 +64,8 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
endDate,
selectedModules,
generateMultiple,
variant,
instructorGender,
})
.then(() => {
toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`);
@@ -200,7 +204,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
"transition duration-300 ease-in-out",
)}
popperClassName="!z-20"
filterDate={(date) => moment(date).isAfter(new Date())}
filterTime={(date) => moment(date).isSameOrAfter(new Date())}
dateFormat="dd/MM/yyyy HH:mm"
selected={startDate}
showTimeSelect
@@ -216,7 +220,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
"transition duration-300 ease-in-out",
)}
popperClassName="!z-20"
filterDate={(date) => moment(date).isAfter(startDate)}
filterTime={(date) => moment(date).isAfter(startDate)}
dateFormat="dd/MM/yyyy HH:mm"
selected={endDate}
showTimeSelect
@@ -225,6 +229,20 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
</div>
</div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor&apos;s Gender</label>
<Select
value={{value: instructorGender, label: capitalize(instructorGender)}}
onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)}
disabled={!selectedModules.includes("speaking") || !!assignment}
options={[
{value: "male", label: "Male"},
{value: "female", label: "Female"},
{value: "varied", label: "Varied"},
]}
/>
</div>
<section className="w-full flex flex-col gap-3">
<span className="font-semibold">Assignees ({assignees.length} selected)</span>
<div className="flex gap-4 overflow-x-scroll scrollbar-hide">
@@ -281,7 +299,10 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
))}
</div>
</section>
<div className="flex gap-4 w-full justify-end">
<div className="flex flex-col gap-4 w-full items-end">
<Checkbox isChecked={variant === "full"} onChange={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
Full length exams
</Checkbox>
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}>
Generate different exams
</Checkbox>

View File

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

View File

@@ -2,7 +2,7 @@
import Modal from "@/components/Modal";
import useStats from "@/hooks/useStats";
import useUsers from "@/hooks/useUsers";
import {Group, Stat, User} from "@/interfaces/user";
import {CorporateUser, Group, Stat, User} from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils";
import moment from "moment";
@@ -20,6 +20,9 @@ import {
BsPersonFillGear,
BsPersonGear,
BsPencilSquare,
BsPersonBadge,
BsPersonCheck,
BsPeople,
} from "react-icons/bs";
import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups";
@@ -31,9 +34,10 @@ import IconCard from "./IconCard";
import GroupList from "@/pages/(admin)/Lists/GroupList";
import useFilterStore from "@/stores/listFilterStore";
import {useRouter} from "next/router";
import useCodes from "@/hooks/useCodes";
interface Props {
user: User;
user: CorporateUser;
}
export default function CorporateDashboard({user}: Props) {
@@ -43,6 +47,7 @@ export default function CorporateDashboard({user}: Props) {
const {stats} = useStats();
const {users, reload} = useUsers();
const {codes} = useCodes(user.id);
const {groups} = useGroups(user.id);
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
@@ -187,7 +192,13 @@ export default function CorporateDashboard({user}: Props) {
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
color="purple"
/>
<IconCard onClick={() => setPage("groups")} Icon={BsPersonAdd} label="Groups" value={groups.length} color="purple" />
<IconCard onClick={() => setPage("groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
<IconCard
Icon={BsPersonCheck}
label="User Balance"
value={`${codes.length}/${user.corporateInformation?.companyInformation?.userAmount || 0}`}
color="purple"
/>
<IconCard
Icon={BsClock}
label="Expiration Date"

View File

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

View File

@@ -1,9 +1,13 @@
import Button from "@/components/Low/Button";
import ProgressBar from "@/components/Low/ProgressBar";
import InviteCard from "@/components/Medium/InviteCard";
import PayPalPayment from "@/components/PayPalPayment";
import ProfileSummary from "@/components/ProfileSummary";
import useAssignments from "@/hooks/useAssignments";
import useInvites from "@/hooks/useInvites";
import useStats from "@/hooks/useStats";
import useUsers from "@/hooks/useUsers";
import {Invite} from "@/interfaces/invite";
import {Assignment} from "@/interfaces/results";
import {CorporateUser, User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore";
@@ -31,7 +35,9 @@ export default function StudentDashboard({user}: Props) {
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
const {stats} = useStats(user.id);
const {users} = useUsers();
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id});
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user.id});
const router = useRouter();
@@ -42,7 +48,7 @@ export default function StudentDashboard({user}: Props) {
const setAssignment = useExamStore((state) => state.setAssignment);
useEffect(() => {
getUserCorporate("IXdh9EQziAVXXh0jOiC5cPVlgS82").then(setCorporateUserToShow);
getUserCorporate(user.id).then(setCorporateUserToShow);
}, [user]);
const startAssignment = (assignment: Assignment) => {
@@ -69,7 +75,7 @@ export default function StudentDashboard({user}: Props) {
return (
<>
{corporateUserToShow && (
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
<div className="absolute right-4 top-4 rounded-lg bg-neutral-200 px-2 py-1">
Linked to: <b>{corporateUserToShow?.corporateInformation?.companyInformation.name || corporateUserToShow.name}</b>
</div>
)}
@@ -77,40 +83,42 @@ export default function StudentDashboard({user}: Props) {
user={user}
items={[
{
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
icon: <BsFileEarmarkText className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
value: Object.keys(groupBySession(stats)).length,
label: "Exams",
},
{
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
icon: <BsPencil className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
value: stats.length,
label: "Exercises",
},
{
icon: <BsStar className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
icon: <BsStar className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
label: "Average Score",
},
]}
/>
{/* Bio */}
<section className="flex flex-col gap-1 md:gap-3">
<span className="font-bold text-lg">Bio</span>
<span className="text-lg font-bold">Bio</span>
<span className="text-mti-gray-taupe">
{user.bio || "Your bio will appear here, you can change it by clicking on your name in the top right corner."}
</span>
</section>
{/* Assignments */}
<section className="flex flex-col gap-1 md:gap-3">
<div className="flex gap-4 items-center">
<div className="flex items-center gap-4">
<div
onClick={reloadAssignments}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<span className="font-bold text-lg text-mti-black">Assignments</span>
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out">
<span className="text-mti-black text-lg font-bold">Assignments</span>
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
</div>
</div>
<span className="text-mti-gray-taupe flex gap-8 overflow-x-scroll scrollbar-hide">
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
{assignments.filter((a) => moment(a.endDate).isSameOrAfter(moment())).length === 0 &&
"Assignments will appear here. It seems that for now there are no assignments for you."}
{assignments
@@ -119,20 +127,20 @@ export default function StudentDashboard({user}: Props) {
.map((assignment) => (
<div
className={clsx(
"border border-mti-gray-anti-flash rounded-xl flex flex-col gap-6 p-4 min-w-[300px]",
"border-mti-gray-anti-flash flex min-w-[300px] flex-col gap-6 rounded-xl border p-4",
assignment.results.map((r) => r.user).includes(user.id) && "border-mti-green-light",
)}
key={assignment.id}>
<div className="flex flex-col gap-1">
<h3 className="font-semibold text-xl text-mti-black/90">{assignment.name}</h3>
<span className="flex gap-1 justify-between">
<h3 className="text-mti-black/90 text-xl font-semibold">{assignment.name}</h3>
<span className="flex justify-between gap-1">
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
<span>-</span>
<span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span>
</span>
</div>
<div className="flex justify-between w-full items-center">
<div className="grid grid-cols-2 gap-2 place-items-center justify-center w-fit min-w-[104px] -md:mt-2">
<div className="flex w-full items-center justify-between">
<div className="-md:mt-2 grid w-fit min-w-[104px] grid-cols-2 place-items-center justify-center gap-2">
{assignment.exams
.filter((e) => e.assignee === user.id)
.map((e) => e.module)
@@ -142,36 +150,36 @@ export default function StudentDashboard({user}: Props) {
key={module}
data-tip={capitalize(module)}
className={clsx(
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl tooltip",
"-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="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" />}
{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>
{!assignment.results.map((r) => r.user).includes(user.id) && (
<>
<div
className="tooltip w-full md:hidden h-full flex items-center justify-end pl-8"
className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden"
data-tip="Your screen size is too small to perform an assignment">
<Button
disabled={moment(assignment.startDate).isAfter(moment())}
className="w-full h-full !rounded-xl"
className="h-full w-full !rounded-xl"
variant="outline">
Start
</Button>
</div>
<Button
disabled={moment(assignment.startDate).isAfter(moment())}
className="w-full max-w-[50%] h-full !rounded-xl -md:hidden"
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
onClick={() => startAssignment(assignment)}
variant="outline">
Start
@@ -182,7 +190,7 @@ export default function StudentDashboard({user}: Props) {
<Button
onClick={() => router.push("/record")}
color="green"
className="w-full max-w-[50%] h-full !rounded-xl -md:hidden"
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
variant="outline">
Submitted
</Button>
@@ -193,23 +201,43 @@ export default function StudentDashboard({user}: Props) {
</span>
</section>
{/* Invites */}
{invites.length > 0 && (
<section className="flex flex-col gap-1 md:gap-3">
<div className="flex items-center gap-4">
<div
onClick={reloadInvites}
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out">
<span className="text-mti-black text-lg font-bold">Invites</span>
<BsArrowRepeat className={clsx("text-xl", isInvitesLoading && "animate-spin")} />
</div>
</div>
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
{invites.map((invite) => (
<InviteCard key={invite.id} invite={invite} users={users} reload={reloadInvites} />
))}
</span>
</section>
)}
{/* Score History */}
<section className="flex flex-col gap-3">
<span className="font-bold text-lg">Score History</span>
<div className="grid -md:grid-rows-4 md:grid-cols-2 gap-6">
<span className="text-lg font-bold">Score History</span>
<div className="-md:grid-rows-4 grid gap-6 md:grid-cols-2">
{MODULE_ARRAY.map((module) => (
<div className="border border-mti-gray-anti-flash rounded-xl flex flex-col gap-2 p-4" key={module}>
<div className="flex gap-2 md:gap-3 items-center">
<div className="w-8 h-8 md:w-12 md:h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg md:rounded-xl">
{module === "reading" && <BsBook className="text-ielts-reading w-4 h-4 md:w-5 md:h-5" />}
{module === "listening" && <BsHeadphones className="text-ielts-listening w-4 h-4 md:w-5 md:h-5" />}
{module === "writing" && <BsPen className="text-ielts-writing w-4 h-4 md:w-5 md:h-5" />}
{module === "speaking" && <BsMegaphone className="text-ielts-speaking w-4 h-4 md:w-5 md:h-5" />}
{module === "level" && <BsClipboard className="text-ielts-level w-4 h-4 md:w-5 md:h-5" />}
<div className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4" key={module}>
<div className="flex items-center gap-2 md:gap-3">
<div className="bg-mti-gray-smoke flex h-8 w-8 items-center justify-center rounded-lg md:h-12 md:w-12 md:rounded-xl">
{module === "reading" && <BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" />}
{module === "listening" && <BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />}
{module === "writing" && <BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" />}
{module === "speaking" && <BsMegaphone className="text-ielts-speaking h-4 w-4 md:h-5 md:w-5" />}
{module === "level" && <BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" />}
</div>
<div className="flex justify-between w-full">
<span className="font-bold md:font-extrabold text-sm">{capitalize(module)}</span>
<span className="text-sm font-normal text-mti-gray-dim">
Level {user.levels[module] || 0} / Level {user.desiredLevels[module] || 9}
<div className="flex w-full justify-between">
<span className="text-sm font-bold md:font-extrabold">{capitalize(module)}</span>
<span className="text-mti-gray-dim text-sm font-normal">
Level {user.levels[module] || 0} / Level 9 (Desired Level: {user.desiredLevels[module] || 9})
</span>
</div>
</div>
@@ -217,8 +245,10 @@ export default function StudentDashboard({user}: Props) {
<ProgressBar
color={module}
label=""
percentage={Math.round((user.levels[module] * 100) / user.desiredLevels[module])}
className="w-full h-2"
mark={Math.round((user.desiredLevels[module] * 100) / 9)}
markLabel={`Desired Level: ${user.desiredLevels[module]}`}
percentage={Math.round((user.levels[module] * 100) / 9)}
className="h-2 w-full"
/>
</div>
</div>

View File

@@ -19,6 +19,7 @@ import {
BsEnvelopePaper,
BsGlobeCentralSouthAsia,
BsPaperclip,
BsPeople,
BsPerson,
BsPersonAdd,
BsPersonFill,
@@ -150,8 +151,9 @@ export default function TeacherDashboard({user}: Props) {
};
const AssignmentsPage = () => {
const activeFilter = (a: Assignment) => moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment());
const pastFilter = (a: Assignment) => moment(a.endDate).isBefore(moment());
const activeFilter = (a: Assignment) =>
moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()) && a.assignees.length > a.results.length;
const pastFilter = (a: Assignment) => moment(a.endDate).isBefore(moment()) || a.assignees.length === a.results.length;
const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
return (
@@ -271,7 +273,7 @@ export default function TeacherDashboard({user}: Props) {
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
color="purple"
/>
<IconCard Icon={BsPersonAdd} label="Groups" value={groups.length} color="purple" onClick={() => setPage("groups")} />
<IconCard Icon={BsPeople} label="Groups" value={groups.length} color="purple" onClick={() => setPage("groups")} />
<div
onClick={() => setPage("assignments")}
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
</head>
<div style="background-color: #ffffff; color: #353338;"
class="h-full min-h-screen w-full flex flex-col p-8 gap-16 text-base">
<img src="/logo_title.png" class="w-48 h-48 self-center" />
<div>
<span>Hello {{name}},</span>
<br/>
<br/>
<span>You have been invited to join {{corporateName}}'s group!</span>
<br />
<br/>
<span>Please access the platform to accept or decline the invite.</span>
</div>
<br />
<br />
<div>
<span>Thanks, <br /> Your EnCoach team</span>
</div>
</div>
</html>

View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
</head>
<div style="background-color: #ffffff; color: #353338;"
class="h-full min-h-screen w-full flex flex-col p-8 gap-16 text-base">
<img src="/logo_title.png" class="w-48 h-48 self-center" />
<div>
<span>Hello {{corporateName}},</span>
<br />
<br />
<span>{{name}} has decided to {{decision}} your invite!</span>
</div>
<br />
<br />
<div>
<span>Thanks, <br /> Your EnCoach team</span>
</div>
</div>
</html>

View File

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

View File

@@ -76,7 +76,6 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
return (
<div className="flex flex-col items-center justify-center gap-1">
<span className="text-xl font-bold">{levelStr}</span>
<span className="text-xl">{grade}</span>
</div>
);
}
@@ -86,7 +85,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
return (
<>
<div className="w-full min-h-full h-fit flex flex-col items-center justify-between gap-8">
<div className="flex h-fit min-h-full w-full flex-col items-center justify-between gap-8">
<ModuleTitle
module={selectedModule}
totalExercises={getTotalExercises()}
@@ -99,10 +98,10 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
<div
onClick={() => setSelectedModule("reading")}
className={clsx(
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-reading hover:text-white",
"hover:bg-ielts-reading flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
selectedModule === "reading" ? "bg-ielts-reading text-white" : "bg-mti-gray-smoke text-ielts-reading",
)}>
<BsBook className="w-6 h-6" />
<BsBook className="h-6 w-6" />
<span className="font-semibold">Reading</span>
</div>
)}
@@ -110,10 +109,10 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
<div
onClick={() => setSelectedModule("listening")}
className={clsx(
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-listening hover:text-white",
"hover:bg-ielts-listening flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
selectedModule === "listening" ? "bg-ielts-listening text-white" : "bg-mti-gray-smoke text-ielts-listening",
)}>
<BsHeadphones className="w-6 h-6" />
<BsHeadphones className="h-6 w-6" />
<span className="font-semibold">Listening</span>
</div>
)}
@@ -121,10 +120,10 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
<div
onClick={() => setSelectedModule("writing")}
className={clsx(
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-writing hover:text-white",
"hover:bg-ielts-writing flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
selectedModule === "writing" ? "bg-ielts-writing text-white" : "bg-mti-gray-smoke text-ielts-writing",
)}>
<BsPen className="w-6 h-6" />
<BsPen className="h-6 w-6" />
<span className="font-semibold">Writing</span>
</div>
)}
@@ -132,10 +131,10 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
<div
onClick={() => setSelectedModule("speaking")}
className={clsx(
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-speaking hover:text-white",
"hover:bg-ielts-speaking flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
selectedModule === "speaking" ? "bg-ielts-speaking text-white" : "bg-mti-gray-smoke text-ielts-speaking",
)}>
<BsMegaphone className="w-6 h-6" />
<BsMegaphone className="h-6 w-6" />
<span className="font-semibold">Speaking</span>
</div>
)}
@@ -143,18 +142,18 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
<div
onClick={() => setSelectedModule("level")}
className={clsx(
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-level hover:text-white",
"hover:bg-ielts-level flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
selectedModule === "level" ? "bg-ielts-level text-white" : "bg-mti-gray-smoke text-ielts-level",
)}>
<BsClipboard className="w-6 h-6" />
<BsClipboard className="h-6 w-6" />
<span className="font-semibold">Level</span>
</div>
)}
</div>
{isLoading && (
<div className="w-fit h-fit absolute top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 animate-pulse flex flex-col gap-12 items-center">
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
<span className={clsx("loading loading-infinity w-32", moduleColors[selectedModule].progress)} />
<span className={clsx("font-bold text-2xl text-center", moduleColors[selectedModule].progress)}>
<span className={clsx("text-center text-2xl font-bold", moduleColors[selectedModule].progress)}>
Evaluating your answers, please be patient...
<br />
You can also check it later on your records page!
@@ -162,17 +161,21 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
</div>
)}
{!isLoading && (
<div className="w-full flex gap-9 mt-32 items-center justify-between mb-20">
<div className="mb-20 mt-32 flex w-full items-center justify-between gap-9">
<span className="max-w-3xl">{moduleResultText(selectedModule, bandScore)}</span>
<div className="flex gap-9 px-16">
<div
className={clsx("radial-progress overflow-hidden", moduleColors[selectedModule].progress)}
style={
{"--value": (selectedScore.correct / selectedScore.total) * 100, "--thickness": "12px", "--size": "13rem"} as any
{
"--value": (selectedScore.correct / selectedScore.total) * 100,
"--thickness": "12px",
"--size": "13rem",
} as any
}>
<div
className={clsx(
"w-48 h-48 rounded-full flex flex-col items-center justify-center",
"flex h-48 w-48 flex-col items-center justify-center rounded-full",
moduleColors[selectedModule].inner,
)}>
<span className="text-xl">Level</span>
@@ -181,7 +184,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
</div>
<div className="flex flex-col gap-5">
<div className="flex gap-2">
<div className="w-3 h-3 bg-mti-red-light rounded-full mt-1" />
<div className="bg-mti-red-light mt-1 h-3 w-3 rounded-full" />
<div className="flex flex-col">
<span className="text-mti-red-light">
{(((selectedScore.total - selectedScore.missing) / selectedScore.total) * 100).toFixed(0)}%
@@ -190,14 +193,14 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
</div>
</div>
<div className="flex gap-2">
<div className="w-3 h-3 bg-mti-purple-light rounded-full mt-1" />
<div className="bg-mti-purple-light mt-1 h-3 w-3 rounded-full" />
<div className="flex flex-col">
<span className="text-mti-purple-light">{selectedScore.correct.toString().padStart(2, "0")}</span>
<span className="text-lg">Correct</span>
</div>
</div>
<div className="flex gap-2">
<div className="w-3 h-3 bg-mti-rose-light rounded-full mt-1" />
<div className="bg-mti-rose-light mt-1 h-3 w-3 rounded-full" />
<div className="flex flex-col">
<span className="text-mti-rose-light">
{(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")}
@@ -212,28 +215,28 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
</div>
{!isLoading && (
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<div className="absolute bottom-8 left-0 flex w-full justify-between gap-8 self-end px-8">
<div className="flex gap-8">
<div className="w-fit flex flex-col items-center gap-1 cursor-pointer">
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
<button
onClick={() => window.location.reload()}
className="w-11 h-11 rounded-full bg-mti-purple-light hover:bg-mti-purple flex items-center justify-center transition duration-300 ease-in-out">
<BsArrowCounterclockwise className="text-white w-7 h-7" />
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
<BsArrowCounterclockwise className="h-7 w-7 text-white" />
</button>
<span>Play Again</span>
</div>
<div className="w-fit flex flex-col items-center gap-1 cursor-pointer">
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
<button
onClick={onViewResults}
className="w-11 h-11 rounded-full bg-mti-purple-light hover:bg-mti-purple flex items-center justify-center transition duration-300 ease-in-out">
<BsEyeFill className="text-white w-7 h-7" />
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
<BsEyeFill className="h-7 w-7 text-white" />
</button>
<span>Review Answers</span>
</div>
</div>
<Link href="/" className="max-w-[200px] w-full self-end">
<Button color="purple" className="max-w-[200px] self-end w-full">
<Link href="/" className="w-full max-w-[200px] self-end">
<Button color="purple" className="w-full max-w-[200px] self-end">
Dashboard
</Button>
</Link>

View File

@@ -22,9 +22,9 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
const [questionIndex, setQuestionIndex] = useState(0);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [exerciseIndex, setExerciseIndex] = useState(0);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
useEffect(() => {
setCurrentQuestionIndex(0);
@@ -38,7 +38,7 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
const nextExercise = (solution?: UserSolution) => {
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
}
setQuestionIndex((prev) => prev + currentQuestionIndex);
@@ -62,7 +62,7 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
const previousExercise = (solution?: UserSolution) => {
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
}
if (exerciseIndex > 0) {
@@ -91,7 +91,7 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
!showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
showSolutions &&

View File

@@ -7,7 +7,6 @@ import AudioPlayer from "@/components/Low/AudioPlayer";
import Button from "@/components/Low/Button";
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils";
interface Props {
@@ -16,24 +15,35 @@ interface Props {
onFinish: (userSolutions: UserSolution[]) => void;
}
const INSTRUCTIONS_AUDIO_SRC =
"https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_recordings%2Fgeneric_intro.mp3?alt=media&token=9b9cfdb8-e90d-40d1-854b-51c4378a5c4b";
export default function Listening({exam, showSolutions = false, onFinish}: Props) {
const [questionIndex, setQuestionIndex] = useState(0);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [exerciseIndex, setExerciseIndex] = useState(showSolutions ? 0 : -1);
const [partIndex, setPartIndex] = useState(0);
const [timesListened, setTimesListened] = useState(0);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(
exam.parts.flatMap((x) => x.exercises).map((x) => defaultUserSolutions(x, exam)),
);
const [showBlankModal, setShowBlankModal] = useState(false);
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
const {partIndex, setPartIndex} = useExamStore((state) => state);
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
useEffect(() => {
if (showSolutions) return setExerciseIndex(-1);
}, [setExerciseIndex, showSolutions]);
// useEffect(() => {
// if (exam.variant !== "partial") setPartIndex(-1);
// }, [exam.variant, setPartIndex]);
useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) {
setExerciseIndex((prev) => prev + 1);
setExerciseIndex(exerciseIndex + 1);
}
}, [hasExamEnded, exerciseIndex]);
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
useEffect(() => {
setCurrentQuestionIndex(0);
@@ -49,18 +59,19 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
};
const nextExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
}
setQuestionIndex((prev) => prev + currentQuestionIndex);
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
setExerciseIndex((prev) => prev + 1);
setExerciseIndex(exerciseIndex + 1);
return;
}
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
setPartIndex((prev) => prev + 1);
setPartIndex(partIndex + 1);
setExerciseIndex(showSolutions ? 0 : -1);
return;
}
@@ -89,11 +100,12 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
};
const previousExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
}
setExerciseIndex((prev) => prev - 1);
setExerciseIndex(exerciseIndex - 1);
};
const getExercise = () => {
@@ -104,6 +116,17 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
};
};
const renderAudioInstructionsPlayer = () => (
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
<div className="flex flex-col w-full gap-2">
<h4 className="text-xl font-semibold">Please listen to the instructions audio attentively.</h4>
</div>
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
<AudioPlayer key={partIndex} src={INSTRUCTIONS_AUDIO_SRC} color="listening" />
</div>
</div>
);
const renderAudioPlayer = () => (
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
<div className="flex flex-col w-full gap-2">
@@ -133,38 +156,53 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
<div className="flex flex-col h-full w-full gap-8 justify-between">
<ModuleTitle
exerciseIndex={
(exam.parts
.flatMap((x) => x.exercises)
.findIndex(
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
) || 0) +
(exerciseIndex === -1 ? 0 : 1) +
questionIndex +
currentQuestionIndex
partIndex === -1
? 0
: (exam.parts
.flatMap((x) => x.exercises)
.findIndex(
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
) || 0) +
(exerciseIndex === -1 ? 0 : 1) +
questionIndex +
currentQuestionIndex
}
minTimer={exam.minTimer}
module="listening"
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
disableTimer={showSolutions}
/>
{renderAudioPlayer()}
{/* Audio Player for the Instructions */}
{partIndex === -1 && renderAudioInstructionsPlayer()}
{/* Part's audio player */}
{partIndex > -1 && renderAudioPlayer()}
{/* Exercise renderer */}
{exerciseIndex > -1 &&
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
{/* Solution renderer */}
{exerciseIndex > -1 &&
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
showSolutions &&
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
</div>
{exerciseIndex === -1 && partIndex > 0 && (
{exerciseIndex === -1 && partIndex > -1 && exam.variant !== "partial" && (
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => {
if (partIndex === 0) return setPartIndex(-1);
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
setPartIndex((prev) => prev - 1);
setPartIndex(partIndex - 1);
}}
className="max-w-[200px] w-full">
Back
@@ -175,7 +213,13 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
</Button>
</div>
)}
{exerciseIndex === -1 && partIndex === 0 && (
{partIndex === -1 && exam.variant !== "partial" && (
<Button color="purple" onClick={() => setPartIndex(0)} className="max-w-[200px] self-end w-full justify-self-end">
Start now
</Button>
)}
{exerciseIndex === -1 && partIndex === 0 && exam.variant === "partial" && (
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end">
Start now
</Button>

View File

@@ -83,15 +83,20 @@ function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: s
export default function Reading({exam, showSolutions = false, onFinish}: Props) {
const [questionIndex, setQuestionIndex] = useState(0);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [exerciseIndex, setExerciseIndex] = useState(showSolutions ? 0 : -1);
const [partIndex, setPartIndex] = useState(0);
const [showTextModal, setShowTextModal] = useState(false);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(
exam.parts.flatMap((x) => x.exercises).map((x) => defaultUserSolutions(x, exam)),
);
const [showBlankModal, setShowBlankModal] = useState(false);
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
const {partIndex, setPartIndex} = useExamStore((state) => state);
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
const setStoreQuestionIndex = useExamStore((state) => state.setQuestionIndex);
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
useEffect(() => {
if (showSolutions) setExerciseIndex(-1);
}, [setExerciseIndex, showSolutions]);
useEffect(() => {
const listener = (e: KeyboardEvent) => {
@@ -113,9 +118,9 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) {
setExerciseIndex((prev) => prev + 1);
setExerciseIndex(exerciseIndex + 1);
}
}, [hasExamEnded, exerciseIndex]);
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
const confirmFinishModule = (keepGoing?: boolean) => {
if (!keepGoing) {
@@ -127,18 +132,20 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
};
const nextExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
}
setQuestionIndex((prev) => prev + currentQuestionIndex);
setStoreQuestionIndex(0);
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
setExerciseIndex((prev) => prev + 1);
setExerciseIndex(exerciseIndex + 1);
return;
}
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
setPartIndex((prev) => prev + 1);
setPartIndex(partIndex + 1);
setExerciseIndex(showSolutions ? 0 : -1);
return;
}
@@ -167,11 +174,13 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
};
const previousExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
}
setStoreQuestionIndex(0);
setExerciseIndex((prev) => prev - 1);
setExerciseIndex(exerciseIndex - 1);
};
const getExercise = () => {
@@ -206,14 +215,17 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
<>
<div className="flex flex-col h-full w-full gap-8">
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
<TextModal {...exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />
{partIndex > -1 && <TextModal {...exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />}
<ModuleTitle
minTimer={exam.minTimer}
exerciseIndex={
(exam.parts
.flatMap((x) => x.exercises)
.findIndex(
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
(x) =>
x.id ===
exam.parts[partIndex > -1 ? partIndex : 0].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]
?.id,
) || 0) +
(exerciseIndex === -1 ? 0 : 1) +
questionIndex +
@@ -225,17 +237,21 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
label={exerciseIndex === -1 ? undefined : convertCamelCaseToReadable(exam.parts[partIndex].exercises[exerciseIndex].type)}
/>
<div className={clsx("mb-20 w-full", exerciseIndex > -1 && "grid grid-cols-2 gap-4")}>
{renderText()}
{partIndex > -1 && renderText()}
{exerciseIndex > -1 &&
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
{exerciseIndex > -1 &&
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
showSolutions &&
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
</div>
{exerciseIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
{exerciseIndex > -1 && partIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
<Button
color="purple"
variant="outline"
@@ -252,7 +268,7 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
variant="outline"
onClick={() => {
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
setPartIndex((prev) => prev - 1);
setPartIndex(partIndex - 1);
}}
className="max-w-[200px] w-full">
Back

View File

@@ -4,7 +4,7 @@ import {Module} from "@/interfaces";
import clsx from "clsx";
import {User} from "@/interfaces/user";
import ProgressBar from "@/components/Low/ProgressBar";
import {BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
import {BsArrowRepeat, BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
import {totalExamsByModule} from "@/utils/stats";
import useStats from "@/hooks/useStats";
import Button from "@/components/Low/Button";
@@ -12,53 +12,78 @@ import {calculateAverageLevel} from "@/utils/score";
import {sortByModuleName} from "@/utils/moduleUtils";
import {capitalize} from "lodash";
import ProfileSummary from "@/components/ProfileSummary";
import {Variant} from "@/interfaces/exam";
import useSessions, {Session} from "@/hooks/useSessions";
import SessionCard from "@/components/Medium/SessionCard";
import useExamStore from "@/stores/examStore";
import moment from "moment";
interface Props {
user: User;
page: "exercises" | "exams";
onStart: (modules: Module[], avoidRepeated: boolean) => void;
onStart: (modules: Module[], avoidRepeated: boolean, variant: Variant) => void;
disableSelection?: boolean;
}
export default function Selection({user, page, onStart, disableSelection = false}: Props) {
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
const [variant, setVariant] = useState<Variant>("full");
const {stats} = useStats(user?.id);
const {sessions, isLoading, reload} = useSessions(user.id);
const state = useExamStore((state) => state);
const toggleModule = (module: Module) => {
const modules = selectedModules.filter((x) => x !== module);
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module]));
};
const loadSession = async (session: Session) => {
state.setSelectedModules(session.selectedModules);
state.setExam(session.exam);
state.setExams(session.exams);
state.setSessionId(session.sessionId);
state.setAssignment(session.assignment);
state.setExerciseIndex(session.exerciseIndex);
state.setPartIndex(session.partIndex);
state.setModuleIndex(session.moduleIndex);
state.setTimeSpent(session.timeSpent);
state.setUserSolutions(session.userSolutions);
state.setShowSolutions(false);
state.setQuestionIndex(session.questionIndex);
};
return (
<>
<div className="w-full h-full relative flex flex-col gap-8 md:gap-16">
<div className="relative flex h-full w-full flex-col gap-8 md:gap-16">
{user && (
<ProfileSummary
user={user}
items={[
{
icon: <BsBook className="text-ielts-reading w-6 h-6 md:w-8 md:h-8" />,
icon: <BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />,
label: "Reading",
value: totalExamsByModule(stats, "reading"),
},
{
icon: <BsHeadphones className="text-ielts-listening w-6 h-6 md:w-8 md:h-8" />,
icon: <BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />,
label: "Listening",
value: totalExamsByModule(stats, "listening"),
},
{
icon: <BsPen className="text-ielts-writing w-6 h-6 md:w-8 md:h-8" />,
icon: <BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />,
label: "Writing",
value: totalExamsByModule(stats, "writing"),
},
{
icon: <BsMegaphone className="text-ielts-speaking w-6 h-6 md:w-8 md:h-8" />,
icon: <BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />,
label: "Speaking",
value: totalExamsByModule(stats, "speaking"),
},
{
icon: <BsClipboard className="text-ielts-level w-6 h-6 md:w-8 md:h-8" />,
icon: <BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />,
label: "Level",
value: totalExamsByModule(stats, "level"),
},
@@ -67,7 +92,7 @@ export default function Selection({user, page, onStart, disableSelection = false
)}
<section className="flex flex-col gap-3">
<span className="font-bold text-lg">About {capitalize(page)}</span>
<span className="text-lg font-bold">About {capitalize(page)}</span>
<span className="text-mti-gray-taupe">
{page === "exercises" && (
<>
@@ -92,133 +117,171 @@ export default function Selection({user, page, onStart, disableSelection = false
)}
</span>
</section>
<section className="w-full flex -lg:flex-col -lg:items-center -lg:gap-12 justify-between gap-8 mt-8">
{sessions.length > 0 && (
<section className="flex flex-col gap-3 md:gap-3">
<div className="flex items-center gap-4">
<div
onClick={reload}
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out">
<span className="text-mti-black text-lg font-bold">Unfinished Sessions</span>
<BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} />
</div>
</div>
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
{sessions
.sort((a, b) => moment(b.date).diff(moment(a.date)))
.map((session) => (
<SessionCard session={session} key={session.sessionId} reload={reload} loadSession={loadSession} />
))}
</span>
</section>
)}
<section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8">
<div
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
className={clsx(
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
selectedModules.includes("reading") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-0 -translate-y-1/2">
<BsBook className="text-white w-7 h-7" />
<div className="bg-ielts-reading absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsBook className="h-7 w-7 text-white" />
</div>
<span className="font-semibold">Reading:</span>
<p className="text-center text-xs">
<p className="text-left text-xs">
Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
</p>
{!selectedModules.includes("reading") && !selectedModules.includes("level") && !disableSelection && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
)}
{(selectedModules.includes("reading") || disableSelection) && (
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
)}
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
</div>
<div
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
className={clsx(
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
selectedModules.includes("listening") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-0 -translate-y-1/2">
<BsHeadphones className="text-white w-7 h-7" />
<div className="bg-ielts-listening absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsHeadphones className="h-7 w-7 text-white" />
</div>
<span className="font-semibold">Listening:</span>
<p className="text-center text-xs">
<p className="text-left text-xs">
Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
</p>
{!selectedModules.includes("listening") && !selectedModules.includes("level") && !disableSelection && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
)}
{(selectedModules.includes("listening") || disableSelection) && (
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
)}
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
</div>
<div
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
className={clsx(
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
selectedModules.includes("writing") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-0 -translate-y-1/2">
<BsPen className="text-white w-7 h-7" />
<div className="bg-ielts-writing absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsPen className="h-7 w-7 text-white" />
</div>
<span className="font-semibold">Writing:</span>
<p className="text-center text-xs">
<p className="text-left text-xs">
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
</p>
{!selectedModules.includes("writing") && !selectedModules.includes("level") && !disableSelection && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
)}
{(selectedModules.includes("writing") || disableSelection) && (
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
)}
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
</div>
<div
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
className={clsx(
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
selectedModules.includes("speaking") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-0 -translate-y-1/2">
<BsMegaphone className="text-white w-7 h-7" />
<div className="bg-ielts-speaking absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsMegaphone className="h-7 w-7 text-white" />
</div>
<span className="font-semibold">Speaking:</span>
<p className="text-center text-xs">
<p className="text-left text-xs">
You&apos;ll have access to interactive dialogs, pronunciation exercises and speech recordings.
</p>
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && !disableSelection && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
)}
{(selectedModules.includes("speaking") || disableSelection) && (
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
)}
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
</div>
{!disableSelection && (
<div
onClick={selectedModules.length === 0 || selectedModules.includes("level") ? () => toggleModule("level") : undefined}
className={clsx(
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
selectedModules.includes("level") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-0 -translate-y-1/2">
<BsClipboard className="text-white w-7 h-7" />
<div className="bg-ielts-level absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsClipboard className="h-7 w-7 text-white" />
</div>
<span className="font-semibold">Level:</span>
<p className="text-center text-xs">You&apos;ll be able to test your english level with multiple choice questions.</p>
<p className="text-left text-xs">You&apos;ll be able to test your english level with multiple choice questions.</p>
{!selectedModules.includes("level") && selectedModules.length === 0 && !disableSelection && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
)}
{(selectedModules.includes("level") || disableSelection) && (
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
)}
{!selectedModules.includes("level") && selectedModules.length > 0 && (
<BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
)}
</div>
)}
</section>
<div className="flex w-full -md:flex-col -md:gap-4 -md:justify-center md:justify-between items-center">
<div
className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer tooltip w-full -md:justify-center"
data-tip="If possible, the platform will choose exams not yet done"
onClick={() => setAvoidRepeatedExams((prev) => !prev)}>
<input type="checkbox" className="hidden" />
<div className="-md:flex-col -md:gap-4 -md:justify-center flex w-full items-center md:justify-between">
<div className="flex w-full flex-col items-center gap-3">
<div
className={clsx(
"w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
"transition duration-300 ease-in-out",
avoidRepeatedExams && "!bg-mti-purple-light ",
)}>
<BsCheck color="white" className="w-full h-full" />
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
onClick={() => setAvoidRepeatedExams((prev) => !prev)}>
<input type="checkbox" className="hidden" />
<div
className={clsx(
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
"transition duration-300 ease-in-out",
avoidRepeatedExams && "!bg-mti-purple-light ",
)}>
<BsCheck color="white" className="h-full w-full" />
</div>
<span className="tooltip" data-tip="If possible, the platform will choose exams not yet done.">
Avoid Repeated Questions
</span>
</div>
<div
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
onClick={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
<input type="checkbox" className="hidden" />
<div
className={clsx(
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
"transition duration-300 ease-in-out",
variant === "full" && "!bg-mti-purple-light ",
)}>
<BsCheck color="white" className="h-full w-full" />
</div>
<span>Full length exams</span>
</div>
<span>Avoid Repeated Questions</span>
</div>
<div className="tooltip w-full" data-tip={`Your screen size is too small to do ${page}`}>
<Button color="purple" className="px-12 w-full max-w-xs md:hidden" disabled>
<Button color="purple" className="w-full max-w-xs px-12 md:hidden" disabled>
Start Exam
</Button>
</div>
@@ -227,10 +290,11 @@ export default function Selection({user, page, onStart, disableSelection = false
onStart(
!disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"],
avoidRepeatedExams,
variant,
)
}
color="purple"
className="px-12 w-full max-w-xs md:self-end -md:hidden"
className="-md:hidden w-full max-w-xs px-12 md:self-end"
disabled={selectedModules.length === 0 && !disableSelection}>
Start Exam
</Button>

View File

@@ -22,22 +22,26 @@ interface Props {
export default function Speaking({exam, showSolutions = false, onFinish}: Props) {
const [questionIndex, setQuestionIndex] = useState(0);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [exerciseIndex, setExerciseIndex] = useState(0);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
useEffect(() => {
setCurrentQuestionIndex(0);
}, [questionIndex]);
const nextExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
}
setQuestionIndex((prev) => prev + currentQuestionIndex);
if (exerciseIndex + 1 < exam.exercises.length) {
setExerciseIndex((prev) => prev + 1);
setExerciseIndex(exerciseIndex + 1);
return;
}
@@ -55,12 +59,13 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
};
const previousExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
}
if (exerciseIndex > 0) {
setExerciseIndex((prev) => prev - 1);
setExerciseIndex(exerciseIndex - 1);
}
};
@@ -86,7 +91,7 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
!showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
showSolutions &&

View File

@@ -19,18 +19,20 @@ interface Props {
}
export default function Writing({exam, showSolutions = false, onFinish}: Props) {
const [exerciseIndex, setExerciseIndex] = useState(0);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
const nextExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
}
if (exerciseIndex + 1 < exam.exercises.length) {
setExerciseIndex((prev) => prev + 1);
setExerciseIndex(exerciseIndex + 1);
return;
}
@@ -48,12 +50,13 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
};
const previousExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
}
if (exerciseIndex > 0) {
setExerciseIndex((prev) => prev - 1);
setExerciseIndex(exerciseIndex - 1);
}
};
@@ -78,7 +81,7 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
!showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise)}
renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
showSolutions &&

View File

@@ -1,24 +1,17 @@
/* eslint-disable jsx-a11y/alt-text */
import React from "react";
import { View, Text, Image } from "@react-pdf/renderer";
import { styles } from "../styles";
import { ModuleScore } from "@/interfaces/module.scores";
import {View, Text, Image} from "@react-pdf/renderer";
import {styles} from "../styles";
import {ModuleScore} from "@/interfaces/module.scores";
export const RadialResult = ({
module,
score,
total,
png,
}: ModuleScore) => (
<View style={[styles.textFont, styles.radialContainer]}>
<Text style={[styles.textColor, styles.textBold, { fontSize: 10 }]}>
{module}
</Text>
<Image src={png} style={styles.image64}></Image>
<View style={[styles.textColor, styles.radialResultContainer]}>
<Text style={styles.textBold}>{score}</Text>
<Text style={{ fontSize: 8 }}>out of {total}</Text>
</View>
</View>
export const RadialResult = ({module, score, total, png}: ModuleScore) => (
<View style={[styles.textFont, styles.radialContainer]}>
<Text style={[styles.textColor, styles.textBold, {fontSize: 10}]}>{module}</Text>
<Image src={png} style={styles.image64}></Image>
<View style={[styles.textColor, styles.radialResultContainer]}>
<Text style={styles.textBold}>{score.toFixed(2)}</Text>
<Text style={{fontSize: 8}}>out of {total}</Text>
</View>
</View>
);

View File

@@ -1,20 +1,20 @@
import React from "react";
import { View, StyleSheet } from "@react-pdf/renderer";
import { ModuleScore } from "@/interfaces/module.scores";
import { RadialResult } from "./radial.result";
import {View, StyleSheet} from "@react-pdf/renderer";
import {ModuleScore} from "@/interfaces/module.scores";
import {RadialResult} from "./radial.result";
interface Props {
testDetails: ModuleScore[];
testDetails: ModuleScore[];
}
const customStyles = StyleSheet.create({
container: { display: "flex", flexDirection: "row", gap: 30 },
container: {display: "flex", flexDirection: "row", gap: 30},
});
export const SkillExamDetails = ({ testDetails }: Props) => (
<View style={customStyles.container}>
{testDetails.map((detail) => {
const { module } = detail;
return <RadialResult key={module} {...detail} />;
})}
</View>
export const SkillExamDetails = ({testDetails}: Props) => (
<View style={customStyles.container}>
{testDetails.map((detail) => {
const {module} = detail;
return <RadialResult key={module} {...detail} />;
})}
</View>
);

View File

@@ -112,11 +112,6 @@ const GroupTestReport = ({
Candidate Information:
</Text>
<View style={styles.textMargin}>
<Text style={defaultTextStyle}>Name: {name}</Text>
<Text style={defaultTextStyle}>ID: {id}</Text>
<Text style={defaultTextStyle}>Email: {email}</Text>
<Text style={defaultTextStyle}>Gender: {gender}</Text>
<Text style={defaultTextStyle}>Passport ID: {passportId}</Text>
<Text style={defaultTextStyle}>
Total Number of Students: {numberOfStudents}
</Text>
@@ -242,10 +237,10 @@ const GroupTestReport = ({
Sr
</Text>
<Text style={customStyles.tableCell}>Candidate Name</Text>
<Text style={customStyles.tableCell}>Email ID</Text>
<Text style={[customStyles.tableCell, { maxWidth: "48px" }]}>
Gender
<Text style={customStyles.tableCell}>
Passport ID
</Text>
<Text style={customStyles.tableCell}>Email ID</Text>
<Text style={[customStyles.tableCell, { maxWidth: "64px" }]}>
Date of test
</Text>
@@ -255,7 +250,19 @@ const GroupTestReport = ({
{showLevel && <Text style={customStyles.tableCell}>Level</Text>}
</View>
{studentsData.map(
({ id, name, email, gender, date, result, level }, index) => (
(
{
id,
name,
email,
gender,
date,
result,
level,
passportId: studentPassportId,
},
index
) => (
<View
style={[
customStyles.tableRow,
@@ -273,10 +280,8 @@ const GroupTestReport = ({
{index + 1}
</Text>
<Text style={customStyles.tableCell}>{name}</Text>
<Text style={customStyles.tableCell}>{studentPassportId}</Text>
<Text style={customStyles.tableCell}>{email}</Text>
<Text style={[customStyles.tableCell, { maxWidth: "48px" }]}>
{gender}
</Text>
<Text style={[customStyles.tableCell, { maxWidth: "64px" }]}>
{date}
</Text>

View File

@@ -2,7 +2,11 @@ import React from "react";
import { styles } from "./styles";
import { View, Text } from "@react-pdf/renderer";
const TestReportFooter = () => (
interface Props {
userId?: string;
}
const TestReportFooter = ({ userId }: Props) => (
<View
style={[
{
@@ -25,10 +29,15 @@ const TestReportFooter = () => (
</Text>
</View>
<View>
<Text style={styles.textBold}>Confidential <Text style={[styles.textFont, styles.textNormal]}>circulated for concern people</Text></Text>
<Text style={styles.textBold}>
Confidential {" "}
<Text style={[styles.textFont, styles.textNormal]}>
circulated for concern people
</Text>
</Text>
</View>
</View>
<View style={{ paddingTop: 10 }}>
<View>
<Text style={styles.textBold}>Declaration</Text>
<Text style={{ paddingTop: 5 }}>
We hereby declare that exam results on our platform, assessed by AI, are
@@ -40,6 +49,22 @@ const TestReportFooter = () => (
continuously enhance our system to ensure accuracy and reliability.
</Text>
</View>
<View style={{ paddingTop: 4 }}>
<Text style={styles.textBold}>
PDF Version:{" "}
<Text style={[styles.textFont, styles.textNormal]}>
{process.env.PDF_VERSION}
</Text>
</Text>
</View>
{userId && (
<View>
<Text style={styles.textBold}>
User ID:{" "}
<Text style={[styles.textFont, styles.textNormal]}>{userId}</Text>
</Text>
</View>
)}
<View style={[styles.textColor, { paddingTop: 5 }]}>
<Text style={styles.textUnderline}>info@encoach.com</Text>
<Text>https://encoach.com</Text>

View File

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

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

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

35
src/hooks/useInvites.tsx Normal file
View File

@@ -0,0 +1,35 @@
import { Invite } from "@/interfaces/invite";
import { Ticket } from "@/interfaces/ticket";
import { Code, Group, User } from "@/interfaces/user";
import axios from "axios";
import { useEffect, useState } from "react";
export default function useInvites({
from,
to,
}: {
from?: string;
to?: string;
}) {
const [invites, setInvites] = useState<Invite[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
const filters: ((i: Invite) => boolean)[] = [];
if (from) filters.push((i: Invite) => i.from === from);
if (to) filters.push((i: Invite) => i.to === to);
setIsLoading(true);
axios
.get<Invite[]>(`/api/invites`)
.then((response) =>
setInvites(filters.reduce((d, f) => d.filter(f), response.data)),
)
.finally(() => setIsLoading(false));
};
useEffect(getData, [to, from]);
return { invites, isLoading, isError, reload: getData };
}

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

@@ -0,0 +1,24 @@
import {Exam} from "@/interfaces/exam";
import {ExamState} from "@/stores/examStore";
import axios from "axios";
import {useEffect, useState} from "react";
export type Session = ExamState & {user: string; id: string; date: string};
export default function useSessions(user?: string) {
const [sessions, setSessions] = useState<Session[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
setIsLoading(true);
axios
.get<Session[]>(`/api/sessions${user ? `?user=${user}` : ""}`)
.then((response) => setSessions(response.data))
.finally(() => setIsLoading(false));
};
useEffect(getData, [user]);
return {sessions, isLoading, isError, reload: getData};
}

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

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

View File

@@ -1,7 +1,8 @@
import {Module} from ".";
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
export type Variant = "diagnostic" | "partial";
export type Variant = "full" | "partial";
export type InstructorGender = "male" | "female" | "varied";
export interface ReadingExam {
parts: ReadingPart[];
@@ -82,6 +83,7 @@ export interface SpeakingExam {
minTimer: number;
isDiagnostic: boolean;
variant?: Variant;
instructorGender: InstructorGender;
}
export type Exercise =
@@ -103,13 +105,24 @@ export interface Evaluation {
interface InteractiveSpeakingEvaluation extends Evaluation {
perfect_answer_1?: string;
transcript_1?: string;
fixed_text_1?: string;
perfect_answer_2?: string;
transcript_2?: string;
fixed_text_2?: string;
perfect_answer_3?: string;
transcript_3?: string;
fixed_text_3?: string;
}
interface SpeakingEvaluation extends CommonEvaluation {
perfect_answer_1?: string;
transcript_1?: string;
fixed_text_1?: string;
}
interface CommonEvaluation extends Evaluation {
perfect_answer?: string;
perfect_answer_1?: string;
fixed_text?: string;
}
@@ -141,7 +154,7 @@ export interface SpeakingExercise {
userSolutions: {
id: string;
solution: string;
evaluation?: CommonEvaluation;
evaluation?: SpeakingEvaluation;
}[];
}
@@ -153,7 +166,7 @@ export interface InteractiveSpeakingExercise {
prompts: {text: string; video_url: string}[];
userSolutions: {
id: string;
solution: {question: string; answer: string}[];
solution: {questionIndex: number; question: string; answer: string}[];
evaluation?: InteractiveSpeakingEvaluation;
}[];
}

5
src/interfaces/invite.ts Normal file
View File

@@ -0,0 +1,5 @@
export interface Invite {
id: string;
from: string;
to: string;
}

View File

@@ -1,22 +1,23 @@
import {Module} from "@/interfaces";
export interface ModuleScore {
score: number;
total: number;
code: Module;
module: Module | 'Overall';
png?: string,
evaluation?: string,
suggestions?: string,
}
score: number;
total: number;
code: Module;
module: Module | "Overall";
png?: string;
evaluation?: string;
suggestions?: string;
}
export interface StudentData {
id: string;
name: string;
email: string;
gender: string;
date: string;
result: string;
level?: string;
bandScore: number;
}
export interface StudentData {
id: string;
name: string;
email: string;
gender: string;
date: string;
result: string;
level?: string;
bandScore: number;
passportId?: string;
}

View File

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

34
src/interfaces/ticket.ts Normal file
View File

@@ -0,0 +1,34 @@
import { Type } from "./user";
export interface Ticket {
id: string;
date: string;
status: TicketStatus;
type: TicketType;
reporter: TicketReporter;
reportedFrom: string;
description: string;
subject: string;
assignedTo?: string;
}
export interface TicketReporter {
id: string;
name: string;
email: string;
type: Type;
}
export type TicketType = "feedback" | "bug" | "help";
export const TicketTypeLabel: { [key in TicketType]: string } = {
feedback: "Feedback",
bug: "Bug",
help: "Help",
};
export type TicketStatus = "submitted" | "in-progress" | "completed";
export const TicketStatusLabel: { [key in TicketStatus]: string } = {
submitted: "Submitted",
"in-progress": "In Progress",
completed: "Completed",
};

View File

@@ -1,4 +1,5 @@
import {Module} from ".";
import {InstructorGender} from "./exam";
export type User = StudentUser | TeacherUser | CorporateUser | AgentUser | AdminUser | DeveloperUser;
@@ -21,6 +22,7 @@ export interface BasicUser {
export interface StudentUser extends BasicUser {
type: "student";
preferredGender?: InstructorGender;
demographicInformation?: DemographicInformation;
}
@@ -48,6 +50,7 @@ export interface AdminUser extends BasicUser {
export interface DeveloperUser extends BasicUser {
type: "developer";
preferredGender?: InstructorGender;
demographicInformation?: DemographicInformation;
}
@@ -127,5 +130,16 @@ export interface Group {
disableEditing?: boolean;
}
export interface Code {
code: string;
creator: string;
expiryDate: Date;
type: Type;
userId?: string;
email?: string;
name?: string;
passport_id?: string;
}
export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent";
export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent"];

View File

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

View File

@@ -1,299 +1,402 @@
import Button from "@/components/Low/Button";
import Checkbox from "@/components/Low/Checkbox";
import Input from "@/components/Low/Input";
import Modal from "@/components/Modal";
import useGroups from "@/hooks/useGroups";
import useUsers from "@/hooks/useUsers";
import {Module} from "@/interfaces";
import {Group, User} from "@/interfaces/user";
import {Disclosure, Transition} from "@headlessui/react";
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
import { Group, User } from "@/interfaces/user";
import {
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import axios from "axios";
import clsx from "clsx";
import {capitalize, uniq, uniqBy} from "lodash";
import {useEffect, useRef, useState} from "react";
import {BsCheck, BsDash, BsPencil, BsPlus, BsQuestionCircleFill, BsTrash} from "react-icons/bs";
import {toast} from "react-toastify";
import { capitalize, uniq } from "lodash";
import { useEffect, useState } from "react";
import { BsPencil, BsQuestionCircleFill, BsTrash } from "react-icons/bs";
import Select from "react-select";
import {uuidv4} from "@firebase/util";
import {useFilePicker} from "use-file-picker";
import Modal from "@/components/Modal";
import { toast } from "react-toastify";
import readXlsxFile from "read-excel-file";
import { useFilePicker } from "use-file-picker";
const columnHelper = createColumnHelper<Group>();
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
const EMAIL_REGEX = new RegExp(
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/,
);
interface CreateDialogProps {
user: User;
users: User[];
group?: Group;
onClose: () => void;
user: User;
users: User[];
group?: Group;
onClose: () => void;
}
const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
const [name, setName] = useState<string | undefined>(group?.name || undefined);
const [admin, setAdmin] = useState<string>(group?.admin || user.id);
const [participants, setParticipants] = useState<string[]>(group?.participants || []);
const {openFilePicker, filesContent, clear} = useFilePicker({
accept: ".xlsx",
multiple: false,
readAs: "ArrayBuffer",
});
const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
const [name, setName] = useState<string | undefined>(
group?.name || undefined,
);
const [admin, setAdmin] = useState<string>(group?.admin || user.id);
const [participants, setParticipants] = useState<string[]>(
group?.participants || [],
);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (filesContent.length > 0) {
const file = filesContent[0];
readXlsxFile(file.content).then((rows) => {
const emails = uniq(
rows
.map((row) => {
const [email] = row as string[];
return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email) ? email.toString().trim() : undefined;
})
.filter((x) => !!x),
);
const { openFilePicker, filesContent, clear } = useFilePicker({
accept: ".xlsx",
multiple: false,
readAs: "ArrayBuffer",
});
if (emails.length === 0) {
toast.error("Please upload an Excel file containing e-mails!");
clear();
return;
}
useEffect(() => {
if (filesContent.length > 0) {
setIsLoading(true);
const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined);
const filteredUsers = emailUsers.filter(
(x) =>
((user.type === "developer" || user.type === "admin" || user.type === "corporate") &&
(x?.type === "student" || x?.type === "teacher")) ||
(user.type === "teacher" && x?.type === "student"),
);
const file = filesContent[0];
readXlsxFile(file.content).then((rows) => {
const emails = uniq(
rows
.map((row) => {
const [email] = row as string[];
return EMAIL_REGEX.test(email) &&
!users.map((u) => u.email).includes(email)
? email.toString().trim()
: undefined;
})
.filter((x) => !!x),
);
setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id));
toast.success(
user.type !== "teacher"
? "Added all teachers and students found in the file you've provided!"
: "Added all students found in the file you've provided!",
{toastId: "upload-success"},
);
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filesContent, user.type, users]);
if (emails.length === 0) {
toast.error("Please upload an Excel file containing e-mails!");
clear();
setIsLoading(false);
return;
}
const submit = () => {
if (name !== group?.name && (name === "Students" || name === "Teachers")) {
toast.error("That group name is reserved and cannot be used, please enter another one.");
return;
}
const emailUsers = [...new Set(emails)]
.map((x) => users.find((y) => y.email.toLowerCase() === x))
.filter((x) => x !== undefined);
const filteredUsers = emailUsers.filter(
(x) =>
((user.type === "developer" ||
user.type === "admin" ||
user.type === "corporate") &&
(x?.type === "student" || x?.type === "teacher")) ||
(user.type === "teacher" && x?.type === "student"),
);
(group ? axios.patch : axios.post)(group ? `/api/groups/${group.id}` : "/api/groups", {name, admin, participants})
.then(() => {
toast.success(`Group "${name}" ${group ? "edited" : "created"} successfully`);
return true;
})
.catch(() => {
toast.error("Something went wrong, please try again later!");
return false;
})
.finally(onClose);
};
setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id));
toast.success(
user.type !== "teacher"
? "Added all teachers and students found in the file you've provided!"
: "Added all students found in the file you've provided!",
{ toastId: "upload-success" },
);
setIsLoading(false);
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filesContent, user.type, users]);
return (
<div className="flex flex-col gap-12 mt-4 w-full px-4 py-2">
<div className="flex flex-col gap-8">
<Input name="name" type="text" label="Name" defaultValue={name} onChange={setName} required disabled={group?.disableEditing} />
<div className="flex flex-col gap-3 w-full">
<div className="flex gap-2 items-center">
<label className="font-normal text-base text-mti-gray-dim">Participants</label>
<div className="tooltip" data-tip="The Excel file should only include a column with the desired e-mails.">
<BsQuestionCircleFill />
</div>
</div>
<div className="flex gap-8 w-full">
<Select
className="w-full"
value={participants.map((x) => ({
value: x,
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
}))}
placeholder="Participants..."
defaultValue={participants.map((x) => ({
value: x,
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
}))}
options={users
.filter((x) => (user.type === "teacher" ? x.type === "student" : x.type === "student" || x.type === "teacher"))
.map((x) => ({value: x.id, label: `${x.email} - ${x.name}`}))}
onChange={(value) => setParticipants(value.map((x) => x.value))}
isMulti
isSearchable
styles={{
control: (styles) => ({
...styles,
backgroundColor: "white",
borderRadius: "999px",
padding: "1rem 1.5rem",
zIndex: "40",
}),
}}
/>
{user.type !== "teacher" && (
<Button className="w-full max-w-[300px]" onClick={openFilePicker} variant="outline">
{filesContent.length === 0 ? "Upload participants Excel file" : filesContent[0].name}
</Button>
)}
</div>
</div>
</div>
<div className="flex w-full justify-end items-center gap-8 mt-8">
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={onClose}>
Cancel
</Button>
<Button className="w-full max-w-[200px]" onClick={submit} disabled={!name}>
Submit
</Button>
</div>
</div>
);
const submit = () => {
setIsLoading(true);
if (name !== group?.name && (name === "Students" || name === "Teachers")) {
toast.error(
"That group name is reserved and cannot be used, please enter another one.",
);
setIsLoading(false);
return;
}
(group ? axios.patch : axios.post)(
group ? `/api/groups/${group.id}` : "/api/groups",
{ name, admin, participants },
)
.then(() => {
toast.success(
`Group "${name}" ${group ? "edited" : "created"} successfully`,
);
return true;
})
.catch(() => {
toast.error("Something went wrong, please try again later!");
return false;
})
.finally(() => {
setIsLoading(false);
onClose();
});
};
return (
<div className="mt-4 flex w-full flex-col gap-12 px-4 py-2">
<div className="flex flex-col gap-8">
<Input
name="name"
type="text"
label="Name"
defaultValue={name}
onChange={setName}
required
disabled={group?.disableEditing}
/>
<div className="flex w-full flex-col gap-3">
<div className="flex items-center gap-2">
<label className="text-mti-gray-dim text-base font-normal">
Participants
</label>
<div
className="tooltip"
data-tip="The Excel file should only include a column with the desired e-mails."
>
<BsQuestionCircleFill />
</div>
</div>
<div className="flex w-full gap-8">
<Select
className="w-full"
value={participants.map((x) => ({
value: x,
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
}))}
placeholder="Participants..."
defaultValue={participants.map((x) => ({
value: x,
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
}))}
options={users
.filter((x) =>
user.type === "teacher"
? x.type === "student"
: x.type === "student" || x.type === "teacher",
)
.map((x) => ({ value: x.id, label: `${x.email} - ${x.name}` }))}
onChange={(value) => setParticipants(value.map((x) => x.value))}
isMulti
isSearchable
menuPortalTarget={document?.body}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
control: (styles) => ({
...styles,
backgroundColor: "white",
borderRadius: "999px",
padding: "1rem 1.5rem",
zIndex: "40",
}),
}}
/>
{user.type !== "teacher" && (
<Button
className="w-full max-w-[300px]"
onClick={openFilePicker}
isLoading={isLoading}
variant="outline"
>
{filesContent.length === 0
? "Upload participants Excel file"
: filesContent[0].name}
</Button>
)}
</div>
</div>
</div>
<div className="mt-8 flex w-full items-center justify-end gap-8">
<Button
variant="outline"
color="red"
className="w-full max-w-[200px]"
isLoading={isLoading}
onClick={onClose}
>
Cancel
</Button>
<Button
className="w-full max-w-[200px]"
onClick={submit}
isLoading={isLoading}
disabled={!name}
>
Submit
</Button>
</div>
</div>
);
};
const filterTypes = ["corporate", "teacher"];
export default function GroupList({user}: {user: User}) {
const [isCreating, setIsCreating] = useState(false);
const [editingGroup, setEditingGroup] = useState<Group>();
const [filterByUser, setFilterByUser] = useState(false);
export default function GroupList({ user }: { user: User }) {
const [isCreating, setIsCreating] = useState(false);
const [editingGroup, setEditingGroup] = useState<Group>();
const [filterByUser, setFilterByUser] = useState(false);
const {users} = useUsers();
const {groups, reload} = useGroups(user && filterTypes.includes(user?.type) ? user.id : undefined);
const { users } = useUsers();
const { groups, reload } = useGroups(
user && filterTypes.includes(user?.type) ? user.id : undefined,
);
useEffect(() => {
if (user && (user.type === "corporate" || user.type === "teacher")) {
setFilterByUser(true);
}
}, [user]);
useEffect(() => {
if (user && (user.type === "corporate" || user.type === "teacher")) {
setFilterByUser(true);
}
}, [user]);
const deleteGroup = (group: Group) => {
if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return;
const deleteGroup = (group: Group) => {
if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return;
axios
.delete<{ok: boolean}>(`/api/groups/${group.id}`)
.then(() => toast.success(`Group "${group.name}" deleted successfully`))
.catch(() => toast.error("Something went wrong, please try again later!"))
.finally(reload);
};
axios
.delete<{ ok: boolean }>(`/api/groups/${group.id}`)
.then(() => toast.success(`Group "${group.name}" deleted successfully`))
.catch(() => toast.error("Something went wrong, please try again later!"))
.finally(reload);
};
const defaultColumns = [
columnHelper.accessor("id", {
header: "ID",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("name", {
header: "Name",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("admin", {
header: "Admin",
cell: (info) => (
<div className="tooltip" data-tip={capitalize(users.find((x) => x.id === info.getValue())?.type)}>
{users.find((x) => x.id === info.getValue())?.name}
</div>
),
}),
columnHelper.accessor("participants", {
header: "Participants",
cell: (info) =>
info
.getValue()
.map((x) => users.find((y) => y.id === x)?.name)
.join(", "),
}),
{
header: "",
id: "actions",
cell: ({row}: {row: {original: Group}}) => {
return (
<>
{user && (user.type === "developer" || user.type === "admin" || user.id === row.original.admin) && (
<div className="flex gap-2">
{!row.original.disableEditing && (
<div data-tip="Edit" className="cursor-pointer tooltip" onClick={() => setEditingGroup(row.original)}>
<BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
)}
{!row.original.disableEditing && (
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteGroup(row.original)}>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
)}
</div>
)}
</>
);
},
},
];
const defaultColumns = [
columnHelper.accessor("id", {
header: "ID",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("name", {
header: "Name",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("admin", {
header: "Admin",
cell: (info) => (
<div
className="tooltip"
data-tip={capitalize(
users.find((x) => x.id === info.getValue())?.type,
)}
>
{users.find((x) => x.id === info.getValue())?.name}
</div>
),
}),
columnHelper.accessor("participants", {
header: "Participants",
cell: (info) =>
info
.getValue()
.map((x) => users.find((y) => y.id === x)?.name)
.join(", "),
}),
{
header: "",
id: "actions",
cell: ({ row }: { row: { original: Group } }) => {
return (
<>
{user &&
(user.type === "developer" ||
user.type === "admin" ||
user.id === row.original.admin) && (
<div className="flex gap-2">
{(!row.original.disableEditing ||
["developer", "admin"].includes(user.type)) && (
<div
data-tip="Edit"
className="tooltip cursor-pointer"
onClick={() => setEditingGroup(row.original)}
>
<BsPencil className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
</div>
)}
{(!row.original.disableEditing ||
["developer", "admin"].includes(user.type)) && (
<div
data-tip="Delete"
className="tooltip cursor-pointer"
onClick={() => deleteGroup(row.original)}
>
<BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
</div>
)}
</div>
)}
</>
);
},
},
];
const table = useReactTable({
data: groups,
columns: defaultColumns,
getCoreRowModel: getCoreRowModel(),
});
const table = useReactTable({
data: groups,
columns: defaultColumns,
getCoreRowModel: getCoreRowModel(),
});
const closeModal = () => {
setIsCreating(false);
setEditingGroup(undefined);
reload();
};
const closeModal = () => {
setIsCreating(false);
setEditingGroup(undefined);
reload();
};
return (
<div className="w-full h-full rounded-xl">
<Modal isOpen={isCreating || !!editingGroup} onClose={closeModal} title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}>
<CreatePanel
group={editingGroup}
user={user}
onClose={closeModal}
users={
user?.type === "corporate" || user?.type === "teacher"
? users.filter(
(u) =>
groups
.filter((g) => g.admin === user.id)
.flatMap((g) => g.participants)
.includes(u.id) || groups.flatMap((g) => g.participants).includes(u.id),
)
: users
}
/>
</Modal>
<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" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
return (
<div className="h-full w-full rounded-xl">
<Modal
isOpen={isCreating || !!editingGroup}
onClose={closeModal}
title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}
>
<CreatePanel
group={editingGroup}
user={user}
onClose={closeModal}
users={
user?.type === "corporate" || user?.type === "teacher"
? users.filter(
(u) =>
groups
.filter((g) => g.admin === user.id)
.flatMap((g) => g.participants)
.includes(u.id) ||
groups.flatMap((g) => g.participants).includes(u.id),
)
: users
}
/>
</Modal>
<table className="bg-mti-purple-ultralight/40 w-full rounded-xl">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="py-4" key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr
className="even:bg-mti-purple-ultralight/40 rounded-lg py-2 odd:bg-white"
key={row.id}
>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
<button
onClick={() => setIsCreating(true)}
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white">
New Group
</button>
</div>
);
<button
onClick={() => setIsCreating(true)}
className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out"
>
New Group
</button>
</div>
);
}

View File

@@ -71,7 +71,9 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
defaultValue={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
onChange={(value) => setCurrency(value?.value || "EUR")}
value={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
menuPortalTarget={document?.body}
styles={{
menuPortal: (base) => ({...base, zIndex: 9999}),
control: (styles) => ({
...styles,
paddingLeft: "4px",
@@ -105,7 +107,9 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
defaultValue={{value: "months", label: "Months"}}
onChange={(value) => setUnit((value?.value as DurationUnit) || "months")}
value={{value: unit, label: capitalize(unit)}}
menuPortalTarget={document?.body}
styles={{
menuPortal: (base) => ({...base, zIndex: 9999}),
control: (styles) => ({
...styles,
paddingLeft: "4px",

View File

@@ -1,60 +1,134 @@
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import {useEffect, useState} from "react";
import {Module} from "@/interfaces";
import {useEffect, useState} from "react";
import Selection from "@/exams/Selection";
import Reading from "@/exams/Reading";
import {Exam, InteractiveSpeakingExercise, SpeakingExercise, UserSolution, WritingExercise} from "@/interfaces/exam";
import Listening from "@/exams/Listening";
import Writing from "@/exams/Writing";
import {ToastContainer, toast} from "react-toastify";
import Finish from "@/exams/Finish";
import axios from "axios";
import {Stat} from "@/interfaces/user";
import Speaking from "@/exams/Speaking";
import {v4 as uuidv4} from "uuid";
import useUser from "@/hooks/useUser";
import useExamStore from "@/stores/examStore";
import Layout from "@/components/High/Layout";
import AbandonPopup from "@/components/AbandonPopup";
import {evaluateSpeakingAnswer, evaluateWritingAnswer} from "@/utils/evaluation";
import {useRouter} from "next/router";
import {getExam} from "@/utils/exams";
import {capitalize} from "lodash";
import Layout from "@/components/High/Layout";
import Finish from "@/exams/Finish";
import Level from "@/exams/Level";
import Listening from "@/exams/Listening";
import Reading from "@/exams/Reading";
import Selection from "@/exams/Selection";
import Speaking from "@/exams/Speaking";
import Writing from "@/exams/Writing";
import useUser from "@/hooks/useUser";
import {Exam, UserSolution, Variant} from "@/interfaces/exam";
import {Stat} from "@/interfaces/user";
import useExamStore from "@/stores/examStore";
import {evaluateSpeakingAnswer, evaluateWritingAnswer} from "@/utils/evaluation";
import {defaultExamUserSolutions, getExam} from "@/utils/exams";
import axios from "axios";
import {useRouter} from "next/router";
import {toast, ToastContainer} from "react-toastify";
import {v4 as uuidv4} from "uuid";
import useSessions from "@/hooks/useSessions";
import ShortUniqueId from "short-unique-id";
interface Props {
page: "exams" | "exercises";
}
export default function ExamPage({page}: Props) {
const [hasBeenUploaded, setHasBeenUploaded] = useState(false);
const [moduleIndex, setModuleIndex] = useState(0);
const [sessionId, setSessionId] = useState("");
const [exam, setExam] = useState<Exam>();
const [isEvaluationLoading, setIsEvaluationLoading] = useState(false);
const [showAbandonPopup, setShowAbandonPopup] = useState(false);
const [variant, setVariant] = useState<Variant>("full");
const [avoidRepeated, setAvoidRepeated] = useState(false);
const [timeSpent, setTimeSpent] = useState(0);
const [hasBeenUploaded, setHasBeenUploaded] = useState(false);
const [showAbandonPopup, setShowAbandonPopup] = useState(false);
const [isEvaluationLoading, setIsEvaluationLoading] = useState(false);
const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<string[]>([]);
const [timeSpent, setTimeSpent] = useState(0);
const [exams, setExams] = useExamStore((state) => [state.exams, state.setExams]);
const [userSolutions, setUserSolutions] = useExamStore((state) => [state.userSolutions, state.setUserSolutions]);
const [showSolutions, setShowSolutions] = useExamStore((state) => [state.showSolutions, state.setShowSolutions]);
const [selectedModules, setSelectedModules] = useExamStore((state) => [state.selectedModules, state.setSelectedModules]);
const resetStore = useExamStore((state) => state.reset);
const assignment = useExamStore((state) => state.assignment);
const initialTimeSpent = useExamStore((state) => state.timeSpent);
const {exam, setExam} = useExamStore((state) => state);
const {exams, setExams} = useExamStore((state) => state);
const {sessionId, setSessionId} = useExamStore((state) => state);
const {partIndex, setPartIndex} = useExamStore((state) => state);
const {moduleIndex, setModuleIndex} = useExamStore((state) => state);
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
const {showSolutions, setShowSolutions} = useExamStore((state) => state);
const {selectedModules, setSelectedModules} = useExamStore((state) => state);
const {user} = useUser({redirectTo: "/login"});
const router = useRouter();
useEffect(() => setSessionId(uuidv4()), []);
const reset = () => {
resetStore();
setVariant("full");
setAvoidRepeated(false);
setHasBeenUploaded(false);
setShowAbandonPopup(false);
setIsEvaluationLoading(false);
setStatsAwaitingEvaluation([]);
setTimeSpent(0);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
const saveSession = async () => {
console.log("Saving your session...");
await axios.post("/api/sessions", {
id: sessionId,
sessionId,
date: new Date().toISOString(),
userSolutions,
moduleIndex,
selectedModules,
assignment,
timeSpent,
exams,
exam,
partIndex,
exerciseIndex,
questionIndex,
user: user?.id,
});
};
useEffect(() => setTimeSpent((prev) => prev + initialTimeSpent), [initialTimeSpent]);
useEffect(() => {
if (userSolutions.length === 0 && exams.length > 0) {
const defaultSolutions = exams.map(defaultExamUserSolutions).flat();
setUserSolutions(defaultSolutions);
}
}, [exams, setUserSolutions, userSolutions]);
useEffect(() => {
if (
sessionId.length > 0 &&
userSolutions.length > 0 &&
selectedModules.length > 0 &&
exams.length > 0 &&
!!exam &&
timeSpent > 0 &&
!showSolutions &&
moduleIndex < selectedModules.length
)
saveSession();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exam, exams, moduleIndex, selectedModules, sessionId, userSolutions, user, exerciseIndex, partIndex, questionIndex]);
useEffect(() => {
if (timeSpent % 20 === 0 && timeSpent > 0 && moduleIndex < selectedModules.length && !showSolutions) saveSession();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeSpent]);
useEffect(() => {
if (selectedModules.length > 0 && sessionId.length === 0) {
const shortUID = new ShortUniqueId();
setSessionId(shortUID.randomUUID(8));
}
}, [setSessionId, selectedModules, sessionId]);
useEffect(() => {
if (user?.type === "developer") console.log(exam);
}, [exam, user]);
useEffect(() => {
selectedModules.length > 0 && timeSpent === 0 && !showSolutions;
if (selectedModules.length > 0 && timeSpent === 0 && !showSolutions) {
const timerInterval = setInterval(() => {
setTimeSpent((prev) => prev + 1);
@@ -69,12 +143,15 @@ export default function ExamPage({page}: Props) {
useEffect(() => {
if (showSolutions) setModuleIndex(-1);
}, [showSolutions]);
}, [setModuleIndex, showSolutions]);
useEffect(() => {
(async () => {
if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) {
const nextExam = exams[moduleIndex];
if (partIndex === -1 && nextExam.module !== "listening") setPartIndex(0);
if (exerciseIndex === -1 && !["reading", "listening"].includes(nextExam.module)) setExerciseIndex(0);
setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined);
}
})();
@@ -84,7 +161,14 @@ export default function ExamPage({page}: Props) {
useEffect(() => {
(async () => {
if (selectedModules.length > 0 && exams.length === 0) {
const examPromises = selectedModules.map((module) => getExam(module, avoidRepeated));
const examPromises = selectedModules.map((module) =>
getExam(
module,
avoidRepeated,
variant,
user?.type === "student" || user?.type === "developer" ? user.preferredGender : undefined,
),
);
Promise.all(examPromises).then((values) => {
if (values.every((x) => !!x)) {
setExams(values.map((x) => x!));
@@ -105,8 +189,8 @@ export default function ExamPage({page}: Props) {
id: solution.id || uuidv4(),
timeSpent,
session: sessionId,
exam: solution.exam!,
module: solution.module!,
exam: exam!.id,
module: exam!.module,
user: user?.id || "",
date: new Date().getTime(),
...(assignment ? {assignment: assignment.id} : {}),
@@ -121,37 +205,41 @@ export default function ExamPage({page}: Props) {
}, [selectedModules, moduleIndex, hasBeenUploaded]);
useEffect(() => {
if (statsAwaitingEvaluation.length === 0) return setIsEvaluationLoading(false);
return setIsEvaluationLoading(true);
setIsEvaluationLoading(statsAwaitingEvaluation.length !== 0);
}, [statsAwaitingEvaluation]);
useEffect(() => {
if (statsAwaitingEvaluation.length > 0) {
statsAwaitingEvaluation.forEach(checkIfStatHasBeenEvaluated);
checkIfStatsHaveBeenEvaluated(statsAwaitingEvaluation);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [statsAwaitingEvaluation]);
const checkIfStatHasBeenEvaluated = (id: string) => {
const checkIfStatsHaveBeenEvaluated = (ids: string[]) => {
setTimeout(async () => {
const statRequest = await axios.get<Stat>(`/api/stats/${id}`);
const stat = statRequest.data;
if (stat.solutions.every((x) => x.evaluation !== null)) {
const userSolution: UserSolution = {
id,
const awaitedStats = await Promise.all(ids.map(async (id) => (await axios.get<Stat>(`/api/stats/${id}`)).data));
const solutionsEvaluated = awaitedStats.every((stat) => stat.solutions.every((x) => x.evaluation !== null));
if (solutionsEvaluated) {
const statsUserSolutions: UserSolution[] = awaitedStats.map((stat) => ({
id: stat.id,
exercise: stat.exercise,
score: stat.score,
solutions: stat.solutions,
type: stat.type,
exam: stat.exam,
module: stat.module,
};
}));
setUserSolutions(userSolutions.map((x) => (x.exercise === userSolution.exercise ? userSolution : x)));
return setStatsAwaitingEvaluation((prev) => prev.filter((x) => x !== id));
const updatedUserSolutions = userSolutions.map((x) => {
const respectiveSolution = statsUserSolutions.find((y) => y.exercise === x.exercise);
return respectiveSolution ? respectiveSolution : x;
});
setUserSolutions(updatedUserSolutions);
return setStatsAwaitingEvaluation((prev) => prev.filter((x) => !ids.includes(x)));
}
return checkIfStatHasBeenEvaluated(id);
return checkIfStatsHaveBeenEvaluated(ids);
}, 5 * 1000);
};
@@ -159,13 +247,21 @@ export default function ExamPage({page}: Props) {
if (exam.module === "reading" || exam.module === "listening") {
const parts = exam.parts.map((p) =>
Object.assign(p, {
exercises: p.exercises.map((x) => Object.assign(x, {userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions})),
exercises: p.exercises.map((x) =>
Object.assign(x, {
userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions,
}),
),
}),
);
return Object.assign(exam, {parts});
}
const exercises = exam.exercises.map((x) => Object.assign(x, {userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions}));
const exercises = exam.exercises.map((x) =>
Object.assign(x, {
userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions,
}),
);
return Object.assign(exam, {exercises});
};
@@ -201,11 +297,17 @@ export default function ExamPage({page}: Props) {
axios.get("/api/stats/update");
setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...solutions]);
setModuleIndex((prev) => prev + 1);
setModuleIndex(moduleIndex + 1);
setPartIndex(-1);
setExerciseIndex(-1);
setQuestionIndex(0);
};
const aggregateScoresByModule = (answers: UserSolution[]): {module: Module; total: number; missing: number; correct: number}[] => {
const scores: {[key in Module]: {total: number; missing: number; correct: number}} = {
const scores: {
[key in Module]: {total: number; missing: number; correct: number};
} = {
reading: {
total: 0,
correct: 0,
@@ -234,6 +336,8 @@ export default function ExamPage({page}: Props) {
};
answers.forEach((x) => {
console.log({x});
scores[x.module!] = {
total: scores[x.module!].total + x.score.total,
correct: scores[x.module!].correct + x.score.correct,
@@ -253,10 +357,11 @@ export default function ExamPage({page}: Props) {
page={page}
user={user!}
disableSelection={page === "exams"}
onStart={(modules, avoid) => {
onStart={(modules: Module[], avoid: boolean, variant: Variant) => {
setModuleIndex(0);
setAvoidRepeated(avoid);
setSelectedModules(modules);
setVariant(variant);
}}
/>
);
@@ -271,6 +376,8 @@ export default function ExamPage({page}: Props) {
onViewResults={() => {
setShowSolutions(true);
setModuleIndex(0);
setExerciseIndex(["reading", "listening"].includes(exams[0].module) ? -1 : 0);
setPartIndex(exams[0].module === "listening" ? -1 : 0);
setExam(exams[0]);
}}
scores={aggregateScoresByModule(userSolutions)}
@@ -318,7 +425,9 @@ export default function ExamPage({page}: Props) {
abandonPopupTitle="Leave Exercise"
abandonPopupDescription="Are you sure you want to leave the exercise? You will lose all your progress."
abandonConfirmButtonText="Confirm"
onAbandon={() => router.reload()}
onAbandon={() => {
reset();
}}
onCancel={() => setShowAbandonPopup(false)}
/>
)}

View File

@@ -19,8 +19,8 @@ const TaskTab = ({exam, setExam}: {exam?: LevelExam; setExam: (exam: LevelExam)
axios
.get(`/api/exam/level/generate/level`)
.then((result) => {
playSound("check");
console.log(result.data);
playSound(typeof result.data === "string" ? "error" : "check");
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
setExam(result.data);
})
.catch((error) => {
@@ -77,10 +77,20 @@ const TaskTab = ({exam, setExam}: {exam?: LevelExam; setExam: (exam: LevelExam)
<span className="font-semibold">
{question.id}. {question.prompt}
</span>
<span>
<span className="font-semibold text-ielts-level">({question.solution})</span>{" "}
{question.options.find((o) => o.id === question.solution)?.text}
</span>
<div className="flex flex-col gap-1">
{question.options.map((option) => (
<span key={option.id} className={clsx(question.solution === option.id && "font-bold")}>
<span
className={clsx(
"font-semibold",
question.solution === option.id ? "text-mti-green-light" : "text-ielts-level",
)}>
({option.id})
</span>{" "}
{option.text}
</span>
))}
</div>
</div>
))}
</div>

View File

@@ -8,8 +8,8 @@ import {Tab} from "@headlessui/react";
import axios from "axios";
import clsx from "clsx";
import {useRouter} from "next/router";
import {useState} from "react";
import {BsArrowRepeat} from "react-icons/bs";
import {useEffect, useState} from "react";
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
import {toast} from "react-toastify";
const PartTab = ({part, types, index, setPart}: {part?: ListeningPart; types: string[]; index: number; setPart: (part?: ListeningPart) => void}) => {
@@ -26,7 +26,8 @@ const PartTab = ({part, types, index, setPart}: {part?: ListeningPart; types: st
axios
.get(`/api/exam/listening/generate/listening_section_${index}${topic || types ? `?${url.toString()}` : ""}`)
.then((result) => {
playSound("check");
playSound(typeof result.data === "string" ? "error" : "check");
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
setPart(result.data);
})
.catch((error) => {
@@ -42,7 +43,7 @@ const PartTab = ({part, types, index, setPart}: {part?: ListeningPart; types: st
<Input type="text" placeholder="Grand Canyon..." name="topic" label="Topic" onChange={setTopic} roundness="xl" defaultValue={topic} />
<button
onClick={generate}
disabled={isLoading}
disabled={isLoading || types.length === 0}
data-tip="The passage is currently being generated"
className={clsx(
"bg-ielts-listening/70 border border-ielts-listening text-white w-full max-w-[200px] rounded-xl h-[70px]",
@@ -110,10 +111,21 @@ const ListeningGeneration = () => {
const [part2, setPart2] = useState<ListeningPart>();
const [part3, setPart3] = useState<ListeningPart>();
const [part4, setPart4] = useState<ListeningPart>();
const [minTimer, setMinTimer] = useState(30);
const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<ListeningExam>();
const [types, setTypes] = useState<string[]>([]);
useEffect(() => {
const part1Timer = part1 ? 5 : 0;
const part2Timer = part2 ? 8 : 0;
const part3Timer = part3 ? 8 : 0;
const part4Timer = part4 ? 9 : 0;
const sum = part1Timer + part2Timer + part3Timer + part4Timer;
setMinTimer(sum > 0 ? sum : 5);
}, [part1, part2, part3, part4]);
const availableTypes = [
{type: "multipleChoice", label: "Multiple Choice"},
{type: "writeBlanksQuestions", label: "Write the Blanks: Questions"},
@@ -129,12 +141,14 @@ const ListeningGeneration = () => {
const toggleType = (type: string) => setTypes((prev) => (prev.includes(type) ? [...prev.filter((x) => x !== type)] : [...prev, type]));
const submitExam = () => {
if (!part1 || !part2 || !part3 || !part4) return toast.error("Please generate all for sections!");
const parts = [part1, part2, part3, part4].filter((x) => !!x);
console.log({parts});
if (parts.length === 0) return toast.error("Please generate at least one section!");
setIsLoading(true);
axios
.post(`/api/exam/listening/generate/listening`, {parts: [part1, part2, part3, part4]})
.post(`/api/exam/listening/generate/listening`, {parts, minTimer})
.then((result) => {
playSound("sent");
console.log(`Generated Exam ID: ${result.data.id}`);
@@ -172,6 +186,17 @@ const ListeningGeneration = () => {
return (
<>
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
<Input
type="number"
name="minTimer"
onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))}
value={minTimer}
className="max-w-[300px]"
/>
</div>
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Exercises</label>
<div className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
@@ -197,46 +222,46 @@ const ListeningGeneration = () => {
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70",
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70 flex gap-2 items-center justify-center",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-listening focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-listening",
)
}>
Section 1
Section 1 {part1 && <BsCheck />}
</Tab>
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70",
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70 flex gap-2 items-center justify-center",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-listening focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-listening",
)
}>
Section 2
Section 2 {part2 && <BsCheck />}
</Tab>
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70",
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70 flex gap-2 items-center justify-center",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-listening focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-listening",
)
}>
Section 3
Section 3 {part3 && <BsCheck />}
</Tab>
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70",
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70 flex gap-2 items-center justify-center",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-listening focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-listening",
)
}>
Section 4
Section 4 {part4 && <BsCheck />}
</Tab>
</Tab.List>
<Tab.Panels>
@@ -264,14 +289,14 @@ const ListeningGeneration = () => {
</button>
)}
<button
disabled={!part1 || !part2 || !part3 || !part4 || isLoading}
disabled={(!part1 && !part2 && !part3 && !part4) || isLoading}
data-tip="Please generate all three passages"
onClick={submitExam}
className={clsx(
"bg-ielts-listening/70 border border-ielts-listening text-white w-full max-w-[200px] rounded-xl h-[70px] self-end",
"hover:bg-ielts-listening disabled:bg-ielts-listening/40 disabled:cursor-not-allowed",
"transition ease-in-out duration-300",
(!part1 || !part2 || !part3 || !part4) && "tooltip",
!part1 && !part2 && !part3 && !part4 && "tooltip",
)}>
{isLoading ? (
<div className="flex items-center justify-center">

View File

@@ -8,8 +8,8 @@ import {Tab} from "@headlessui/react";
import axios from "axios";
import clsx from "clsx";
import {useRouter} from "next/router";
import {useState} from "react";
import {BsArrowRepeat} from "react-icons/bs";
import {useEffect, useState} from "react";
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
import {toast} from "react-toastify";
import {v4} from "uuid";
@@ -27,7 +27,8 @@ const PartTab = ({part, types, index, setPart}: {part?: ReadingPart; types: stri
axios
.get(`/api/exam/reading/generate/reading_passage_${index}${topic || types ? `?${url.toString()}` : ""}`)
.then((result) => {
playSound("check");
playSound(typeof result.data === "string" ? "error" : "check");
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
setPart(result.data);
})
.catch((error) => {
@@ -43,7 +44,7 @@ const PartTab = ({part, types, index, setPart}: {part?: ReadingPart; types: stri
<Input type="text" placeholder="Grand Canyon..." name="topic" label="Topic" onChange={setTopic} roundness="xl" defaultValue={topic} />
<button
onClick={generate}
disabled={isLoading}
disabled={isLoading || types.length === 0}
data-tip="The passage is currently being generated"
className={clsx(
"bg-ielts-reading/70 border border-ielts-reading text-white w-full max-w-[200px] rounded-xl h-[70px]",
@@ -87,10 +88,16 @@ const ReadingGeneration = () => {
const [part1, setPart1] = useState<ReadingPart>();
const [part2, setPart2] = useState<ReadingPart>();
const [part3, setPart3] = useState<ReadingPart>();
const [minTimer, setMinTimer] = useState(60);
const [types, setTypes] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<ReadingExam>();
useEffect(() => {
const parts = [part1, part2, part3].filter((x) => !!x);
setMinTimer(parts.length === 0 ? 60 : parts.length * 20);
}, [part1, part2, part3]);
const router = useRouter();
const setExams = useExamStore((state) => state.setExams);
@@ -122,19 +129,21 @@ const ReadingGeneration = () => {
};
const submitExam = () => {
if (!part1 || !part2 || !part3) {
toast.error("Please generate all three passages before submitting");
const parts = [part1, part2, part3].filter((x) => !!x) as ReadingPart[];
if (parts.length === 0) {
toast.error("Please generate at least one passage before submitting");
return;
}
setIsLoading(true);
const exam: ReadingExam = {
parts: [part1, part2, part3],
parts,
isDiagnostic: false,
minTimer: 60,
minTimer,
module: "reading",
id: v4(),
type: "academic",
variant: parts.length === 3 ? "full" : "partial",
};
axios
@@ -148,6 +157,7 @@ const ReadingGeneration = () => {
setPart1(undefined);
setPart2(undefined);
setPart3(undefined);
setMinTimer(60);
setTypes([]);
})
.catch((error) => {
@@ -159,6 +169,17 @@ const ReadingGeneration = () => {
return (
<>
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
<Input
type="number"
name="minTimer"
onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))}
value={minTimer}
className="max-w-[300px]"
/>
</div>
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Exercises</label>
<div className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
@@ -182,35 +203,35 @@ const ReadingGeneration = () => {
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-reading/70",
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-reading/70 flex gap-2 items-center justify-center",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-reading focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-reading",
)
}>
Passage 1
Passage 1 {part1 && <BsCheck />}
</Tab>
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-reading/70",
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-reading/70 flex gap-2 items-center justify-center",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-reading focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-reading",
)
}>
Passage 2
Passage 2 {part2 && <BsCheck />}
</Tab>
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-reading/70",
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-reading/70 flex gap-2 items-center justify-center",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-reading focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-reading",
)
}>
Passage 3
Passage 3 {part3 && <BsCheck />}
</Tab>
</Tab.List>
<Tab.Panels>
@@ -237,14 +258,14 @@ const ReadingGeneration = () => {
</button>
)}
<button
disabled={!part1 || !part2 || !part3 || isLoading}
disabled={(!part1 && !part2 && !part3) || isLoading}
data-tip="Please generate all three passages"
onClick={submitExam}
className={clsx(
"bg-ielts-reading/70 border border-ielts-reading text-white w-full max-w-[200px] rounded-xl h-[70px] self-end",
"hover:bg-ielts-reading disabled:bg-ielts-reading/40 disabled:cursor-not-allowed",
"transition ease-in-out duration-300",
(!part1 || !part2 || !part3) && "tooltip",
!part1 && !part2 && !part3 && "tooltip",
)}>
{isLoading ? (
<div className="flex items-center justify-center">

View File

@@ -1,5 +1,7 @@
import Input from "@/components/Low/Input";
import {Exercise, SpeakingExam} from "@/interfaces/exam";
import Select from "@/components/Low/Select";
import {Exercise, InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise} from "@/interfaces/exam";
import {AVATARS} from "@/resources/speakingAvatars";
import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams";
import {playSound} from "@/utils/sound";
@@ -7,21 +9,27 @@ import {convertCamelCaseToReadable} from "@/utils/string";
import {Tab} from "@headlessui/react";
import axios from "axios";
import clsx from "clsx";
import {capitalize, sample, uniq} from "lodash";
import moment from "moment";
import {useRouter} from "next/router";
import {useState} from "react";
import {BsArrowRepeat} from "react-icons/bs";
import {useEffect, useState} from "react";
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
import {toast} from "react-toastify";
import {v4} from "uuid";
const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; setPart: (part?: SpeakingPart) => void}) => {
const [gender, setGender] = useState<"male" | "female">("male");
const [isLoading, setIsLoading] = useState(false);
const generate = () => {
setPart(undefined);
setIsLoading(true);
axios
.get(`/api/exam/speaking/generate/speaking_task_${index}`)
.then((result) => {
playSound("check");
playSound(typeof result.data === "string" ? "error" : "check");
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
setPart(result.data);
})
.catch((error) => {
@@ -31,8 +39,45 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
.finally(() => setIsLoading(false));
};
const generateVideo = () => {
if (!part) return toast.error("Please generate the first part before generating the video!");
toast.info("This will take quite a while, please do not leave this page or close the tab/window.");
const avatar = sample(AVATARS.filter((x) => x.gender === gender));
setIsLoading(true);
const initialTime = moment();
axios
.post(`/api/exam/speaking/generate/speaking/generate_${index === 3 ? "interactive" : "speaking"}_video`, {...part, avatar: avatar?.id})
.then((result) => {
const isError = typeof result.data === "string" || moment().diff(initialTime, "seconds") < 60;
playSound(isError ? "error" : "check");
if (isError) return toast.error("Something went wrong, please try to generate the video again.");
setPart({...part, result: result.data, gender, avatar});
})
.catch((e) => {
toast.error("Something went wrong!");
console.log(e);
})
.finally(() => setIsLoading(false));
};
return (
<Tab.Panel className="w-full bg-ielts-speaking/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Gender</label>
<Select
options={[
{value: "male", label: "Male"},
{value: "female", label: "Female"},
]}
value={{value: gender, label: capitalize(gender)}}
onChange={(value) => (value ? setGender(value.value as typeof gender) : null)}
disabled={isLoading}
/>
</div>
<div className="flex gap-4 items-end">
<button
onClick={generate}
@@ -52,6 +97,24 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
"Generate"
)}
</button>
<button
onClick={generateVideo}
disabled={isLoading || !part}
data-tip="The passage is currently being generated"
className={clsx(
"bg-ielts-speaking/70 border border-ielts-speaking text-white w-full rounded-xl h-[70px]",
"hover:bg-ielts-speaking disabled:bg-ielts-speaking/40 disabled:cursor-not-allowed",
"transition ease-in-out duration-300",
isLoading && "tooltip",
)}>
{isLoading ? (
<div className="flex items-center justify-center">
<BsArrowRepeat className="text-white animate-spin" size={25} />
</div>
) : (
"Generate Video"
)}
</button>
</div>
{isLoading && (
<div className="w-fit h-fit mt-12 self-center animate-pulse flex flex-col gap-8 items-center">
@@ -59,7 +122,7 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
<span className={clsx("font-bold text-2xl text-ielts-speaking")}>Generating...</span>
</div>
)}
{part && (
{part && !isLoading && (
<div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide h-96">
<h3 className="text-xl font-semibold">{part.topic}</h3>
{part.question && <span className="w-full">{part.question}</span>}
@@ -82,6 +145,12 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
))}
</div>
)}
{part.result && <span className="font-bold mt-4">Video Generated: </span>}
{part.avatar && part.gender && (
<span>
<b>Instructor:</b> {part.avatar.name} - {capitalize(part.avatar.gender)}
</span>
)}
</div>
)}
</Tab.Panel>
@@ -93,27 +162,48 @@ interface SpeakingPart {
question?: string;
questions?: string[];
topic: string;
result?: SpeakingExercise | InteractiveSpeakingExercise;
gender?: "male" | "female";
avatar?: (typeof AVATARS)[number];
}
const SpeakingGeneration = () => {
const [part1, setPart1] = useState<SpeakingPart>();
const [part2, setPart2] = useState<SpeakingPart>();
const [part3, setPart3] = useState<SpeakingPart>();
const [minTimer, setMinTimer] = useState(14);
const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<SpeakingExam>();
useEffect(() => {
const parts = [part1, part2, part3].filter((x) => !!x);
setMinTimer(parts.length === 0 ? 5 : parts.length * 5);
}, [part1, part2, part3]);
const router = useRouter();
const setExams = useExamStore((state) => state.setExams);
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const submitExam = () => {
if (!part1 || !part2 || !part3) return toast.error("Please generate all for tasks!");
if (!part1?.result && !part2?.result && !part3?.result) return toast.error("Please generate at least one task!");
setIsLoading(true);
const genders = [part1?.gender, part2?.gender, part3?.gender].filter((x) => !!x);
const exam: SpeakingExam = {
id: v4(),
isDiagnostic: false,
exercises: [part1?.result, part2?.result, part3?.result].filter((x) => !!x) as (SpeakingExercise | InteractiveSpeakingExercise)[],
minTimer,
variant: minTimer >= 14 ? "full" : "partial",
module: "speaking",
instructorGender: genders.every((x) => x === "male") ? "male" : genders.every((x) => x === "female") ? "female" : "varied",
};
axios
.post(`/api/exam/speaking/generate/speaking`, {exercises: [part1, part2, part3]})
.post(`/api/exam/speaking`, exam)
.then((result) => {
playSound("sent");
console.log(`Generated Exam ID: ${result.data.id}`);
@@ -123,10 +213,11 @@ const SpeakingGeneration = () => {
setPart1(undefined);
setPart2(undefined);
setPart3(undefined);
setMinTimer(14);
})
.catch((error) => {
console.log(error);
toast.error("Something went wrong!");
toast.error("Something went wrong while generating, please try again later.");
})
.finally(() => setIsLoading(false));
};
@@ -149,40 +240,51 @@ const SpeakingGeneration = () => {
return (
<>
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
<Input
type="number"
name="minTimer"
onChange={(e) => setMinTimer(parseInt(e) < 5 ? 5 : parseInt(e))}
value={minTimer}
className="max-w-[300px]"
/>
</div>
<Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/70",
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/70 flex gap-2 items-center justify-center",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
)
}>
Task 1
Exercise 1 {part1 && part1.result && <BsCheck />}
</Tab>
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/70",
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/70 flex gap-2 items-center justify-center",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
)
}>
Task 2
Exercise 2 {part2 && part2.result && <BsCheck />}
</Tab>
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/70",
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/70 flex gap-2 items-center justify-center",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
)
}>
Task 3
Interactive {part3 && part3.result && <BsCheck />}
</Tab>
</Tab.List>
<Tab.Panels>
@@ -209,14 +311,14 @@ const SpeakingGeneration = () => {
</button>
)}
<button
disabled={!part1 || !part2 || !part3 || isLoading}
disabled={(!part1?.result && !part2?.result && !part3?.result) || isLoading}
data-tip="Please generate all three passages"
onClick={submitExam}
className={clsx(
"bg-ielts-speaking/70 border border-ielts-speaking text-white w-full max-w-[200px] rounded-xl h-[70px] self-end",
"hover:bg-ielts-speaking disabled:bg-ielts-speaking/40 disabled:cursor-not-allowed",
"transition ease-in-out duration-300",
(!part1 || !part2 || !part3) && "tooltip",
!part1 && !part2 && !part3 && "tooltip",
)}>
{isLoading ? (
<div className="flex items-center justify-center">

View File

@@ -1,5 +1,5 @@
import Input from "@/components/Low/Input";
import {WritingExam} from "@/interfaces/exam";
import {WritingExam, WritingExercise} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams";
import {playSound} from "@/utils/sound";
@@ -7,8 +7,8 @@ import {Tab} from "@headlessui/react";
import axios from "axios";
import clsx from "clsx";
import {useRouter} from "next/router";
import {useState} from "react";
import {BsArrowRepeat} from "react-icons/bs";
import {useEffect, useState} from "react";
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
import {toast} from "react-toastify";
import {v4} from "uuid";
@@ -20,7 +20,8 @@ const TaskTab = ({task, index, setTask}: {task?: string; index: number; setTask:
axios
.get(`/api/exam/writing/generate/writing_task${index}_general`)
.then((result) => {
playSound("check");
playSound(typeof result.data === "string" ? "error" : "check");
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
setTask(result.data.question);
})
.catch((error) => {
@@ -68,9 +69,16 @@ const TaskTab = ({task, index, setTask}: {task?: string; index: number; setTask:
const WritingGeneration = () => {
const [task1, setTask1] = useState<string>();
const [task2, setTask2] = useState<string>();
const [minTimer, setMinTimer] = useState(60);
const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<WritingExam>();
useEffect(() => {
const task1Timer = task1 ? 20 : 0;
const task2Timer = task2 ? 40 : 0;
setMinTimer(task1Timer > 0 || task2Timer > 0 ? task1Timer + task2Timer : 20);
}, [task1, task2]);
const router = useRouter();
const setExams = useExamStore((state) => state.setExams);
@@ -93,18 +101,13 @@ const WritingGeneration = () => {
};
const submitExam = () => {
if (!task1 || !task2) {
toast.error("Please generate all tasks before submitting");
if (!task1 && !task2) {
toast.error("Please generate a task before submitting");
return;
}
setIsLoading(true);
const exam: WritingExam = {
isDiagnostic: false,
minTimer: 60,
module: "writing",
exercises: [
{
const exercise1 = task1
? ({
id: v4(),
type: "writing",
prefix: `You should spend about 20 minutes on this task.`,
@@ -115,8 +118,11 @@ const WritingGeneration = () => {
limit: 150,
type: "min",
},
},
{
} as WritingExercise)
: undefined;
const exercise2 = task2
? ({
id: v4(),
type: "writing",
prefix: `You should spend about 40 minutes on this task.`,
@@ -127,9 +133,17 @@ const WritingGeneration = () => {
limit: 250,
type: "min",
},
},
],
} as WritingExercise)
: undefined;
setIsLoading(true);
const exam: WritingExam = {
isDiagnostic: false,
minTimer,
module: "writing",
exercises: [...(exercise1 ? [exercise1] : []), ...(exercise2 ? [exercise2] : [])],
id: v4(),
variant: exercise1 && exercise2 ? "full" : "partial",
};
axios
@@ -152,29 +166,40 @@ const WritingGeneration = () => {
return (
<>
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
<Input
type="number"
name="minTimer"
onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))}
value={minTimer}
className="max-w-[300px]"
/>
</div>
<Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1">
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/70",
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/70 flex gap-2 items-center justify-center",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
)
}>
Task 1
Task 1 {task1 && <BsCheck />}
</Tab>
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/70",
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/70 flex gap-2 items-center justify-center",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
)
}>
Task 2
Task 2 {task2 && <BsCheck />}
</Tab>
</Tab.List>
<Tab.Panels>
@@ -200,14 +225,14 @@ const WritingGeneration = () => {
</button>
)}
<button
disabled={!task1 || !task2 || isLoading}
disabled={(!task1 && !task2) || isLoading}
data-tip="Please generate all three passages"
onClick={submitExam}
className={clsx(
"bg-ielts-writing/70 border border-ielts-writing text-white w-full max-w-[200px] rounded-xl h-[70px] self-end",
"hover:bg-ielts-writing disabled:bg-ielts-writing/40 disabled:cursor-not-allowed",
"transition ease-in-out duration-300",
(!task1 || !task2) && "tooltip",
!task1 && !task2 && "tooltip",
)}>
{isLoading ? (
<div className="flex items-center justify-center">

View File

@@ -1,215 +1,279 @@
import Button from "@/components/Low/Button";
import Input from "@/components/Low/Input";
import useUsers from "@/hooks/useUsers";
import {User} from "@/interfaces/user";
import {sendEmailVerification} from "@/utils/email";
import { User } from "@/interfaces/user";
import { sendEmailVerification } from "@/utils/email";
import axios from "axios";
import {Divider} from "primereact/divider";
import {useState} from "react";
import {toast} from "react-toastify";
import {KeyedMutator} from "swr";
import { Divider } from "primereact/divider";
import { useState } from "react";
import { toast } from "react-toastify";
import { KeyedMutator } from "swr";
import Select from "react-select";
import moment from "moment";
interface Props {
isLoading: boolean;
setIsLoading: (isLoading: boolean) => void;
mutateUser: KeyedMutator<User>;
sendEmailVerification: typeof sendEmailVerification;
isLoading: boolean;
setIsLoading: (isLoading: boolean) => void;
mutateUser: KeyedMutator<User>;
sendEmailVerification: typeof sendEmailVerification;
}
const availableDurations = {
"1_month": {label: "1 Month", number: 1},
"3_months": {label: "3 Months", number: 3},
"6_months": {label: "6 Months", number: 6},
"12_months": {label: "12 Months", number: 12},
"1_month": { label: "1 Month", number: 1 },
"3_months": { label: "3 Months", number: 3 },
"6_months": { label: "6 Months", number: 6 },
"12_months": { label: "12 Months", number: 12 },
};
export default function RegisterCorporate({isLoading, setIsLoading, mutateUser, sendEmailVerification}: Props) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [referralAgent, setReferralAgent] = useState<string | undefined>();
export default function RegisterCorporate({
isLoading,
setIsLoading,
mutateUser,
sendEmailVerification,
}: Props) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [referralAgent, setReferralAgent] = useState<string | undefined>();
const [companyName, setCompanyName] = useState("");
const [companyUsers, setCompanyUsers] = useState(0);
const [subscriptionDuration, setSubscriptionDuration] = useState(1);
const [companyName, setCompanyName] = useState("");
const [companyUsers, setCompanyUsers] = useState(0);
const [subscriptionDuration, setSubscriptionDuration] = useState(1);
const {users} = useUsers();
const { users } = useUsers();
const onSuccess = () => toast.success("An e-mail has been sent, please make sure to check your spam folder!");
const onSuccess = () =>
toast.success(
"An e-mail has been sent, please make sure to check your spam folder!",
);
const onError = (e: Error) => {
console.error(e);
toast.error("Something went wrong, please logout and re-login.", {toastId: "send-verify-error"});
};
const onError = (e: Error) => {
console.error(e);
toast.error("Something went wrong, please logout and re-login.", {
toastId: "send-verify-error",
});
};
const register = (e: any) => {
e.preventDefault();
const register = (e: any) => {
e.preventDefault();
if (confirmPassword !== password) {
toast.error("Your passwords do not match!", {toastId: "password-not-match"});
return;
}
if (confirmPassword !== password) {
toast.error("Your passwords do not match!", {
toastId: "password-not-match",
});
return;
}
setIsLoading(true);
axios
.post("/api/register", {
name,
email,
password,
type: "corporate",
profilePicture: "/defaultAvatar.png",
subscriptionExpirationDate: moment().subtract(1, "days").toISOString(),
corporateInformation: {
companyInformation: {
name: companyName,
userAmount: companyUsers,
},
monthlyDuration: subscriptionDuration,
referralAgent,
},
})
.then((response) => {
mutateUser(response.data.user).then(() => sendEmailVerification(setIsLoading, onSuccess, onError));
})
.catch((error) => {
console.log(error.response.data);
setIsLoading(true);
axios
.post("/api/register", {
name,
email,
password,
type: "corporate",
profilePicture: "/defaultAvatar.png",
subscriptionExpirationDate: moment().subtract(1, "days").toISOString(),
corporateInformation: {
companyInformation: {
name: companyName,
userAmount: companyUsers,
},
monthlyDuration: subscriptionDuration,
referralAgent,
},
})
.then((response) => {
mutateUser(response.data.user).then(() =>
sendEmailVerification(setIsLoading, onSuccess, onError),
);
})
.catch((error) => {
console.log(error.response.data);
if (error.response.status === 401) {
toast.error("There is already a user with that e-mail!");
return;
}
if (error.response.status === 401) {
toast.error("There is already a user with that e-mail!");
return;
}
if (error.response.status === 400) {
toast.error("The provided code is invalid!");
return;
}
if (error.response.status === 400) {
toast.error("The provided code is invalid!");
return;
}
toast.error("There was something wrong, please try again!");
})
.finally(() => setIsLoading(false));
};
toast.error("There was something wrong, please try again!");
})
.finally(() => setIsLoading(false));
};
return (
<form className="flex flex-col items-center gap-4 w-full" onSubmit={register}>
<div className="w-full flex gap-4">
<Input type="text" name="name" onChange={(e) => setName(e)} placeholder="Enter your name" defaultValue={name} required />
<Input type="email" name="email" onChange={(e) => setEmail(e)} placeholder="Enter email address" defaultValue={email} required />
</div>
return (
<form
className="flex w-full flex-col items-center gap-4"
onSubmit={register}
>
<div className="flex w-full gap-4">
<Input
type="text"
name="name"
onChange={(e) => setName(e)}
placeholder="Enter your name"
defaultValue={name}
required
/>
<Input
type="email"
name="email"
onChange={(e) => setEmail(e.toLowerCase())}
placeholder="Enter email address"
defaultValue={email}
required
/>
</div>
<div className="w-full flex gap-4">
<Input
type="password"
name="password"
onChange={(e) => setPassword(e)}
placeholder="Enter your password"
defaultValue={password}
required
/>
<Input
type="password"
name="confirmPassword"
onChange={(e) => setConfirmPassword(e)}
placeholder="Confirm your password"
defaultValue={confirmPassword}
required
/>
</div>
<div className="flex w-full gap-4">
<Input
type="password"
name="password"
onChange={(e) => setPassword(e)}
placeholder="Enter your password"
defaultValue={password}
required
/>
<Input
type="password"
name="confirmPassword"
onChange={(e) => setConfirmPassword(e)}
placeholder="Confirm your password"
defaultValue={confirmPassword}
required
/>
</div>
<Divider className="w-full !my-2" />
<Divider className="!my-2 w-full" />
<div className="w-full flex gap-4">
<Input
type="text"
name="companyName"
onChange={(e) => setCompanyName(e)}
placeholder="Corporate name"
label="Corporate name"
defaultValue={companyName}
required
/>
<Input
type="number"
name="companyUsers"
onChange={(e) => setCompanyUsers(parseInt(e))}
label="Number of users"
defaultValue={companyUsers}
required
/>
</div>
<div className="flex w-full gap-4">
<Input
type="text"
name="companyName"
onChange={(e) => setCompanyName(e)}
placeholder="Corporate name"
label="Corporate name"
defaultValue={companyName}
required
/>
<Input
type="number"
name="companyUsers"
onChange={(e) => setCompanyUsers(parseInt(e))}
label="Number of users"
defaultValue={companyUsers}
required
/>
</div>
<div className="w-full flex gap-4">
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Referral *</label>
<Select
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
options={[
{value: "", label: "No referral"},
...users.filter((u) => u.type === "agent").map((x) => ({value: x.id, label: `${x.name} - ${x.email}`})),
]}
defaultValue={{value: "", label: "No referral"}}
onChange={(value) => setReferralAgent(value?.value)}
styles={{
control: (styles) => ({
...styles,
paddingLeft: "4px",
border: "none",
outline: "none",
":focus": {
outline: "none",
},
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
</div>
<div className="flex w-full gap-4">
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">
Referral *
</label>
<Select
className="placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none disabled:cursor-not-allowed"
options={[
{ value: "", label: "No referral" },
...users
.filter((u) => u.type === "agent")
.map((x) => ({ value: x.id, label: `${x.name} - ${x.email}` })),
]}
defaultValue={{ value: "", label: "No referral" }}
onChange={(value) => setReferralAgent(value?.value)}
styles={{
control: (styles) => ({
...styles,
paddingLeft: "4px",
border: "none",
outline: "none",
":focus": {
outline: "none",
},
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused
? "#D5D9F0"
: state.isSelected
? "#7872BF"
: "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
</div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Subscription Duration *</label>
<Select
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
options={Object.keys(availableDurations).map((value) => ({
value,
label: availableDurations[value as keyof typeof availableDurations].label,
}))}
defaultValue={{value: "1_month", label: availableDurations["1_month"].label}}
onChange={(value) =>
setSubscriptionDuration(value ? availableDurations[value.value as keyof typeof availableDurations].number : 1)
}
styles={{
control: (styles) => ({
...styles,
paddingLeft: "4px",
border: "none",
outline: "none",
":focus": {
outline: "none",
},
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
</div>
</div>
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">
Subscription Duration *
</label>
<Select
className="placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none disabled:cursor-not-allowed"
options={Object.keys(availableDurations).map((value) => ({
value,
label:
availableDurations[value as keyof typeof availableDurations]
.label,
}))}
defaultValue={{
value: "1_month",
label: availableDurations["1_month"].label,
}}
onChange={(value) =>
setSubscriptionDuration(
value
? availableDurations[
value.value as keyof typeof availableDurations
].number
: 1,
)
}
styles={{
control: (styles) => ({
...styles,
paddingLeft: "4px",
border: "none",
outline: "none",
":focus": {
outline: "none",
},
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused
? "#D5D9F0"
: state.isSelected
? "#7872BF"
: "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
</div>
</div>
<Button
className="lg:mt-8 w-full"
color="purple"
disabled={
isLoading || !email || !name || !password || !confirmPassword || password !== confirmPassword || !companyName || companyUsers <= 0
}>
Create account
</Button>
</form>
);
<Button
className="w-full lg:mt-8"
color="purple"
disabled={
isLoading ||
!email ||
!name ||
!password ||
!confirmPassword ||
password !== confirmPassword ||
!companyName ||
companyUsers <= 0
}
>
Create account
</Button>
</form>
);
}

View File

@@ -1,132 +1,167 @@
import Button from "@/components/Low/Button";
import Checkbox from "@/components/Low/Checkbox";
import Input from "@/components/Low/Input";
import {User} from "@/interfaces/user";
import {sendEmailVerification} from "@/utils/email";
import { User } from "@/interfaces/user";
import { sendEmailVerification } from "@/utils/email";
import axios from "axios";
import {useEffect, useState} from "react";
import {toast} from "react-toastify";
import {KeyedMutator} from "swr";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import { KeyedMutator } from "swr";
interface Props {
queryCode?: string;
defaultInformation?: {
email: string;
name: string;
passport_id?: string;
};
isLoading: boolean;
setIsLoading: (isLoading: boolean) => void;
mutateUser: KeyedMutator<User>;
sendEmailVerification: typeof sendEmailVerification;
queryCode?: string;
defaultInformation?: {
email: string;
name: string;
passport_id?: string;
};
isLoading: boolean;
setIsLoading: (isLoading: boolean) => void;
mutateUser: KeyedMutator<User>;
sendEmailVerification: typeof sendEmailVerification;
}
export default function RegisterIndividual({queryCode, defaultInformation, isLoading, setIsLoading, mutateUser, sendEmailVerification}: Props) {
const [name, setName] = useState(defaultInformation?.name || "");
const [email, setEmail] = useState(defaultInformation?.email || "");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [code, setCode] = useState(queryCode || "");
const [hasCode, setHasCode] = useState<boolean>(!!queryCode);
export default function RegisterIndividual({
queryCode,
defaultInformation,
isLoading,
setIsLoading,
mutateUser,
sendEmailVerification,
}: Props) {
const [name, setName] = useState(defaultInformation?.name || "");
const [email, setEmail] = useState(defaultInformation?.email || "");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [code, setCode] = useState(queryCode || "");
const [hasCode, setHasCode] = useState<boolean>(!!queryCode);
const onSuccess = () => toast.success("An e-mail has been sent, please make sure to check your spam folder!");
const onSuccess = () =>
toast.success(
"An e-mail has been sent, please make sure to check your spam folder!",
);
const onError = (e: Error) => {
console.error(e);
toast.error("Something went wrong, please logout and re-login.", {toastId: "send-verify-error"});
};
const onError = (e: Error) => {
console.error(e);
toast.error("Something went wrong, please logout and re-login.", {
toastId: "send-verify-error",
});
};
const register = (e: any) => {
e.preventDefault();
const register = (e: any) => {
e.preventDefault();
if (confirmPassword !== password) {
toast.error("Your passwords do not match!", {toastId: "password-not-match"});
return;
}
if (confirmPassword !== password) {
toast.error("Your passwords do not match!", {
toastId: "password-not-match",
});
return;
}
setIsLoading(true);
axios
.post("/api/register", {
name,
email,
password,
type: "individual",
code,
passport_id: defaultInformation?.passport_id,
profilePicture: "/defaultAvatar.png",
})
.then((response) => {
mutateUser(response.data.user).then(() => sendEmailVerification(setIsLoading, onSuccess, onError));
})
.catch((error) => {
console.log(error.response.data);
setIsLoading(true);
axios
.post("/api/register", {
name,
email,
password,
type: "individual",
code,
passport_id: defaultInformation?.passport_id,
profilePicture: "/defaultAvatar.png",
})
.then((response) => {
mutateUser(response.data.user).then(() =>
sendEmailVerification(setIsLoading, onSuccess, onError),
);
})
.catch((error) => {
console.log(error.response.data);
if (error.response.status === 401) {
toast.error("There is already a user with that e-mail!");
return;
}
if (error.response.status === 401) {
toast.error("There is already a user with that e-mail!");
return;
}
if (error.response.status === 400) {
toast.error("The provided code is invalid!");
return;
}
if (error.response.status === 400) {
toast.error("The provided code is invalid!");
return;
}
toast.error("There was something wrong, please try again!");
})
.finally(() => setIsLoading(false));
};
toast.error("There was something wrong, please try again!");
})
.finally(() => setIsLoading(false));
};
return (
<form className="flex flex-col items-center gap-6 w-full" onSubmit={register}>
<Input type="text" name="name" onChange={(e) => setName(e)} placeholder="Enter your name" value={name} required />
<Input
type="email"
name="email"
onChange={(e) => setEmail(e)}
placeholder="Enter email address"
value={email}
disabled={!!defaultInformation?.email}
required
/>
<Input
type="password"
name="password"
onChange={(e) => setPassword(e)}
placeholder="Enter your password"
defaultValue={password}
required
/>
<Input
type="password"
name="confirmPassword"
onChange={(e) => setConfirmPassword(e)}
placeholder="Confirm your password"
defaultValue={confirmPassword}
required
/>
return (
<form
className="flex w-full flex-col items-center gap-6"
onSubmit={register}
>
<Input
type="text"
name="name"
onChange={(e) => setName(e)}
placeholder="Enter your name"
value={name}
required
/>
<Input
type="email"
name="email"
onChange={(e) => setEmail(e.toLowerCase())}
placeholder="Enter email address"
value={email}
disabled={!!defaultInformation?.email}
required
/>
<Input
type="password"
name="password"
onChange={(e) => setPassword(e)}
placeholder="Enter your password"
defaultValue={password}
required
/>
<Input
type="password"
name="confirmPassword"
onChange={(e) => setConfirmPassword(e)}
placeholder="Confirm your password"
defaultValue={confirmPassword}
required
/>
<div className="flex flex-col gap-4 w-full items-start">
<Checkbox isChecked={hasCode} onChange={setHasCode}>
I have a code
</Checkbox>
{hasCode && (
<Input
type="text"
name="code"
onChange={(e) => setCode(e)}
placeholder="Enter your registration code (optional)"
defaultValue={code}
required
/>
)}
</div>
<div className="flex w-full flex-col items-start gap-4">
<Checkbox isChecked={hasCode} onChange={setHasCode}>
I have a code
</Checkbox>
{hasCode && (
<Input
type="text"
name="code"
onChange={(e) => setCode(e)}
placeholder="Enter your registration code (optional)"
defaultValue={code}
required
/>
)}
</div>
<Button
className="lg:mt-8 w-full"
color="purple"
disabled={isLoading || !email || !name || !password || !confirmPassword || password !== confirmPassword || (hasCode ? !code : false)}>
Create account
</Button>
</form>
);
<Button
className="w-full lg:mt-8"
color="purple"
disabled={
isLoading ||
!email ||
!name ||
!password ||
!confirmPassword ||
password !== confirmPassword ||
(hasCode ? !code : false)
}
>
Create account
</Button>
</form>
);
}

View File

@@ -9,6 +9,11 @@ import clsx from "clsx";
import {capitalize} from "lodash";
import {useState} from "react";
import getSymbolFromCurrency from "currency-symbol-map";
import useInvites from "@/hooks/useInvites";
import {BsArrowRepeat} from "react-icons/bs";
import InviteCard from "@/components/Medium/InviteCard";
import {useRouter} from "next/router";
import {PayPalScriptProvider} from "@paypal/react-paypal-js";
interface Props {
user: User;
@@ -20,9 +25,12 @@ interface Props {
export default function PaymentDue({user, hasExpired = false, clientID, reload}: Props) {
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const {packages} = usePackages();
const {users} = useUsers();
const {groups} = useGroups();
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user?.id});
const isIndividual = () => {
if (user?.type === "developer") return true;
@@ -38,59 +46,94 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
return (
<>
{isLoading && (
<div className="w-screen h-screen absolute top-0 left-0 overflow-hidden z-[999] bg-black/60">
<div className="w-fit h-fit absolute top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 animate-pulse flex flex-col gap-8 items-center text-white">
<div className="absolute left-0 top-0 z-[999] h-screen w-screen overflow-hidden bg-black/60">
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-8 text-white">
<span className={clsx("loading loading-infinity w-48")} />
<span className={clsx("font-bold text-2xl")}>Completing your payment...</span>
<span className={clsx("text-2xl font-bold")}>Completing your payment...</span>
</div>
</div>
)}
{user ? (
<Layout user={user} navDisabled={hasExpired}>
<div className="flex flex-col items-center justify-center text-center w-full gap-4">
{hasExpired && <span className="font-bold text-lg">You do not have time credits for your account type!</span>}
{invites.length > 0 && (
<section className="flex flex-col gap-1 md:gap-3">
<div className="flex items-center gap-4">
<div
onClick={reloadInvites}
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out">
<span className="text-mti-black text-lg font-bold">Invites</span>
<BsArrowRepeat className={clsx("text-xl", isInvitesLoading && "animate-spin")} />
</div>
</div>
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
{invites.map((invite) => (
<InviteCard
key={invite.id}
invite={invite}
users={users}
reload={() => {
reloadInvites();
router.reload();
}}
/>
))}
</span>
</section>
)}
<div className="flex w-full flex-col items-center justify-center gap-4 text-center">
{hasExpired && <span className="text-lg font-bold">You do not have time credits for your account type!</span>}
{isIndividual() && (
<div className="flex flex-col items-center w-full overflow-x-scroll scrollbar-hide gap-12">
<div className="scrollbar-hide flex w-full flex-col items-center gap-12 overflow-x-scroll">
<span className="max-w-lg">
To add to your use of EnCoach, please purchase one of the time packages available below:
</span>
<div className="w-full flex flex-wrap justify-center gap-8">
{packages.map((p) => (
<div key={p.id} className={clsx("p-4 bg-white rounded-xl flex flex-col gap-6 items-start")}>
<div className="flex flex-col items-start mb-2">
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" />
<span className="font-semibold text-xl">
EnCoach - {p.duration}{" "}
{capitalize(
p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit,
)}
</span>
<div className="flex w-full flex-wrap justify-center gap-8">
<PayPalScriptProvider
options={{
clientId: clientID,
currency: "USD",
intent: "capture",
commit: true,
vault: true,
}}>
{packages.map((p) => (
<div key={p.id} className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}>
<div className="mb-2 flex flex-col items-start">
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" />
<span className="text-xl font-semibold">
EnCoach - {p.duration}{" "}
{capitalize(
p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit,
)}
</span>
</div>
<div className="flex w-full flex-col items-start gap-2">
<span className="text-2xl">
{p.price}
{getSymbolFromCurrency(p.currency)}
</span>
<PayPalPayment
key={clientID}
{...p}
clientID={clientID}
setIsLoading={setIsLoading}
onSuccess={() => {
setTimeout(reload, 500);
}}
/>
</div>
<div className="flex flex-col items-start gap-1">
<span>This includes:</span>
<ul className="flex flex-col items-start text-sm">
<li>- Train your abilities for the IELTS exam</li>
<li>- Gain insights into your weaknesses and strengths</li>
<li>- Allow yourself to correctly prepare for the exam</li>
</ul>
</div>
</div>
<div className="flex flex-col gap-2 items-start w-full">
<span className="text-2xl">
{p.price}
{getSymbolFromCurrency(p.currency)}
</span>
<PayPalPayment
key={clientID}
{...p}
clientID={clientID}
setIsLoading={setIsLoading}
onSuccess={() => {
setTimeout(reload, 500);
}}
/>
</div>
<div className="flex flex-col gap-1 items-start">
<span>This includes:</span>
<ul className="flex flex-col items-start text-sm">
<li>- Train your abilities for the IELTS exam</li>
<li>- Gain insights into your weaknesses and strengths</li>
<li>- Allow yourself to correctly prepare for the exam</li>
</ul>
</div>
</div>
))}
))}
</PayPalScriptProvider>
</div>
</div>
)}
@@ -99,12 +142,12 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
<span className="max-w-lg">
To add to your use of EnCoach and that of your students and teachers, please pay your designated package below:
</span>
<div className={clsx("p-4 bg-white rounded-xl flex flex-col gap-6 items-start")}>
<div className="flex flex-col items-start mb-2">
<div className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}>
<div className="mb-2 flex flex-col items-start">
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" />
<span className="font-semibold text-xl">EnCoach - {user.corporateInformation?.monthlyDuration} Months</span>
<span className="text-xl font-semibold">EnCoach - {user.corporateInformation?.monthlyDuration} Months</span>
</div>
<div className="flex flex-col gap-2 items-start w-full">
<div className="flex w-full flex-col items-start gap-2">
<span className="text-2xl">
{user.corporateInformation.payment.value}
{getSymbolFromCurrency(user.corporateInformation.payment.currency)}
@@ -121,9 +164,10 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
setIsLoading(false);
setTimeout(reload, 500);
}}
loadScript
/>
</div>
<div className="flex flex-col gap-1 items-start">
<div className="flex flex-col items-start gap-1">
<span>This includes:</span>
<ul className="flex flex-col items-start text-sm">
<li>

View File

@@ -10,9 +10,10 @@ import {useRouter} from "next/router";
import {useEffect} from "react";
import useExamStore from "@/stores/examStore";
import usePreferencesStore from "@/stores/preferencesStore";
import axios from "axios";
export default function App({Component, pageProps}: AppProps) {
const reset = useExamStore((state) => state.reset);
const {reset} = useExamStore((state) => state);
const setIsSidebarMinimized = usePreferencesStore((state) => state.setSidebarMinimized);
const router = useRouter();

View File

@@ -1,157 +1,227 @@
/* eslint-disable @next/next/no-img-element */
import {toast, ToastContainer} from "react-toastify";
import { toast, ToastContainer } from "react-toastify";
import axios from "axios";
import {FormEvent, useEffect, useState} from "react";
import { FormEvent, useEffect, useState } from "react";
import Head from "next/head";
import useUser from "@/hooks/useUser";
import {Divider} from "primereact/divider";
import { Divider } from "primereact/divider";
import Button from "@/components/Low/Button";
import {BsArrowRepeat} from "react-icons/bs";
import { BsArrowRepeat } from "react-icons/bs";
import Link from "next/link";
import Input from "@/components/Low/Input";
import {useRouter} from "next/router";
import { useRouter } from "next/router";
export function getServerSideProps({query, res}: {query: {oobCode: string; mode: string; apiKey?: string; continueUrl?: string}; res: any}) {
if (!query || !query.oobCode || !query.mode) {
res.setHeader("location", "/login");
res.statusCode = 302;
res.end();
return {
props: {},
};
}
export function getServerSideProps({
query,
res,
}: {
query: {
oobCode: string;
mode: string;
continueUrl?: string;
};
res: any;
}) {
if (!query || !query.oobCode || !query.mode) {
res.setHeader("location", "/login");
res.statusCode = 302;
res.end();
return {
props: {},
};
}
return {
props: {
code: query.oobCode,
mode: query.mode,
apiKey: query.apiKey,
continueUrl: query.continueUrl,
},
};
return {
props: {
code: query.oobCode,
mode: query.mode,
...(query.continueUrl ? { continueUrl: query.continueUrl } : {}),
},
};
}
export default function Reset({code, mode, apiKey, continueUrl}: {code: string; mode: string; apiKey?: string; continueUrl?: string}) {
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
export default function Reset({
code,
mode,
continueUrl,
}: {
code: string;
mode: string;
continueUrl?: string;
}) {
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const router = useRouter();
useUser({
redirectTo: "/",
redirectIfFound: true,
});
useUser({
redirectTo: "/",
redirectIfFound: true,
});
useEffect(() => {
if (mode === "signIn") {
axios
.post<{ok: boolean}>("/api/reset/verify", {
email: continueUrl?.replace("https://platform.encoach.com/", ""),
})
.then((response) => {
if (response.data.ok) {
toast.success("Your account has been verified!", {toastId: "verify-successful"});
setTimeout(() => {
router.reload();
}, 1000);
return;
}
useEffect(() => {
if (mode === "signIn") {
axios
.post<{ ok: boolean }>("/api/reset/verify", {
email: continueUrl?.replace("https://platform.encoach.com/", ""),
})
.then((response) => {
if (response.data.ok) {
toast.success("Your account has been verified!", {
toastId: "verify-successful",
});
setTimeout(() => {
router.push("/");
}, 1000);
return;
}
toast.error("Something went wrong! Please make sure to click the link in your e-mail again and input the correct e-mail!", {
toastId: "verify-error",
});
})
.catch(() => {
toast.error("Something went wrong! Please make sure to click the link in your e-mail again and input the correct e-mail!", {
toastId: "verify-error",
});
setIsLoading(false);
});
}
});
toast.error(
"Something went wrong! Please make sure to click the link in your e-mail again and input the correct e-mail!",
{
toastId: "verify-error",
},
);
})
.catch(() => {
toast.error(
"Something went wrong! Please make sure to click the link in your e-mail again and input the correct e-mail!",
{
toastId: "verify-error",
},
);
setIsLoading(false);
});
}
});
const login = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const login = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
axios
.post<{ok: boolean}>("/api/reset/confirm", {code, password})
.then((response) => {
if (response.data.ok) {
toast.success("Your password has been reset!", {toastId: "reset-successful"});
setTimeout(() => {
router.push("/login");
}, 1000);
return;
}
setIsLoading(true);
axios
.post<{ ok: boolean }>("/api/reset/confirm", { code, password })
.then((response) => {
if (response.data.ok) {
toast.success("Your password has been reset!", {
toastId: "reset-successful",
});
setTimeout(() => {
router.push("/login");
}, 1000);
return;
}
toast.error("Something went wrong! Please make sure to click the link in your e-mail again!", {toastId: "reset-error"});
})
.catch(() => {
toast.error("Something went wrong! Please make sure to click the link in your e-mail again!", {toastId: "reset-error"});
})
.finally(() => setIsLoading(false));
};
toast.error(
"Something went wrong! Please make sure to click the link in your e-mail again!",
{ toastId: "reset-error" },
);
})
.catch(() => {
toast.error(
"Something went wrong! Please make sure to click the link in your e-mail again!",
{ toastId: "reset-error" },
);
})
.finally(() => setIsLoading(false));
};
return (
<>
<Head>
<title>Reset | EnCoach</title>
<meta name="description" content="Generated by create next app" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="w-full h-[100vh] flex bg-white text-black">
<ToastContainer />
<section className="h-full w-fit min-w-fit relative hidden lg:flex">
<div className="absolute h-full w-full bg-mti-rose-light z-10 bg-opacity-50" />
<img src="/people-talking-tablet.png" alt="People smiling looking at a tablet" className="h-full aspect-auto" />
</section>
{mode === "resetPassword" && (
<section className="h-full w-full flex flex-col items-center justify-center gap-2">
<div className="flex flex-col gap-2 items-center relative">
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-36 lg:w-64 absolute -top-36 lg:-top-64" />
<h1 className="font-bold text-2xl lg:text-4xl">Reset your password</h1>
<p className="self-start text-sm lg:text-base font-normal text-mti-gray-cool">to your registered Email Address</p>
</div>
<Divider className="max-w-xs lg:max-w-md" />
<form className="flex flex-col items-center gap-6 w-full -lg:px-8 lg:w-1/2" onSubmit={login}>
<Input type="password" name="password" onChange={(e) => setPassword(e)} placeholder="Password" />
return (
<>
<Head>
<title>Reset | EnCoach</title>
<meta name="description" content="Generated by create next app" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="flex h-[100vh] w-full bg-white text-black">
<ToastContainer />
<section className="relative hidden h-full w-fit min-w-fit lg:flex">
<div className="bg-mti-rose-light absolute z-10 h-full w-full bg-opacity-50" />
<img
src="/people-talking-tablet.png"
alt="People smiling looking at a tablet"
className="aspect-auto h-full"
/>
</section>
{mode === "resetPassword" && (
<section className="flex h-full w-full flex-col items-center justify-center gap-2">
<div className="relative flex flex-col items-center gap-2">
<img
src="/logo_title.png"
alt="EnCoach's Logo"
className="absolute -top-36 w-36 lg:-top-64 lg:w-64"
/>
<h1 className="text-2xl font-bold lg:text-4xl">
Reset your password
</h1>
<p className="text-mti-gray-cool self-start text-sm font-normal lg:text-base">
to your registered Email Address
</p>
</div>
<Divider className="max-w-xs lg:max-w-md" />
<form
className="-lg:px-8 flex w-full flex-col items-center gap-6 lg:w-1/2"
onSubmit={login}
>
<Input
type="password"
name="password"
onChange={(e) => setPassword(e)}
placeholder="Password"
/>
<Button className="mt-8 w-full" color="purple" disabled={isLoading}>
{!isLoading && "Reset"}
{isLoading && (
<div className="flex items-center justify-center">
<BsArrowRepeat className="text-white animate-spin" size={25} />
</div>
)}
</Button>
</form>
<span className="text-mti-gray-cool text-sm font-normal mt-8">
Don&apos;t have an account?{" "}
<Link className="text-mti-purple-light" href="/register">
Sign up
</Link>
</span>
</section>
)}
{mode === "signIn" && (
<section className="h-full w-full flex flex-col items-center justify-center gap-2">
<div className="flex flex-col gap-2 items-center relative">
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-36 lg:w-64 absolute -top-36 lg:-top-64" />
<h1 className="font-bold text-2xl lg:text-4xl">Confirm your account</h1>
<p className="self-start text-sm lg:text-base font-normal text-mti-gray-cool">to your registered Email Address</p>
</div>
<Divider className="max-w-xs lg:max-w-md" />
<div className="flex flex-col items-center gap-6 w-full -lg:px-8 lg:w-1/2">
<span className="text-center">
Your e-mail is currently being verified, please wait a second. <br /> <br />
Once it has been verified, you will be redirected to the home page.
</span>
</div>
</section>
)}
</main>
</>
);
<Button
className="mt-8 w-full"
color="purple"
disabled={isLoading}
>
{!isLoading && "Reset"}
{isLoading && (
<div className="flex items-center justify-center">
<BsArrowRepeat
className="animate-spin text-white"
size={25}
/>
</div>
)}
</Button>
</form>
<span className="text-mti-gray-cool mt-8 text-sm font-normal">
Don&apos;t have an account?{" "}
<Link className="text-mti-purple-light" href="/register">
Sign up
</Link>
</span>
</section>
)}
{mode === "signIn" && (
<section className="flex h-full w-full flex-col items-center justify-center gap-2">
<div className="relative flex flex-col items-center gap-2">
<img
src="/logo_title.png"
alt="EnCoach's Logo"
className="absolute -top-36 w-36 lg:-top-64 lg:w-64"
/>
<h1 className="text-2xl font-bold lg:text-4xl">
Confirm your account
</h1>
<p className="text-mti-gray-cool self-start text-sm font-normal lg:text-base">
to your registered Email Address
</p>
</div>
<Divider className="max-w-xs lg:max-w-md" />
<div className="-lg:px-8 flex w-full flex-col items-center gap-6 lg:w-1/2">
<span className="text-center">
Your e-mail is currently being verified, please wait a second.{" "}
<br /> <br />
Once it has been verified, you will be redirected to the home
page.
</span>
</div>
</section>
)}
</main>
</>
);
}

View File

@@ -106,22 +106,20 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
results: any;
exams: {module: Module}[];
startDate: string;
pdf?: string;
pdf: {
path: string,
version: string,
},
};
if (!data) {
res.status(400).end();
return;
}
if (data.assigner !== req.session.user.id) {
res.status(401).json({ok: false});
return;
}
if (data.pdf) {
if (data.pdf && data.pdf.path && data.pdf.version === process.env.PDF_VERSION) {
// if it does, return the pdf url
const fileRef = ref(storage, data.pdf);
const fileRef = ref(storage, data.pdf.path);
const url = await getDownloadURL(fileRef);
res.status(200).end(url);
return;
}
@@ -158,9 +156,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
return {...e, bandScore};
});
const moduleResults = data.exams.map(({module}) => {
// in order to make sure we are using unique modules, generate the set based on them
const uniqueModules = [...new Set(flattenResults.map(item => item.module))] as Module[];
const moduleResults = uniqueModules.map((module) => {
const moduleResults = flattenResultsWithGrade.filter((e) => e.module === module);
const baseBandScore = moduleResults.reduce((accm, curr) => accm + curr.bandScore, 0) / moduleResults.length;
const bandScore = isNaN(baseBandScore) ? 0 : baseBandScore;
const {correct, total} = getScoreAndTotal(moduleResults);
@@ -239,6 +238,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const result = exams.length === 0 ? "N/A" : `${correct}/${total}`;
const userDemographicInformation = user?.demographicInformation as DemographicInformation;
return {
id,
name: user?.name || "N/A",
@@ -248,6 +249,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
result,
level: showLevel ? getLevelScoreForUserExams(bandScore) : undefined,
bandScore,
passportId: userDemographicInformation?.passport_id || ""
};
});
};
@@ -387,7 +389,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
// update the stats entries with the pdf url to prevent duplication
await updateDoc(docSnap.ref, {
pdf: refName,
pdf: {
path: refName,
version: process.env.PDF_VERSION,
},
});
const url = await getDownloadURL(fileRef);
res.status(200).end(url);
@@ -419,8 +424,8 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
return;
}
if (data.pdf) {
const fileRef = ref(storage, data.pdf);
if (data.pdf?.path) {
const fileRef = ref(storage, data.pdf.path);
const url = await getDownloadURL(fileRef);
return res.redirect(url);
}

View File

@@ -7,8 +7,8 @@ import {sessionOptions} from "@/lib/session";
import {uuidv4} from "@firebase/util";
import {Module} from "@/interfaces";
import {getExams} from "@/utils/exams.be";
import {Exam} from "@/interfaces/exam";
import {capitalize, flatten} from "lodash";
import {Exam, InstructorGender, Variant} from "@/interfaces/exam";
import {capitalize, flatten, uniqBy} from "lodash";
import {User} from "@/interfaces/user";
import moment from "moment";
import {sendEmail} from "@/email";
@@ -52,13 +52,19 @@ function getRandomIndex(arr: any[]): number {
return randomIndex;
}
const generateExams = async (generateMultiple: Boolean, selectedModules: Module[], assignees: string[]): Promise<ExamWithUser[]> => {
const generateExams = async (
generateMultiple: Boolean,
selectedModules: Module[],
assignees: string[],
variant?: Variant,
instructorGender?: InstructorGender,
): Promise<ExamWithUser[]> => {
if (generateMultiple) {
// for optimization purposes, it would be better to create a new endpoint that returned the answers for all users at once
const allExams = await assignees.map(async (assignee) => {
const selectedModulePromises = await selectedModules.map(async (module: Module) => {
const allExams = assignees.map(async (assignee) => {
const selectedModulePromises = selectedModules.map(async (module: Module) => {
try {
const exams: Exam[] = await getExams(db, module, "true", assignee);
const exams: Exam[] = await getExams(db, module, "true", assignee, variant, instructorGender);
const exam = exams[getRandomIndex(exams)];
if (exam) {
@@ -79,8 +85,8 @@ const generateExams = async (generateMultiple: Boolean, selectedModules: Module[
return exams;
}
const selectedModulePromises = await selectedModules.map(async (module: Module) => {
const exams: Exam[] = await getExams(db, module, "false", undefined);
const selectedModulePromises = selectedModules.map(async (module: Module) => {
const exams: Exam[] = await getExams(db, module, "false", undefined, variant, instructorGender);
const exam = exams[getRandomIndex(exams)];
if (exam) {
@@ -101,6 +107,8 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
// Generate multiple true would generate an unique exam for each user
// false would generate the same exam for all users
generateMultiple = false,
variant,
instructorGender,
...body
} = req.body as {
selectedModules: Module[];
@@ -109,9 +117,11 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
name: string;
startDate: string;
endDate: string;
variant?: Variant;
instructorGender?: InstructorGender;
};
const exams: ExamWithUser[] = await generateExams(generateMultiple, selectedModules, assignees);
const exams: ExamWithUser[] = await generateExams(generateMultiple, selectedModules, assignees, variant, instructorGender);
if (exams.length === 0) {
res.status(400).json({ok: false, error: "No exams found for the selected modules"});
@@ -123,6 +133,7 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
assignees,
results: [],
exams,
instructorGender,
...body,
});
@@ -135,13 +146,24 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
const assignee = {id: assigneeID, ...assigneeSnapshot.data()} as User;
const name = body.name;
const teacher = req.session.user!;
const examModulesLabel = exams.map((x) => capitalize(x.module)).join(", ");
const examModulesLabel = uniqBy(exams, (x) => x.module)
.map((x) => capitalize(x.module))
.join(", ");
const startDate = moment(body.startDate).format("DD/MM/YYYY - HH:mm");
const endDate = moment(body.endDate).format("DD/MM/YYYY - HH:mm");
await sendEmail(
"assignment",
{user: {name: assignee.name}, assignment: {name, startDate, endDate, modules: examModulesLabel, assigner: teacher.name}},
{
user: {name: assignee.name},
assignment: {
name,
startDate,
endDate,
modules: examModulesLabel,
assigner: teacher.name,
},
},
[assignee.email],
"EnCoach - New Assignment!",
);

View File

@@ -1,77 +1,143 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, setDoc, doc, query, collection, where, getDocs} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {Type} from "@/interfaces/user";
import {PERMISSIONS} from "@/constants/userPermissions";
import {uuidv4} from "@firebase/util";
import {prepareMailer, prepareMailOptions} from "@/email";
import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
import {
getFirestore,
setDoc,
doc,
query,
collection,
where,
getDocs,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Type } from "@/interfaces/user";
import { PERMISSIONS } from "@/constants/userPermissions";
import { uuidv4 } from "@firebase/util";
import { prepareMailer, prepareMailOptions } from "@/email";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false, reason: "You must be logged in to generate a code!"});
return;
}
if (req.method === "GET") return get(req, res);
if (req.method === "POST") return post(req, res);
const {type, codes, infos, expiryDate} = req.body as {
type: Type;
codes: string[];
infos?: {email: string; name: string; passport_id: string}[];
expiryDate: null | Date;
};
const permission = PERMISSIONS.generateCode[type];
if (!permission.includes(req.session.user.type)) {
res.status(403).json({ok: false, reason: "Your account type does not have permissions to generate a code for that type of user!"});
return;
}
if (req.session.user.type === "corporate") {
const codesGeneratedByUserSnapshot = await getDocs(query(collection(db, "codes"), where("creator", "==", req.session.user.id)));
const totalCodes = codesGeneratedByUserSnapshot.docs.length + codes.length;
const allowedCodes = req.session.user.corporateInformation?.companyInformation.userAmount || 0;
if (totalCodes > allowedCodes) {
res.status(403).json({
ok: false,
reason: `You have or would have exceeded your amount of allowed codes, you currently are allowed to generate ${
allowedCodes - codesGeneratedByUserSnapshot.docs.length
} codes.`,
});
return;
}
}
const codePromises = codes.map(async (code, index) => {
const codeRef = doc(db, "codes", code);
await setDoc(codeRef, {type, code, creator: req.session.user!.id, expiryDate});
if (infos && infos.length > index) {
const {email, name, passport_id} = infos[index];
await setDoc(codeRef, {email: email.trim(), name: name.trim(), passport_id: passport_id.trim()}, {merge: true});
const transport = prepareMailer();
const mailOptions = prepareMailOptions(
{
type,
code,
},
[email.trim()],
"EnCoach Registration",
"main",
);
await transport.sendMail(mailOptions);
}
});
Promise.all(codePromises).then(() => {
res.status(200).json({ok: true});
});
return res.status(404).json({ ok: false });
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res
.status(401)
.json({ ok: false, reason: "You must be logged in to generate a code!" });
return;
}
const { creator } = req.query as { creator?: string };
const q = query(collection(db, "codes"), where("creator", "==", creator));
const snapshot = await getDocs(creator ? q : collection(db, "codes"));
res.status(200).json(snapshot.docs.map((doc) => doc.data()));
}
async function post(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res
.status(401)
.json({ ok: false, reason: "You must be logged in to generate a code!" });
return;
}
const { type, codes, infos, expiryDate } = req.body as {
type: Type;
codes: string[];
infos?: { email: string; name: string; passport_id?: string }[];
expiryDate: null | Date;
};
const permission = PERMISSIONS.generateCode[type];
if (!permission.includes(req.session.user.type)) {
res
.status(403)
.json({
ok: false,
reason:
"Your account type does not have permissions to generate a code for that type of user!",
});
return;
}
if (req.session.user.type === "corporate") {
const codesGeneratedByUserSnapshot = await getDocs(
query(
collection(db, "codes"),
where("creator", "==", req.session.user.id),
),
);
const totalCodes = codesGeneratedByUserSnapshot.docs.length + codes.length;
const allowedCodes =
req.session.user.corporateInformation?.companyInformation.userAmount || 0;
if (totalCodes > allowedCodes) {
res.status(403).json({
ok: false,
reason: `You have or would have exceeded your amount of allowed codes, you currently are allowed to generate ${
allowedCodes - codesGeneratedByUserSnapshot.docs.length
} codes.`,
});
return;
}
}
const codePromises = codes.map(async (code, index) => {
const codeRef = doc(db, "codes", code);
const codeInformation = {
type,
code,
creator: req.session.user!.id,
expiryDate,
};
if (infos && infos.length > index) {
const { email, name, passport_id } = infos[index];
const transport = prepareMailer();
const mailOptions = prepareMailOptions(
{
type,
code,
},
[email.toLowerCase().trim()],
"EnCoach Registration",
"main",
);
try {
await transport.sendMail(mailOptions);
await setDoc(
codeRef,
{
...codeInformation,
email: email.trim().toLowerCase(),
name: name.trim(),
...(passport_id ? { passport_id: passport_id.trim() } : {}),
},
{ merge: true },
);
return true;
} catch (e) {
return false;
}
} else {
await setDoc(codeRef, codeInformation);
}
});
Promise.all(codePromises).then((results) => {
res.status(200).json({ ok: true, valid: results.filter((x) => x).length });
});
}

View File

@@ -39,8 +39,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) return res.status(401).json({ok: false});
if (req.session.user.type !== "developer") return res.status(403).json({ok: false});
const {endpoint, topic, exercises} = req.query as {module: Module; endpoint: string; topic?: string; exercises?: string[]};
const url = `${process.env.BACKEND_URL}/${endpoint}`;
const {endpoint, topic, exercises} = req.query as {module: Module; endpoint: string[]; topic?: string; exercises?: string[]};
const url = `${process.env.BACKEND_URL}/${endpoint.join("/")}`;
const result = await axios.post(
`${url}${topic && exercises ? `?topic=${topic.toLowerCase()}&exercises=${exercises.join("&exercises=")}` : ""}`,

View File

@@ -4,8 +4,8 @@ import {app} from "@/firebase";
import {getFirestore, setDoc, doc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {Exam} from "@/interfaces/exam";
import { getExams } from "@/utils/exams.be";
import {Exam, InstructorGender, Variant} from "@/interfaces/exam";
import {getExams} from "@/utils/exams.be";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
@@ -23,12 +23,14 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
return;
}
const {
module,
avoidRepeated,
} = req.query as {module: string; avoidRepeated: string};
const {module, avoidRepeated, variant, instructorGender} = req.query as {
module: string;
avoidRepeated: string;
variant?: Variant;
instructorGender?: InstructorGender;
};
const exams: Exam[] = await getExams(db, module, avoidRepeated, req.session.user.id);
const exams: Exam[] = await getExams(db, module, avoidRepeated, req.session.user.id, variant, instructorGender);
res.status(200).json(exams);
}

View File

@@ -1,80 +1,108 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, collection, getDocs, getDoc, doc, deleteDoc, setDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {Group} from "@/interfaces/user";
import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
import {
getFirestore,
collection,
getDocs,
getDoc,
doc,
deleteDoc,
setDoc,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Group } from "@/interfaces/user";
import { updateExpiryDateOnGroup } from "@/utils/groups.be";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return await get(req, res);
if (req.method === "DELETE") return await del(req, res);
if (req.method === "PATCH") return await patch(req, res);
if (req.method === "GET") return await get(req, res);
if (req.method === "DELETE") return await del(req, res);
if (req.method === "PATCH") return await patch(req, res);
res.status(404).json(undefined);
res.status(404).json(undefined);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
const {id} = req.query as {id: string};
const { id } = req.query as { id: string };
const snapshot = await getDoc(doc(db, "groups", id));
const snapshot = await getDoc(doc(db, "groups", id));
if (snapshot.exists()) {
res.status(200).json({...snapshot.data(), id: snapshot.id});
} else {
res.status(404).json(undefined);
}
if (snapshot.exists()) {
res.status(200).json({ ...snapshot.data(), id: snapshot.id });
} else {
res.status(404).json(undefined);
}
}
async function del(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
const {id} = req.query as {id: string};
const { id } = req.query as { id: string };
const snapshot = await getDoc(doc(db, "groups", id));
const group = {...snapshot.data(), id: snapshot.id} as Group;
const snapshot = await getDoc(doc(db, "groups", id));
const group = { ...snapshot.data(), id: snapshot.id } as Group;
const user = req.session.user;
if (user.type === "admin" || user.type === "developer" || user.id === group.admin) {
await deleteDoc(snapshot.ref);
const user = req.session.user;
if (
user.type === "admin" ||
user.type === "developer" ||
user.id === group.admin
) {
await deleteDoc(snapshot.ref);
res.status(200).json({ok: true});
return;
}
res.status(200).json({ ok: true });
return;
}
res.status(403).json({ok: false});
res.status(403).json({ ok: false });
}
async function patch(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
const {id} = req.query as {id: string};
const { id } = req.query as { id: string };
const snapshot = await getDoc(doc(db, "groups", id));
const group = {...snapshot.data(), id: snapshot.id} as Group;
const snapshot = await getDoc(doc(db, "groups", id));
const group = { ...snapshot.data(), id: snapshot.id } as Group;
const user = req.session.user;
if (user.type === "admin" || user.type === "developer" || user.id === group.admin) {
await setDoc(snapshot.ref, req.body, {merge: true});
const user = req.session.user;
if (
user.type === "admin" ||
user.type === "developer" ||
user.id === group.admin
) {
if ("participants" in req.body) {
const newParticipants = (req.body.participants as string[]).filter(
(x) => !group.participants.includes(x),
);
await Promise.all(
newParticipants.map(
async (p) => await updateExpiryDateOnGroup(p, group.admin),
),
);
}
res.status(200).json({ok: true});
return;
}
await setDoc(snapshot.ref, req.body, { merge: true });
res.status(403).json({ok: false});
res.status(200).json({ ok: true });
return;
}
res.status(403).json({ ok: false });
}

View File

@@ -1,45 +1,73 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, collection, getDocs, setDoc, doc, query, where} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {Group} from "@/interfaces/user";
import {v4} from "uuid";
import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
import {
getFirestore,
collection,
getDocs,
setDoc,
doc,
query,
where,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Group } from "@/interfaces/user";
import { v4 } from "uuid";
import { updateExpiryDateOnGroup } from "@/utils/groups.be";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
if (req.method === "GET") await get(req, res);
if (req.method === "POST") await post(req, res);
if (req.method === "GET") await get(req, res);
if (req.method === "POST") await post(req, res);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
const {admin, participant} = req.query as {admin: string; participant: string};
const { admin, participant } = req.query as {
admin: string;
participant: string;
};
const queryConstraints = [
...(admin ? [where("admin", "==", admin)] : []),
...(participant ? [where("participants", "array-contains", participant)] : []),
];
const snapshot = await getDocs(queryConstraints.length > 0 ? query(collection(db, "groups"), ...queryConstraints) : collection(db, "groups"));
const groups = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as Group[];
const queryConstraints = [
...(admin ? [where("admin", "==", admin)] : []),
...(participant
? [where("participants", "array-contains", participant)]
: []),
];
const snapshot = await getDocs(
queryConstraints.length > 0
? query(collection(db, "groups"), ...queryConstraints)
: collection(db, "groups"),
);
const groups = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as Group[];
res.status(200).json(groups);
res.status(200).json(groups);
}
async function post(req: NextApiRequest, res: NextApiResponse) {
const body = req.body as Group;
const body = req.body as Group;
await setDoc(doc(db, "groups", v4()), {name: body.name, admin: body.admin, participants: body.participants});
res.status(200).json({ok: true});
await Promise.all(
body.participants.map(
async (p) => await updateExpiryDateOnGroup(p, body.admin),
),
);
await setDoc(doc(db, "groups", v4()), {
name: body.name,
admin: body.admin,
participants: body.participants,
});
res.status(200).json({ ok: true });
}

View File

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

View File

@@ -0,0 +1,128 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, getDoc, doc, deleteDoc, setDoc, getDocs, collection, where, query} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {Ticket} from "@/interfaces/ticket";
import {Invite} from "@/interfaces/invite";
import {CorporateUser, Group, User} from "@/interfaces/user";
import {v4} from "uuid";
import {sendEmail} from "@/email";
import {updateExpiryDateOnGroup} from "@/utils/groups.be";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return await get(req, res);
res.status(404).json(undefined);
}
async function addToInviterGroup(user: User, invitedBy: User) {
const invitedByGroupsRef = await getDocs(query(collection(db, "groups"), where("admin", "==", invitedBy.id)));
const invitedByGroups = invitedByGroupsRef.docs.map((g) => ({
...g.data(),
id: g.id,
})) as Group[];
const typeGroupName = user.type === "student" ? "Students" : user.type === "teacher" ? "Teachers" : undefined;
if (typeGroupName) {
const typeGroup: Group = invitedByGroups.find((g) => g.name === typeGroupName) || {
id: v4(),
admin: invitedBy.id,
name: typeGroupName,
participants: [],
disableEditing: true,
};
await setDoc(
doc(db, "groups", typeGroup.id),
{
...typeGroup,
participants: [...typeGroup.participants.filter((x) => x !== user.id), user.id],
},
{merge: true},
);
}
const invitationsGroup: Group = invitedByGroups.find((g) => g.name === "Invited") || {
id: v4(),
admin: invitedBy.id,
name: "Invited",
participants: [],
disableEditing: true,
};
await setDoc(
doc(db, "groups", invitationsGroup.id),
{
...invitationsGroup,
participants: [...invitationsGroup.participants.filter((x) => x !== user.id), user.id],
},
{
merge: true,
},
);
}
async function deleteFromPreviousCorporateGroups(user: User, invitedBy: User) {
const corporatesRef = await getDocs(query(collection(db, "users"), where("type", "==", "corporate")));
const corporates = (corporatesRef.docs.map((x) => ({...x.data(), id: x.id})) as CorporateUser[]).filter((x) => x.id !== invitedBy.id);
const userGroupsRef = await getDocs(query(collection(db, "groups"), where("participants", "array-contains", user.id)));
const userGroups = userGroupsRef.docs.map((x) => ({...x.data(), id: x.id})) as Group[];
const corporateGroups = userGroups.filter((x) => corporates.map((c) => c.id).includes(x.admin));
await Promise.all(
corporateGroups.map(async (group) => {
await setDoc(doc(db, "groups", group.id), {participants: group.participants.filter((x) => x !== user.id)}, {merge: true});
}),
);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const {id} = req.query as {id: string};
const snapshot = await getDoc(doc(db, "invites", id));
if (snapshot.exists()) {
const invite = {...snapshot.data(), id: snapshot.id} as Invite;
if (invite.to !== req.session.user.id) return res.status(403).json({ok: false});
await deleteDoc(snapshot.ref);
const invitedByRef = await getDoc(doc(db, "users", invite.from));
if (!invitedByRef.exists()) return res.status(404).json({ok: false});
await updateExpiryDateOnGroup(invite.to, invite.from);
const invitedBy = {...invitedByRef.data(), id: invitedByRef.id} as User;
if (invitedBy.type === "corporate") await deleteFromPreviousCorporateGroups(req.session.user, invitedBy);
await addToInviterGroup(req.session.user, invitedBy);
try {
await sendEmail(
"respondedInvite",
{
corporateName: invitedBy.name,
name: req.session.user.name,
decision: "accept",
},
[invitedBy.email],
`${req.session.user.name} has accepted your invite!`,
);
} catch (e) {
console.log(e);
}
res.status(200).json({ok: true});
} else {
res.status(404).json(undefined);
}
}

View File

@@ -0,0 +1,72 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
import {
getFirestore,
getDoc,
doc,
deleteDoc,
setDoc,
getDocs,
collection,
where,
query,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Ticket } from "@/interfaces/ticket";
import { Invite } from "@/interfaces/invite";
import { Group, User } from "@/interfaces/user";
import { v4 } from "uuid";
import { sendEmail } from "@/email";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return await get(req, res);
res.status(404).json(undefined);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
const { id } = req.query as { id: string };
const snapshot = await getDoc(doc(db, "invites", id));
if (snapshot.exists()) {
const invite = { ...snapshot.data(), id: snapshot.id } as Invite;
if (invite.to !== req.session.user.id)
return res.status(403).json({ ok: false });
await deleteDoc(snapshot.ref);
const invitedByRef = await getDoc(doc(db, "users", invite.from));
if (!invitedByRef.exists()) return res.status(404).json({ ok: false });
const invitedBy = { ...invitedByRef.data(), id: invitedByRef.id } as User;
try {
await sendEmail(
"respondedInvite",
{
corporateName: invitedBy.name,
name: req.session.user.name,
decision: "decline",
},
[invitedBy.email],
`${req.session.user.name} has declined your invite!`,
);
} catch (e) {
console.log(e);
}
res.status(200).json({ ok: true });
} else {
res.status(404).json(undefined);
}
}

View File

@@ -0,0 +1,88 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { sendEmail } from "@/email";
import { app } from "@/firebase";
import { Invite } from "@/interfaces/invite";
import { Ticket } from "@/interfaces/ticket";
import { User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
import {
collection,
doc,
getDoc,
getDocs,
getFirestore,
setDoc,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import type { NextApiRequest, NextApiResponse } from "next";
import ShortUniqueId from "short-unique-id";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
if (req.method === "GET") await get(req, res);
if (req.method === "POST") await post(req, res);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
const snapshot = await getDocs(collection(db, "invites"));
res.status(200).json(
snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})),
);
}
async function post(req: NextApiRequest, res: NextApiResponse) {
const body = req.body as Invite;
const existingInvites = (await getDocs(collection(db, "invites"))).docs.map(
(x) => ({ ...x.data(), id: x.id }),
) as Invite[];
const invitedRef = await getDoc(doc(db, "users", body.to));
if (!invitedRef.exists()) return res.status(404).json({ ok: false });
const invitedByRef = await getDoc(doc(db, "users", body.from));
if (!invitedByRef.exists()) return res.status(404).json({ ok: false });
const invited = { ...invitedRef.data(), id: invitedRef.id } as User;
const invitedBy = { ...invitedByRef.data(), id: invitedByRef.id } as User;
try {
await sendEmail(
"receivedInvite",
{
name: invited.name,
corporateName:
invitedBy.type === "corporate"
? invitedBy.corporateInformation?.companyInformation?.name ||
invitedBy.name
: invitedBy.name,
},
[invited.email],
"You have been invited to a group!",
);
} catch (e) {
console.log(e);
}
if (
existingInvites.filter((i) => i.to === body.to && i.from === body.from)
.length == 0
) {
const shortUID = new ShortUniqueId();
await setDoc(doc(db, "invites", body.id || shortUID.randomUUID(8)), body);
}
res.status(200).json({ ok: true });
}

View File

@@ -1,10 +1,10 @@
import {NextApiRequest, NextApiResponse} from "next";
import {getAuth, signInWithEmailAndPassword} from "firebase/auth";
import {app} from "@/firebase";
import {sessionOptions} from "@/lib/session";
import {withIronSessionApiRoute} from "iron-session/next";
import {User} from "@/interfaces/user";
import {getFirestore, getDoc, doc} from "firebase/firestore";
import { NextApiRequest, NextApiResponse } from "next";
import { getAuth, signInWithEmailAndPassword } from "firebase/auth";
import { app } from "@/firebase";
import { sessionOptions } from "@/lib/session";
import { withIronSessionApiRoute } from "iron-session/next";
import { User } from "@/interfaces/user";
import { getFirestore, getDoc, doc } from "firebase/firestore";
const auth = getAuth(app);
const db = getFirestore(app);
@@ -12,27 +12,27 @@ const db = getFirestore(app);
export default withIronSessionApiRoute(login, sessionOptions);
async function login(req: NextApiRequest, res: NextApiResponse) {
const {email, password} = req.body as {email: string; password: string};
const { email, password } = req.body as { email: string; password: string };
signInWithEmailAndPassword(auth, email, password)
.then(async (userCredentials) => {
const userId = userCredentials.user.uid;
signInWithEmailAndPassword(auth, email.toLowerCase(), password)
.then(async (userCredentials) => {
const userId = userCredentials.user.uid;
const docUser = await getDoc(doc(db, "users", userId));
if (!docUser.exists()) {
res.status(401).json({error: 401, message: "User does not exist!"});
return;
}
const docUser = await getDoc(doc(db, "users", userId));
if (!docUser.exists()) {
res.status(401).json({ error: 401, message: "User does not exist!" });
return;
}
const user = docUser.data() as User;
const user = docUser.data() as User;
req.session.user = {...user, id: userId};
await req.session.save();
req.session.user = { ...user, id: userId };
await req.session.save();
res.status(200).json({user: {...user, id: userId}});
})
.catch((error) => {
console.log(error);
res.status(401).json({error});
});
res.status(200).json({ user: { ...user, id: userId } });
})
.catch((error) => {
console.log(error);
res.status(401).json({ error });
});
}

View File

@@ -74,6 +74,10 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
if (user.type === "admin" || user.type === "developer") {
await setDoc(snapshot.ref, req.body, {merge: true});
if (req.body.isPaid) {
const corporateID = req.body.corporate;
await setDoc(doc(db, "users", corporateID), {status: "active"}, {merge: true});
}
return res.status(200).json({ok: true});
}

View File

@@ -119,6 +119,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const updatedDoc = (await getDoc(doc(db, "payments", paymentId))).data() as Payment;
if (updatedDoc.commissionTransfer && updatedDoc.corporateTransfer) {
await setDoc(doc(db, "payments", paymentId), {isPaid: true}, {merge: true});
await setDoc(doc(db, "users", updatedDoc.corporate), {status: "active"}, {merge: true});
}
res.status(200).json({ref});
} catch (error) {

View File

@@ -1,11 +1,23 @@
import {NextApiRequest, NextApiResponse} from "next";
import {createUserWithEmailAndPassword, getAuth} from "firebase/auth";
import {app} from "@/firebase";
import {sessionOptions} from "@/lib/session";
import {withIronSessionApiRoute} from "iron-session/next";
import {getFirestore, doc, setDoc, query, collection, where, getDocs} from "firebase/firestore";
import {CorporateInformation, DemographicInformation, Type} from "@/interfaces/user";
import {addUserToGroupOnCreation} from "@/utils/registration";
import { NextApiRequest, NextApiResponse } from "next";
import { createUserWithEmailAndPassword, getAuth } from "firebase/auth";
import { app } from "@/firebase";
import { sessionOptions } from "@/lib/session";
import { withIronSessionApiRoute } from "iron-session/next";
import {
getFirestore,
doc,
setDoc,
query,
collection,
where,
getDocs,
} from "firebase/firestore";
import {
CorporateInformation,
DemographicInformation,
Type,
} from "@/interfaces/user";
import { addUserToGroupOnCreation } from "@/utils/registration";
import moment from "moment";
const auth = getAuth(app);
@@ -14,117 +26,140 @@ const db = getFirestore(app);
export default withIronSessionApiRoute(register, sessionOptions);
const DEFAULT_DESIRED_LEVELS = {
reading: 9,
listening: 9,
writing: 9,
speaking: 9,
reading: 9,
listening: 9,
writing: 9,
speaking: 9,
};
const DEFAULT_LEVELS = {
reading: 0,
listening: 0,
writing: 0,
speaking: 0,
reading: 0,
listening: 0,
writing: 0,
speaking: 0,
};
async function register(req: NextApiRequest, res: NextApiResponse) {
const {type} = req.body as {
type: "individual" | "corporate";
};
const { type } = req.body as {
type: "individual" | "corporate";
};
if (type === "individual") return registerIndividual(req, res);
if (type === "corporate") return registerCorporate(req, res);
if (type === "individual") return registerIndividual(req, res);
if (type === "corporate") return registerCorporate(req, res);
}
async function registerIndividual(req: NextApiRequest, res: NextApiResponse) {
const {email, passport_id, password, code} = req.body as {
email: string;
passport_id?: string;
password: string;
code?: string;
};
const { email, passport_id, password, code } = req.body as {
email: string;
passport_id?: string;
password: string;
code?: string;
};
const codeQuery = query(collection(db, "codes"), where("code", "==", code));
const codeDocs = (await getDocs(codeQuery)).docs.filter((x) => !Object.keys(x.data()).includes("userId"));
const codeQuery = query(collection(db, "codes"), where("code", "==", code));
const codeDocs = (await getDocs(codeQuery)).docs.filter(
(x) => !Object.keys(x.data()).includes("userId"),
);
if (code && code.length > 0 && codeDocs.length === 0) {
res.status(400).json({error: "Invalid Code!"});
return;
}
if (code && code.length > 0 && codeDocs.length === 0) {
res.status(400).json({ error: "Invalid Code!" });
return;
}
const codeData = codeDocs.length > 0 ? (codeDocs[0].data() as {code: string; type: Type; creator?: string; expiryDate: Date | null}) : undefined;
const codeData =
codeDocs.length > 0
? (codeDocs[0].data() as {
code: string;
type: Type;
creator?: string;
expiryDate: Date | null;
})
: undefined;
createUserWithEmailAndPassword(auth, email, password)
.then(async (userCredentials) => {
const userId = userCredentials.user.uid;
delete req.body.password;
createUserWithEmailAndPassword(auth, email.toLowerCase(), password)
.then(async (userCredentials) => {
const userId = userCredentials.user.uid;
delete req.body.password;
const user = {
...req.body,
desiredLevels: DEFAULT_DESIRED_LEVELS,
levels: DEFAULT_LEVELS,
bio: "",
isFirstLogin: codeData ? codeData.type === "student" : true,
focus: "academic",
type: email.endsWith("@ecrop.dev") ? "developer" : codeData ? codeData.type : "student",
subscriptionExpirationDate: codeData ? codeData.expiryDate : moment().subtract(1, "days").toISOString(),
...(passport_id ? {demographicInformation: {passport_id}} : {}),
registrationDate: new Date(),
status: code ? "active" : "paymentDue",
};
const user = {
...req.body,
email: email.toLowerCase(),
desiredLevels: DEFAULT_DESIRED_LEVELS,
levels: DEFAULT_LEVELS,
bio: "",
isFirstLogin: codeData ? codeData.type === "student" : true,
focus: "academic",
type: email.endsWith("@ecrop.dev")
? "developer"
: codeData
? codeData.type
: "student",
subscriptionExpirationDate: codeData
? codeData.expiryDate
: moment().subtract(1, "days").toISOString(),
...(passport_id ? { demographicInformation: { passport_id } } : {}),
registrationDate: new Date().toISOString(),
status: code ? "active" : "paymentDue",
};
await setDoc(doc(db, "users", userId), user);
await setDoc(doc(db, "users", userId), user);
if (codeDocs.length > 0 && codeData) {
await setDoc(codeDocs[0].ref, {userId: userId}, {merge: true});
if (codeData.creator) await addUserToGroupOnCreation(userId, codeData.type, codeData.creator);
}
if (codeDocs.length > 0 && codeData) {
await setDoc(codeDocs[0].ref, { userId: userId }, { merge: true });
if (codeData.creator)
await addUserToGroupOnCreation(
userId,
codeData.type,
codeData.creator,
);
}
req.session.user = {...user, id: userId};
await req.session.save();
req.session.user = { ...user, id: userId };
await req.session.save();
res.status(200).json({user: {...user, id: userId}});
})
.catch((error) => {
console.log(error);
res.status(401).json({error});
});
res.status(200).json({ user: { ...user, id: userId } });
})
.catch((error) => {
console.log(error);
res.status(401).json({ error });
});
}
async function registerCorporate(req: NextApiRequest, res: NextApiResponse) {
const {email, password} = req.body as {
email: string;
password: string;
corporateInformation: CorporateInformation;
};
const { email, password } = req.body as {
email: string;
password: string;
corporateInformation: CorporateInformation;
};
createUserWithEmailAndPassword(auth, email, password)
.then(async (userCredentials) => {
const userId = userCredentials.user.uid;
delete req.body.password;
createUserWithEmailAndPassword(auth, email.toLowerCase(), password)
.then(async (userCredentials) => {
const userId = userCredentials.user.uid;
delete req.body.password;
const user = {
...req.body,
desiredLevels: DEFAULT_DESIRED_LEVELS,
levels: DEFAULT_LEVELS,
bio: "",
isFirstLogin: false,
focus: "academic",
type: "corporate",
subscriptionExpirationDate: req.body.subscriptionExpirationDate || null,
status: "paymentDue",
registrationDate: new Date().toISOString(),
};
const user = {
...req.body,
email: email.toLowerCase(),
desiredLevels: DEFAULT_DESIRED_LEVELS,
levels: DEFAULT_LEVELS,
bio: "",
isFirstLogin: false,
focus: "academic",
type: "corporate",
subscriptionExpirationDate: req.body.subscriptionExpirationDate || null,
status: "paymentDue",
registrationDate: new Date().toISOString(),
};
await setDoc(doc(db, "users", userId), user);
await setDoc(doc(db, "users", userId), user);
req.session.user = {...user, id: userId};
await req.session.save();
req.session.user = { ...user, id: userId };
await req.session.save();
res.status(200).json({user: {...user, id: userId}});
})
.catch((error) => {
console.log(error);
res.status(401).json({error});
});
res.status(200).json({ user: { ...user, id: userId } });
})
.catch((error) => {
console.log(error);
res.status(401).json({ error });
});
}

View File

@@ -0,0 +1,54 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, doc, getDoc, deleteDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {Session} from "@/hooks/useSessions";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return get(req, res);
if (req.method === "DELETE") return del(req, res);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const {id} = req.query as {id: string};
const docRef = doc(db, "sessions", id);
const docSnap = await getDoc(docRef);
if (docSnap.exists()) {
res.status(200).json({
id: docSnap.id,
...docSnap.data(),
});
} else {
res.status(404).json(undefined);
}
}
async function del(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const {id} = req.query as {id: string};
const docRef = doc(db, "sessions", id);
const docSnap = await getDoc(docRef);
if (!docSnap.exists()) return res.status(404).json({ok: false});
await deleteDoc(docRef);
return res.status(200).json({ok: true});
}

View File

@@ -0,0 +1,46 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, collection, getDocs, query, where, doc, setDoc, addDoc, getDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return get(req, res);
if (req.method === "POST") return post(req, res);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const {user} = req.query as {user?: string};
const q = user ? query(collection(db, "sessions"), where("user", "==", user)) : collection(db, "sessions");
const snapshot = await getDocs(q);
res.status(200).json(
snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})),
);
}
async function post(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const session = req.body;
await setDoc(doc(db, "sessions", session.id), session, {merge: true});
res.status(200).json({ok: true});
}

View File

@@ -1,23 +0,0 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc, deleteDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {uuidv4} from "@firebase/util";
const db = getFirestore(app);
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return GET(req, res);
res.status(404).json({ok: false});
}
async function GET(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query;
const snapshot = await getDoc(doc(db, "stats", id as string));
res.status(200).json({...snapshot.data(), id: snapshot.id});
}

View File

@@ -1,33 +1,20 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { app, storage } from "@/firebase";
import {
getFirestore,
doc,
getDoc,
updateDoc,
getDocs,
query,
collection,
where,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import type {NextApiRequest, NextApiResponse} from "next";
import {app, storage} from "@/firebase";
import {getFirestore, doc, getDoc, updateDoc, getDocs, query, collection, where} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import ReactPDF from "@react-pdf/renderer";
import TestReport from "@/exams/pdf/test.report";
import { ref, uploadBytes, getDownloadURL } from "firebase/storage";
import { DemographicInformation, User } from "@/interfaces/user";
import { Module } from "@/interfaces";
import { ModuleScore } from "@/interfaces/module.scores";
import { SkillExamDetails } from "@/exams/pdf/details/skill.exam";
import { LevelExamDetails } from "@/exams/pdf/details/level.exam";
import { calculateBandScore } from "@/utils/score";
import {ref, uploadBytes, getDownloadURL} from "firebase/storage";
import {DemographicInformation, User} from "@/interfaces/user";
import {Module} from "@/interfaces";
import {ModuleScore} from "@/interfaces/module.scores";
import {SkillExamDetails} from "@/exams/pdf/details/skill.exam";
import {LevelExamDetails} from "@/exams/pdf/details/level.exam";
import {calculateBandScore} from "@/utils/score";
import axios from "axios";
import { moduleLabels } from "@/utils/moduleUtils";
import {
generateQRCode,
getRadialProgressPNG,
streamToBuffer,
} from "@/utils/pdf";
import {moduleLabels} from "@/utils/moduleUtils";
import {generateQRCode, getRadialProgressPNG, streamToBuffer} from "@/utils/pdf";
import moment from "moment-timezone";
const db = getFirestore(app);
@@ -35,350 +22,330 @@ const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return get(req, res);
if (req.method === "POST") return post(req, res);
if (req.method === "GET") return get(req, res);
if (req.method === "POST") return post(req, res);
}
const getExamSummary = (score: number) => {
if (score > 0.8) {
return "Scoring between 81% and 100% on the English exam demonstrates an outstanding level of proficiency in writing, speaking, listening, and reading. Mastery of key concepts is evident across all language domains, showcasing not only a high level of skill but also a dedication to excellence. Continuing to challenge oneself with advanced material in writing, speaking, listening, and reading will further refine the already impressive command of the English language.";
}
if (score > 0.8) {
return "Scoring between 81% and 100% on the English exam demonstrates an outstanding level of proficiency in writing, speaking, listening, and reading. Mastery of key concepts is evident across all language domains, showcasing not only a high level of skill but also a dedication to excellence. Continuing to challenge oneself with advanced material in writing, speaking, listening, and reading will further refine the already impressive command of the English language.";
}
if (score > 0.6) {
return "Scoring between 61% and 80% on the English exam, encompassing writing, speaking, listening, and reading, reflects a commendable level of proficiency in each domain. There's evidence of a solid grasp of key concepts, and effective application of skills. Room for refinement and deeper exploration in writing, speaking, listening, and reading remains, presenting an opportunity for further mastery.";
}
if (score > 0.6) {
return "Scoring between 61% and 80% on the English exam, encompassing writing, speaking, listening, and reading, reflects a commendable level of proficiency in each domain. There's evidence of a solid grasp of key concepts, and effective application of skills. Room for refinement and deeper exploration in writing, speaking, listening, and reading remains, presenting an opportunity for further mastery.";
}
if (score > 0.4) {
return "Scoring between 41% and 60% on the English exam across writing, speaking, listening, and reading demonstrates a moderate level of understanding in each domain. While there's a commendable grasp of key concepts, refining fundamental skills in writing, speaking, listening, and reading can lead to notable improvement. Consistent effort and targeted focus on weaker areas are recommended.";
}
if (score > 0.4) {
return "Scoring between 41% and 60% on the English exam across writing, speaking, listening, and reading demonstrates a moderate level of understanding in each domain. While there's a commendable grasp of key concepts, refining fundamental skills in writing, speaking, listening, and reading can lead to notable improvement. Consistent effort and targeted focus on weaker areas are recommended.";
}
if (score > 0.2) {
return "Scoring between 21% and 40% on the English exam, spanning writing, speaking, listening, and reading, indicates some understanding of key concepts in each domain. However, there's room for improvement in fundamental skills. Strengthening writing, speaking, listening, and reading abilities through consistent effort and focused study will contribute to overall proficiency.";
}
if (score > 0.2) {
return "Scoring between 21% and 40% on the English exam, spanning writing, speaking, listening, and reading, indicates some understanding of key concepts in each domain. However, there's room for improvement in fundamental skills. Strengthening writing, speaking, listening, and reading abilities through consistent effort and focused study will contribute to overall proficiency.";
}
return "This student's performance on the English exam, encompassing writing, speaking, listening, and reading, reflects a significant need for improvement, scoring between 0% and 20%. There's a notable gap in understanding key concepts across all language domains. Strengthening fundamental skills in writing, speaking, listening, and reading is crucial. Developing a consistent study routine and seeking additional support in each area can contribute to substantial progress.";
return "This student's performance on the English exam, encompassing writing, speaking, listening, and reading, reflects a significant need for improvement, scoring between 0% and 20%. There's a notable gap in understanding key concepts across all language domains. Strengthening fundamental skills in writing, speaking, listening, and reading is crucial. Developing a consistent study routine and seeking additional support in each area can contribute to substantial progress.";
};
const getLevelSummary = (score: number) => {
if (score > 0.8) {
return "Scoring between 81% and 100% on the English exam showcases an outstanding level of understanding and proficiency. Your performance reflects a mastery of key concepts, including grammar, vocabulary, and comprehension. You exhibit a high level of skill in applying these elements effectively. Your dedication to excellence is evident, and your consistent, stellar performance is commendable. Continue to challenge yourself with advanced material to further refine your already impressive command of the English language. Your commitment to excellence positions you as a standout student in English studies, and your achievements are a testament to your hard work and capability.";
}
if (score > 0.8) {
return "Scoring between 81% and 100% on the English exam showcases an outstanding level of understanding and proficiency. Your performance reflects a mastery of key concepts, including grammar, vocabulary, and comprehension. You exhibit a high level of skill in applying these elements effectively. Your dedication to excellence is evident, and your consistent, stellar performance is commendable. Continue to challenge yourself with advanced material to further refine your already impressive command of the English language. Your commitment to excellence positions you as a standout student in English studies, and your achievements are a testament to your hard work and capability.";
}
if (score > 0.6) {
return "Scoring between 61% and 80% on the English exam reflects a commendable level of understanding and proficiency. You have demonstrated a solid grasp of key concepts, including grammar, vocabulary, and comprehension. There's evidence of effective application of skills, but room for refinement and deeper exploration remains. Consistent effort in honing nuanced aspects of language will contribute to even greater mastery. Continue engaging with challenging material and seeking opportunities for advanced comprehension. With sustained dedication, you have the potential to elevate your performance to an exceptional level and further excel in your English studies.";
}
if (score > 0.6) {
return "Scoring between 61% and 80% on the English exam reflects a commendable level of understanding and proficiency. You have demonstrated a solid grasp of key concepts, including grammar, vocabulary, and comprehension. There's evidence of effective application of skills, but room for refinement and deeper exploration remains. Consistent effort in honing nuanced aspects of language will contribute to even greater mastery. Continue engaging with challenging material and seeking opportunities for advanced comprehension. With sustained dedication, you have the potential to elevate your performance to an exceptional level and further excel in your English studies.";
}
if (score > 0.4) {
return "Scoring between 41% and 60% on the English exam reflects a moderate level of understanding. You demonstrate a grasp of some key concepts, but there's room for refinement in areas like grammar, vocabulary, and comprehension. Consistent effort and a strategic focus on weaker areas can lead to notable improvement. Engaging with supplementary resources and seeking feedback will further enhance your skills. With continued dedication, there's a solid foundation to build upon, and achieving a higher level of proficiency is within reach. Keep up the good work and aim for sustained progress in your English studies.";
}
if (score > 0.4) {
return "Scoring between 41% and 60% on the English exam reflects a moderate level of understanding. You demonstrate a grasp of some key concepts, but there's room for refinement in areas like grammar, vocabulary, and comprehension. Consistent effort and a strategic focus on weaker areas can lead to notable improvement. Engaging with supplementary resources and seeking feedback will further enhance your skills. With continued dedication, there's a solid foundation to build upon, and achieving a higher level of proficiency is within reach. Keep up the good work and aim for sustained progress in your English studies.";
}
if (score > 0.2) {
return "Scoring between 21% and 40% on the English exam shows some understanding of key concepts, but there's still ample room for improvement. Strengthening foundational skills, such as grammar, vocabulary, and comprehension, is essential. Consistent effort and focused study can help bridge gaps in knowledge and elevate your performance. Consider seeking additional guidance or resources to refine your understanding of the material. With commitment and targeted improvements, you have the potential to make significant strides in your English proficiency.";
}
if (score > 0.2) {
return "Scoring between 21% and 40% on the English exam shows some understanding of key concepts, but there's still ample room for improvement. Strengthening foundational skills, such as grammar, vocabulary, and comprehension, is essential. Consistent effort and focused study can help bridge gaps in knowledge and elevate your performance. Consider seeking additional guidance or resources to refine your understanding of the material. With commitment and targeted improvements, you have the potential to make significant strides in your English proficiency.";
}
return "Your performance on the English exam falls within the 0% to 20% range, indicating a need for improvement. There's room to enhance your grasp of fundamental concepts like grammar, vocabulary, and comprehension. Establishing a consistent study routine and seeking extra support can be beneficial. With dedication and targeted efforts, you have the potential to significantly boost your performance in upcoming assessments.";
return "Your performance on the English exam falls within the 0% to 20% range, indicating a need for improvement. There's room to enhance your grasp of fundamental concepts like grammar, vocabulary, and comprehension. Establishing a consistent study routine and seeking extra support can be beneficial. With dedication and targeted efforts, you have the potential to significantly boost your performance in upcoming assessments.";
};
const getPerformanceSummary = (module: Module, score: number) => {
if (module === "level") return getLevelSummary(score);
return getExamSummary(score);
if (module === "level") return getLevelSummary(score);
return getExamSummary(score);
};
interface SkillsFeedbackRequest {
code: Module;
name: string;
grade: number;
code: Module;
name: string;
grade: number;
}
interface SkillsFeedbackResponse extends SkillsFeedbackRequest {
evaluation: string;
suggestions: string;
evaluation: string;
suggestions: string;
}
const getSkillsFeedback = async (sections: SkillsFeedbackRequest[]) => {
const backendRequest = await axios.post(
`${process.env.BACKEND_URL}/grading_summary`,
{ sections },
{
headers: {
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
},
}
);
const backendRequest = await axios.post(
`${process.env.BACKEND_URL}/grading_summary`,
{sections},
{
headers: {
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
},
},
);
return backendRequest.data?.sections;
return backendRequest.data?.sections;
};
// perform the request with several retries if needed
const handleSkillsFeedbackRequest = async (
sections: SkillsFeedbackRequest[]
): Promise<SkillsFeedbackResponse[] | null> => {
let i = 0;
try {
const data = await getSkillsFeedback(sections);
return data;
} catch (err) {
if (i < 3) {
i++;
return handleSkillsFeedbackRequest(sections);
}
const handleSkillsFeedbackRequest = async (sections: SkillsFeedbackRequest[]): Promise<SkillsFeedbackResponse[] | null> => {
let i = 0;
try {
const data = await getSkillsFeedback(sections);
return data;
} catch (err) {
if (i < 3) {
i++;
return handleSkillsFeedbackRequest(sections);
}
return null;
}
return null;
}
};
async function post(req: NextApiRequest, res: NextApiResponse) {
// verify if it's a logged user that is trying to export
if (req.session.user) {
const { id } = req.query as { id: string };
// fetch stats entries for this particular user with the requested exam session
const docsSnap = await getDocs(
query(
collection(db, "stats"),
where("session", "==", id),
where("user", "==", req.session.user.id)
)
);
// verify if it's a logged user that is trying to export
if (req.session.user) {
const {id} = req.query as {id: string};
// fetch stats entries for this particular user with the requested exam session
const docsSnap = await getDocs(query(collection(db, "stats"), where("session", "==", id)));
if (docsSnap.empty) {
res.status(400).end();
return;
}
if (docsSnap.empty) {
res.status(400).end();
return;
}
const stats = docsSnap.docs.map((d) => d.data());
// verify if the stats already have a pdf generated
const hasPDF = stats.find((s) => s.pdf);
const stats = docsSnap.docs.map((d) => d.data());
// verify if the stats already have a pdf generated
const hasPDF = stats.find((s) => s.pdf?.path && s.pdf?.version === process.env.PDF_VERSION);
// find the user that generated the stats
const statIndex = stats.findIndex((s) => s.user);
if (hasPDF) {
// if it does, return the pdf url
const fileRef = ref(storage, hasPDF.pdf);
const url = await getDownloadURL(fileRef);
if(statIndex === -1) {
res.status(401).json({ok: false});
return;
}
const userId = stats[statIndex].user;
res.status(200).end(url);
return;
}
if (hasPDF) {
// if it does, return the pdf url
const fileRef = ref(storage, hasPDF.pdf.path);
const url = await getDownloadURL(fileRef);
try {
// generate the pdf report
const docUser = await getDoc(doc(db, "users", req.session.user.id));
res.status(200).end(url);
return;
}
if (docUser.exists()) {
// we'll need the user in order to get the user data (name, email, focus, etc);
const user = docUser.data() as User;
try {
// generate the pdf report
const docUser = await getDoc(doc(db, "users", userId));
// generate the QR code for the report
const qrcode = await generateQRCode(
(req.headers.origin || "") + req.url
);
if (docUser.exists()) {
// we'll need the user in order to get the user data (name, email, focus, etc);
const user = docUser.data() as User;
if (!qrcode) {
res.status(500).json({ ok: false });
return;
}
// generate the QR code for the report
const qrcode = await generateQRCode((req.headers.origin || "") + req.url);
// stats may contain multiple exams of the same type so we need to aggregate them
const results = (
stats.reduce((accm: ModuleScore[], { module, score }) => {
const fixedModuleStr =
module[0].toUpperCase() + module.substring(1);
if (accm.find((e: ModuleScore) => e.module === fixedModuleStr)) {
return accm.map((e: ModuleScore) => {
if (e.module === fixedModuleStr) {
return {
...e,
score: e.score + score.correct,
total: e.total + score.total,
};
}
if (!qrcode) {
res.status(500).json({ok: false});
return;
}
return e;
});
}
// stats may contain multiple exams of the same type so we need to aggregate them
const results = (
stats.reduce((accm: ModuleScore[], {module, score}) => {
const fixedModuleStr = module[0].toUpperCase() + module.substring(1);
if (accm.find((e: ModuleScore) => e.module === fixedModuleStr)) {
return accm.map((e: ModuleScore) => {
if (e.module === fixedModuleStr) {
return {
...e,
score: e.score + score.correct,
total: e.total + score.total,
};
}
return [
...accm,
{
module: fixedModuleStr,
score: score.correct,
total: score.total,
code: module,
},
];
}, []) as ModuleScore[]
).map((moduleScore) => {
const { score, total } = moduleScore;
// with all the scores aggreated we can calculate the band score for each module
const bandScore = calculateBandScore(
score,
total,
moduleScore.code as Module,
user.focus
);
return e;
});
}
return {
...moduleScore,
// generate the closest radial progress png for the score
png: getRadialProgressPNG("azul", score, total),
bandScore,
};
});
return [
...accm,
{
module: fixedModuleStr,
score: score.correct,
total: score.total,
code: module,
},
];
}, []) as ModuleScore[]
).map((moduleScore) => {
const {score, total} = moduleScore;
// with all the scores aggreated we can calculate the band score for each module
const bandScore = calculateBandScore(score, total, moduleScore.code as Module, user.focus);
// get the skills feedback from the backend based on the module grade
const skillsFeedback = (await handleSkillsFeedbackRequest(
results.map(({ code, bandScore }) => ({
code,
name: moduleLabels[code],
grade: bandScore,
}))
)) as SkillsFeedbackResponse[];
return {
...moduleScore,
// generate the closest radial progress png for the score
png: getRadialProgressPNG("azul", score, total),
bandScore,
};
});
if (!skillsFeedback) {
res.status(500).json({ ok: false });
return;
}
// get the skills feedback from the backend based on the module grade
const skillsFeedback = (await handleSkillsFeedbackRequest(
results.map(({code, bandScore}) => ({
code,
name: moduleLabels[code],
grade: bandScore,
})),
)) as SkillsFeedbackResponse[];
// assign the feedback to the results
const finalResults = results.map((result) => {
const feedback = skillsFeedback.find(
(f: SkillsFeedbackResponse) => f.code === result.code
);
if (!skillsFeedback) {
res.status(500).json({ok: false});
return;
}
if (feedback) {
return {
...result,
evaluation: feedback?.evaluation,
suggestions: feedback?.suggestions,
};
}
// assign the feedback to the results
const finalResults = results.map((result) => {
const feedback = skillsFeedback.find((f: SkillsFeedbackResponse) => f.code === result.code);
return result;
});
if (feedback) {
return {
...result,
evaluation: feedback?.evaluation,
suggestions: feedback?.suggestions,
};
}
// calculate the overall score out of all the aggregated results
const overallScore = results.reduce(
(accm, { score }) => accm + score,
0
);
const overallTotal = results.reduce(
(accm, { total }) => accm + total,
0
);
const overallResult = overallScore / overallTotal;
return result;
});
const overallPNG = getRadialProgressPNG("laranja", overallScore, overallTotal);
// calculate the overall score out of all the aggregated results
const overallScore = results.reduce((accm, {score}) => accm + score, 0);
const overallTotal = results.reduce((accm, {total}) => accm + total, 0);
const overallResult = overallScore / overallTotal;
// generate the overall detail report
const overallDetail = {
module: "Overall",
score: overallScore,
total: overallTotal,
png: overallPNG,
} as ModuleScore;
const testDetails = [overallDetail, ...finalResults];
const overallPNG = getRadialProgressPNG("laranja", overallScore, overallTotal);
const [stat] = stats;
// generate the overall detail report
const overallDetail = {
module: "Overall",
score: overallScore,
total: overallTotal,
png: overallPNG,
} as ModuleScore;
const testDetails = [overallDetail, ...finalResults];
// generate the performance summary based on the overall result
const performanceSummary = getPerformanceSummary(
stat.module,
overallResult
);
const [stat] = stats;
// level exams have a different report structure than the skill exams
const getCustomData = () => {
if (stat.module === "level") {
return {
title: "ENGLISH LEVEL TEST RESULT REPORT ",
details: (
<LevelExamDetails
detail={overallDetail}
title="Level as per CEFR Levels"
/>
),
};
}
// generate the performance summary based on the overall result
const performanceSummary = getPerformanceSummary(stat.module, overallResult);
return {
title: "ENGLISH SKILLS TEST RESULT REPORT",
details: <SkillExamDetails testDetails={testDetails} />,
};
};
// level exams have a different report structure than the skill exams
const getCustomData = () => {
if (stat.module === "level") {
return {
title: "ENGLISH LEVEL TEST RESULT REPORT ",
details: <LevelExamDetails detail={overallDetail} title="Level as per CEFR Levels" />,
};
}
const { title, details } = getCustomData();
const demographicInformation = user.demographicInformation as DemographicInformation;
const pdfStream = await ReactPDF.renderToStream(
<TestReport
title={title}
date={moment(stat.date).tz(user.demographicInformation?.timezone || 'UTC').format('ll HH:mm:ss')}
name={user.name}
email={user.email}
id={user.id}
gender={demographicInformation?.gender}
summary={performanceSummary}
testDetails={testDetails}
renderDetails={details}
logo={"public/logo_title.png"}
qrcode={qrcode}
summaryPNG={overallPNG}
summaryScore={`${(overallResult * 100).toFixed(0)}%`}
passportId={demographicInformation?.passport_id || ""}
/>
);
return {
title: "ENGLISH SKILLS TEST RESULT REPORT",
details: <SkillExamDetails testDetails={testDetails} />,
};
};
// generate the file ref for storage
const fileName = `${Date.now().toString()}.pdf`;
const refName = `exam_report/${fileName}`;
const fileRef = ref(storage, refName);
const {title, details} = getCustomData();
// upload the pdf to storage
const pdfBuffer = await streamToBuffer(pdfStream);
const snapshot = await uploadBytes(fileRef, pdfBuffer, {
contentType: "application/pdf",
});
const demographicInformation = user.demographicInformation as DemographicInformation;
const pdfStream = await ReactPDF.renderToStream(
<TestReport
title={title}
date={moment(stat.date)
.tz(user.demographicInformation?.timezone || "UTC")
.format("ll HH:mm:ss")}
name={user.name}
email={user.email}
id={userId}
gender={demographicInformation?.gender}
summary={performanceSummary}
testDetails={testDetails}
renderDetails={details}
logo={"public/logo_title.png"}
qrcode={qrcode}
summaryPNG={overallPNG}
summaryScore={`${(overallResult * 100).toFixed(0)}%`}
passportId={demographicInformation?.passport_id || ""}
/>,
);
// update the stats entries with the pdf url to prevent duplication
docsSnap.docs.forEach(async (doc) => {
await updateDoc(doc.ref, {
pdf: refName,
});
});
const url = await getDownloadURL(fileRef);
res.status(200).end(url);
return;
}
// generate the file ref for storage
const fileName = `${Date.now().toString()}.pdf`;
const refName = `exam_report/${fileName}`;
const fileRef = ref(storage, refName);
res.status(401).json({ ok: false });
return;
} catch (err) {
res.status(500).json({ ok: false });
return;
}
}
// upload the pdf to storage
const pdfBuffer = await streamToBuffer(pdfStream);
const snapshot = await uploadBytes(fileRef, pdfBuffer, {
contentType: "application/pdf",
});
res.status(401).json({ ok: false });
return;
// update the stats entries with the pdf url to prevent duplication
docsSnap.docs.forEach(async (doc) => {
await updateDoc(doc.ref, {
pdf: {
path: refName,
version: process.env.PDF_VERSION,
},
});
});
const url = await getDownloadURL(fileRef);
res.status(200).end(url);
return;
}
res.status(401).json({ok: false});
return;
} catch (err) {
res.status(500).json({ok: false});
return;
}
}
res.status(401).json({ok: false});
return;
}
async function get(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.query as { id: string };
const docsSnap = await getDocs(
query(collection(db, "stats"), where("session", "==", id))
);
const {id} = req.query as {id: string};
const docsSnap = await getDocs(query(collection(db, "stats"), where("session", "==", id)));
if (docsSnap.empty) {
res.status(404).end();
return;
}
if (docsSnap.empty) {
res.status(404).end();
return;
}
const stats = docsSnap.docs.map((d) => d.data());
const stats = docsSnap.docs.map((d) => d.data());
const hasPDF = stats.find((s) => s.pdf);
const hasPDF = stats.find((s) => s.pdf?.path);
if (hasPDF) {
const fileRef = ref(storage, hasPDF.pdf);
const url = await getDownloadURL(fileRef);
return res.redirect(url);
}
if (hasPDF) {
const fileRef = ref(storage, hasPDF.pdf.path);
const url = await getDownloadURL(fileRef);
return res.redirect(url);
}
res.status(500).end();
res.status(500).end();
}

View File

@@ -1,7 +1,7 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, collection, getDocs, query, where, doc, setDoc, addDoc, getDoc} from "firebase/firestore";
import {getFirestore, collection, getDocs, query, where, doc, setDoc, addDoc, getDoc, deleteDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {Stat} from "@/interfaces/user";
@@ -43,6 +43,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const stats = req.body as Stat[];
await stats.forEach(async (stat) => await setDoc(doc(db, "stats", stat.id), stat));
await stats.forEach(async (stat) => {
const sessionDoc = await getDoc(doc(db, "sessions", stat.session));
if (sessionDoc.exists()) await deleteDoc(sessionDoc.ref);
});
const groupedStatsByAssignment = groupBy(
stats.filter((x) => !!x.assignment),

View File

@@ -0,0 +1,28 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, doc, getDoc, deleteDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {Session} from "@/hooks/useSessions";
import {deleteObject, getStorage, ref} from "firebase/storage";
const storage = getStorage(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") return post(req, res);
}
async function post(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const {path} = req.body as {path: string};
await deleteObject(ref(storage, path));
return res.status(200).json({ok: true});
}

View File

@@ -0,0 +1,45 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import axios, {AxiosResponse} from "axios";
import formidable from "formidable-serverless";
import {getDownloadURL, ref, uploadBytes} from "firebase/storage";
import fs from "fs";
import {app, storage} from "@/firebase";
import {doc, getDoc, getFirestore, setDoc} from "firebase/firestore";
import {Stat} from "@/interfaces/user";
import {speakingReverseMarking} from "@/utils/score";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const form = formidable({keepExtensions: true});
await form.parse(req, async (err: any, fields: any, files: any) => {
if (err) {
console.log(err);
return res.status(500).json({ok: false});
}
const audioFile = files.audio;
const audioFileRef = ref(storage, `${fields.root}/${(audioFile as any).path.split("/").pop()!.replace("upload_", "")}`);
const binary = fs.readFileSync((audioFile as any).path).buffer;
const snapshot = await uploadBytes(audioFileRef, binary);
const path = await getDownloadURL(snapshot.ref);
res.status(200).json({path});
});
}
export const config = {
api: {
bodyParser: false,
},
};

View File

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

View File

@@ -0,0 +1,69 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { sendEmail } from "@/email";
import { app } from "@/firebase";
import { Ticket, TicketTypeLabel } from "@/interfaces/ticket";
import { sessionOptions } from "@/lib/session";
import {
collection,
doc,
getDocs,
getFirestore,
setDoc,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import moment from "moment";
import type { NextApiRequest, NextApiResponse } from "next";
import ShortUniqueId from "short-unique-id";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
if (req.method === "GET") await get(req, res);
if (req.method === "POST") await post(req, res);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
const snapshot = await getDocs(collection(db, "tickets"));
res.status(200).json(
snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})),
);
}
async function post(req: NextApiRequest, res: NextApiResponse) {
const body = req.body as Ticket;
const shortUID = new ShortUniqueId();
const id = body.id || shortUID.randomUUID(8);
await setDoc(doc(db, "tickets", id), body);
res.status(200).json({ ok: true });
try {
await sendEmail(
"submittedFeedback",
{
id,
subject: body.subject,
reporter: body.reporter,
date: moment(body.date).format("DD/MM/YYYY - HH:mm"),
type: TicketTypeLabel[body.type],
reportedFrom: body.reportedFrom,
description: body.description,
},
[body.reporter.email],
`Ticket ${id}: ${body.subject}`,
);
} catch (e) {
console.log(e);
}
}

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