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 {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 countryCodes from "country-codes-list";
import {RadioGroup} from "@headlessui/react"; import {RadioGroup} from "@headlessui/react";
import Input from "./Low/Input"; import Input from "./Low/Input";
@@ -12,6 +12,8 @@ import {KeyedMutator} from "swr";
import CountrySelect from "./Low/CountrySelect"; import CountrySelect from "./Low/CountrySelect";
import GenderInput from "@/components/High/GenderInput"; import GenderInput from "@/components/High/GenderInput";
import EmploymentStatusInput from "@/components/High/EmploymentStatusInput"; import EmploymentStatusInput from "@/components/High/EmploymentStatusInput";
import TimezoneSelect from "./Low/TImezoneSelect";
import moment from "moment";
interface Props { interface Props {
user: User; user: User;
@@ -25,6 +27,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
const [gender, setGender] = useState<Gender>(); const [gender, setGender] = useState<Gender>();
const [employment, setEmployment] = useState<EmploymentStatus>(); const [employment, setEmployment] = useState<EmploymentStatus>();
const [position, setPosition] = useState<string>(); const [position, setPosition] = useState<string>();
const [timezone, setTimezone] = useState<string>(moment.tz.guess());
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [companyName, setCompanyName] = useState<string>(); const [companyName, setCompanyName] = useState<string>();
@@ -43,6 +46,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
employment: user.type === "corporate" ? undefined : employment, employment: user.type === "corporate" ? undefined : employment,
position: user.type === "corporate" ? position : undefined, position: user.type === "corporate" ? position : undefined,
passport_id, passport_id,
timezone,
}, },
agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined, agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined,
}) })
@@ -94,6 +98,12 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
required 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} /> <GenderInput value={gender} onChange={setGender} />
{user.type === "corporate" && ( {user.type === "corporate" && (
<Input name="position" onChange={setPosition} type="text" label="Position" placeholder="CEO, Head of Marketing..." required /> <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 {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen, BsQuestionSquare} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import Button from "./Low/Button"; import Button from "./Low/Button";
import ModuleLevelSelector from "./Medium/ModuleLevelSelector";
interface Props { interface Props {
user: User; user: User;
@@ -36,7 +37,7 @@ export default function Diagnostic({onFinish}: Props) {
}; };
const selectExam = () => { 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) => { Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) { if (exams.every((x) => !!x)) {
@@ -90,111 +91,17 @@ export default function Diagnostic({onFinish}: Props) {
</div> </div>
</div> </div>
</div> </div>
<div className="flex flex-col items-center justify-center gap-8 w-full"> <div className="flex flex-col items-center justify-center gap-8 w-full">
<h2 className="font-semibold text-xl">What is your current IELTS level?</h2> <h2 className="font-semibold text-xl">What is your current IELTS level?</h2>
<div className="flex flex-col gap-32 w-full mb-20"> <ModuleLevelSelector levels={levels} setLevels={setLevels} />
<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="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>
<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="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"> <div className="w-full tooltip" data-tip="Your screen size is too small to perform a diagnostic test">
<Button <Button
@@ -224,7 +131,5 @@ export default function Diagnostic({onFinish}: Props) {
</Button> </Button>
</div> </div>
</div> </div>
</div>
</div>
); );
} }

View File

@@ -5,6 +5,8 @@ import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill}
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import Button from "../Low/Button"; import Button from "../Low/Button";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {downloadBlob} from "@/utils/evaluation";
import axios from "axios";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false}); const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), { 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({ export default function InteractiveSpeaking({
id, id,
title, title,
examID,
text, text,
type, type,
prompts, prompts,
userSolutions,
updateIndex, updateIndex,
onNext, onNext,
onBack, onBack,
@@ -24,20 +28,109 @@ export default function InteractiveSpeaking({
const [recordingDuration, setRecordingDuration] = useState(0); const [recordingDuration, setRecordingDuration] = useState(0);
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
const [mediaBlob, setMediaBlob] = useState<string>(); const [mediaBlob, setMediaBlob] = useState<string>();
const [promptIndex, setPromptIndex] = useState(0); const [answers, setAnswers] = useState<{prompt: string; blob: string; questionIndex: number}[]>([]);
const [answers, setAnswers] = useState<{prompt: string; blob: string}[]>([]); 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 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(() => { useEffect(() => {
if (updateIndex) updateIndex(promptIndex); if (userSolutions.length > 0 && answers.length === 0) {
}, [promptIndex, updateIndex]); 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(() => { useEffect(() => {
if (hasExamEnded) { if (hasExamEnded) {
const answer = {
questionIndex,
prompt: prompts[questionIndex].text,
blob: mediaBlob!,
};
onNext({ onNext({
exercise: id, exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [], solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 1, total: 1, missing: 0}, score: {correct: 1, total: 1, missing: 0},
type, type,
}); });
@@ -59,19 +152,38 @@ export default function InteractiveSpeaking({
}, [isRecording]); }, [isRecording]);
useEffect(() => { useEffect(() => {
if (promptIndex === answers.length - 1) { if (questionIndex <= answers.length - 1) {
setMediaBlob(answers[promptIndex].blob); 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 = { const answer = {
prompt: prompts[promptIndex].text, questionIndex,
blob: mediaBlob!, prompt: prompts[questionIndex].text,
blob: audioPath ? audioPath : mediaBlob!,
}; };
setAnswers((prev) => [...prev, answer]); setAnswers((prev) => [...prev.filter((x) => x.questionIndex !== index), answer]);
setMediaBlob(undefined); 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 ( return (
@@ -82,8 +194,8 @@ export default function InteractiveSpeaking({
</div> </div>
{prompts && prompts.length > 0 && ( {prompts && prompts.length > 0 && (
<div className="flex flex-col gap-4 w-full items-center"> <div className="flex flex-col gap-4 w-full items-center">
<video key={promptIndex} autoPlay controls className="max-w-3xl rounded-xl"> <video key={questionIndex} autoPlay controls className="max-w-3xl rounded-xl">
<source src={prompts[promptIndex].video_url} /> <source src={prompts[questionIndex].video_url} />
</video> </video>
</div> </div>
)} )}
@@ -91,13 +203,13 @@ export default function InteractiveSpeaking({
<ReactMediaRecorder <ReactMediaRecorder
audio audio
key={promptIndex} key={questionIndex}
onStop={(blob) => setMediaBlob(blob)} onStop={(blob) => setMediaBlob(blob)}
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => ( 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"> <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> <p className="text-base font-normal">Record your answer:</p>
<div className="flex gap-8 items-center justify-center py-8"> <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" /> <div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
{status === "idle" && ( {status === "idle" && (
@@ -176,9 +288,9 @@ export default function InteractiveSpeaking({
</div> </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"> <div className="flex gap-4 items-center">
<BsTrashFill <BsTrashFill
className="text-mti-gray-cool cursor-pointer w-5 h-5" 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"> <div className="self-end flex justify-between w-full gap-8">
<Button <Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
color="purple"
variant="outline"
onClick={() =>
onBack({
exercise: id,
solutions: answers,
score: {correct: 1, total: 1, missing: 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Back Back
</Button> </Button>
<Button <Button color="purple" disabled={!mediaBlob} isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
color="purple" {questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
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> </Button>
</div> </div>
</div> </div>

View File

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

View File

@@ -5,28 +5,58 @@ import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill}
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import Button from "../Low/Button"; import Button from "../Low/Button";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {downloadBlob} from "@/utils/evaluation";
import axios from "axios";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false}); const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), { const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
ssr: false, 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 [recordingDuration, setRecordingDuration] = useState(0);
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
const [mediaBlob, setMediaBlob] = useState<string>(); const [mediaBlob, setMediaBlob] = useState<string>();
const [audioURL, setAudioURL] = useState<string>();
const [isLoading, setIsLoading] = useState(false);
const hasExamEnded = useExamStore((state) => state.hasExamEnded); const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => { const saveToStorage = async () => {
if (hasExamEnded) { if (mediaBlob && mediaBlob.startsWith("blob")) {
onNext({ const blobBuffer = await downloadBlob(mediaBlob);
exercise: id, const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"});
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0}, const seed = Math.random().toString().replace("0.", "");
type,
}); 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]); }, [hasExamEnded]);
@@ -43,6 +73,32 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
}; };
}, [isRecording]); }, [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 ( return (
<div className="flex flex-col h-full w-full gap-9"> <div className="flex flex-col h-full w-full gap-9">
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16"> <div className="flex flex-col 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"> <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> <p className="text-base font-normal">Record your answer:</p>
<div className="flex gap-8 items-center justify-center py-8"> <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" /> <div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
{status === "idle" && ( {status === "idle" && (
@@ -168,9 +224,9 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
</div> </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"> <div className="flex gap-4 items-center">
<BsTrashFill <BsTrashFill
className="text-mti-gray-cool cursor-pointer w-5 h-5" 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"> <div className="self-end flex justify-between w-full gap-8">
<Button <Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
color="purple"
variant="outline"
onClick={() =>
onBack({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Back Back
</Button> </Button>
<Button <Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
color="purple"
disabled={!mediaBlob}
onClick={() =>
onNext({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Next Next
</Button> </Button>
</div> </div>

View File

@@ -22,9 +22,31 @@ export default function Writing({
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : ""); const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : "");
const [isSubmitEnabled, setIsSubmitEnabled] = useState(false); const [isSubmitEnabled, setIsSubmitEnabled] = useState(false);
const [saveTimer, setSaveTimer] = useState(0);
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
const hasExamEnded = useExamStore((state) => state.hasExamEnded); 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(() => { useEffect(() => {
if (localStorage.getItem("enable_paste")) return; if (localStorage.getItem("enable_paste")) return;
@@ -132,7 +154,14 @@ export default function Writing({
<Button <Button
color="purple" color="purple"
disabled={!isSubmitEnabled} 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"> className="max-w-[200px] self-end w-full">
Next Next
</Button> </Button>

View File

@@ -22,6 +22,7 @@ import InteractiveSpeaking from "./InteractiveSpeaking";
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false}); const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false});
export interface CommonProps { export interface CommonProps {
examID?: string;
updateIndex?: (internalIndex: number) => void; updateIndex?: (internalIndex: number) => void;
onNext: (userSolutions: UserSolution) => void; onNext: (userSolutions: UserSolution) => void;
onBack: (userSolutions: UserSolution) => void; onBack: (userSolutions: UserSolution) => void;
@@ -29,17 +30,18 @@ export interface CommonProps {
export const renderExercise = ( export const renderExercise = (
exercise: Exercise, exercise: Exercise,
examID: string,
onNext: (userSolutions: UserSolution) => void, onNext: (userSolutions: UserSolution) => void,
onBack: (userSolutions: UserSolution) => void, onBack: (userSolutions: UserSolution) => void,
updateIndex?: (internalIndex: number) => void, updateIndex?: (internalIndex: number) => void,
) => { ) => {
switch (exercise.type) { switch (exercise.type) {
case "fillBlanks": 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": 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": 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": case "multipleChoice":
return ( return (
<MultipleChoice <MultipleChoice
@@ -48,19 +50,21 @@ export const renderExercise = (
updateIndex={updateIndex} updateIndex={updateIndex}
onNext={onNext} onNext={onNext}
onBack={onBack} onBack={onBack}
examID={examID}
/> />
); );
case "writeBlanks": 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": 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": 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": case "interactiveSpeaking":
return ( return (
<InteractiveSpeaking <InteractiveSpeaking
key={exercise.id} key={exercise.id}
{...(exercise as InteractiveSpeakingExercise)} {...(exercise as InteractiveSpeakingExercise)}
examID={examID}
updateIndex={updateIndex} updateIndex={updateIndex}
onNext={onNext} onNext={onNext}
onBack={onBack} 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 { interface Props {
children: ReactNode; children: ReactNode;
color?: "rose" | "purple" | "red" | "green" | "gray"; color?: "rose" | "purple" | "red" | "green" | "gray" | "pink";
variant?: "outline" | "solid"; variant?: "outline" | "solid";
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
@@ -49,6 +49,11 @@ export default function Button({
outline: 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", "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 ( return (

View File

@@ -42,7 +42,9 @@ export default function CountrySelect({value, disabled = false, onChange}: Props
displayValue={(code: string) => { displayValue={(code: string) => {
const country = countries[code as unknown as keyof TCountries]; 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"> <Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-8">

View File

@@ -5,12 +5,14 @@ interface Props {
label: string; label: string;
percentage: number; percentage: number;
color: "red" | "rose" | "purple" | Module; color: "red" | "rose" | "purple" | Module;
mark?: number;
markLabel?: string;
useColor?: boolean; useColor?: boolean;
className?: string; className?: string;
textClassName?: 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} = { const progressColorClass: {[key in typeof color]: string} = {
red: "bg-mti-red-light", red: "bg-mti-red-light",
rose: "bg-mti-rose-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-mti-gray-anti-flash" : progressColorClass[color],
useColor && "bg-opacity-20", 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 <div
style={{width: `${percentage}%`}} style={{width: `${percentage}%`}}
className={clsx("absolute transition-all duration-300 ease-in-out top-0 left-0 h-full overflow-hidden", progressColorClass[color])} 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,12 +1,12 @@
import {User} from "@/interfaces/user"; import { User } from "@/interfaces/user";
import {Dialog, Transition} from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import {Fragment} from "react"; import { Fragment } from "react";
import {BsShield, BsShieldFill, BsXLg} from "react-icons/bs"; import { BsXLg } from "react-icons/bs";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
@@ -15,7 +15,7 @@ interface Props {
user: User; user: User;
} }
export default function MobileMenu({isOpen, onClose, path, user}: Props) { export default function MobileMenu({ isOpen, onClose, path, user }: Props) {
const router = useRouter(); const router = useRouter();
const logout = async () => { const logout = async () => {
@@ -34,7 +34,8 @@ export default function MobileMenu({isOpen, onClose, path, user}: Props) {
enterTo="opacity-100" enterTo="opacity-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0"> leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black bg-opacity-25" /> <div className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child> </Transition.Child>
@@ -47,42 +48,65 @@ export default function MobileMenu({isOpen, onClose, path, user}: Props) {
enterTo="opacity-100 scale-100" enterTo="opacity-100 scale-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"> leaveTo="opacity-0 scale-95"
<Dialog.Panel className="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"> <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="/"> <Link href="/">
<Image src="/logo_title.png" alt="EnCoach logo" width={69} height={69} /> <Image
src="/logo_title.png"
alt="EnCoach logo"
width={69}
height={69}
/>
</Link> </Link>
<div className="cursor-pointer" onClick={onClose} tabIndex={0}> <div
<BsXLg className="text-2xl text-mti-purple-light" onClick={onClose} /> className="cursor-pointer"
onClick={onClose}
tabIndex={0}
>
<BsXLg
className="text-mti-purple-light text-2xl"
onClick={onClose}
/>
</div> </div>
</Dialog.Title> </Dialog.Title>
<div className="flex flex-col h-full gap-6 px-8 text-lg"> <div className="flex h-full flex-col gap-6 px-8 text-lg">
<Link <Link
href="/" href="/"
className={clsx( className={clsx(
"transition ease-in-out duration-300 w-fit", "w-fit transition duration-300 ease-in-out",
path === "/" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ", path === "/" &&
)}> "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
)}
>
Dashboard Dashboard
</Link> </Link>
{(user.type === "student" || user.type === "teacher" || user.type === "developer") && ( {(user.type === "student" ||
user.type === "teacher" ||
user.type === "developer") && (
<> <>
<Link <Link
href="/exam" href="/exam"
className={clsx( className={clsx(
"transition ease-in-out duration-300 w-fit", "w-fit transition duration-300 ease-in-out",
path === "/exam" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ", path === "/exam" &&
)}> "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
)}
>
Exams Exams
</Link> </Link>
<Link <Link
href="/exercises" href="/exercises"
className={clsx( className={clsx(
"transition ease-in-out duration-300 w-fit", "w-fit transition duration-300 ease-in-out",
path === "/exercises" && path === "/exercises" &&
"text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ", "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
)}> )}
>
Exercises Exercises
</Link> </Link>
</> </>
@@ -90,52 +114,80 @@ export default function MobileMenu({isOpen, onClose, path, user}: Props) {
<Link <Link
href="/stats" href="/stats"
className={clsx( className={clsx(
"transition ease-in-out duration-300 w-fit", "w-fit transition duration-300 ease-in-out",
path === "/stats" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ", path === "/stats" &&
)}> "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
)}
>
Stats Stats
</Link> </Link>
<Link <Link
href="/record" href="/record"
className={clsx( className={clsx(
"transition ease-in-out duration-300 w-fit", "w-fit transition duration-300 ease-in-out",
path === "/record" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ", path === "/record" &&
)}> "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
)}
>
Record Record
</Link> </Link>
{["admin", "developer", "agent", "corporate"].includes(user.type) && ( {["admin", "developer", "agent", "corporate"].includes(
user.type,
) && (
<Link <Link
href="/payment-record" href="/payment-record"
className={clsx( className={clsx(
"transition ease-in-out duration-300 w-fit", "w-fit transition duration-300 ease-in-out",
path === "/payment-record" && path === "/payment-record" &&
"text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ", "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
)}> )}
>
Payment Record Payment Record
</Link> </Link>
)} )}
{["admin", "developer", "corporate", "teacher"].includes(user.type) && ( {["admin", "developer", "corporate", "teacher"].includes(
user.type,
) && (
<Link <Link
href="/settings" href="/settings"
className={clsx( className={clsx(
"transition ease-in-out duration-300 w-fit", "w-fit transition duration-300 ease-in-out",
path === "/settings" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ", path === "/settings" &&
)}> "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
)}
>
Settings Settings
</Link> </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 <Link
href="/profile" href="/profile"
className={clsx( className={clsx(
"transition ease-in-out duration-300 w-fit", "w-fit transition duration-300 ease-in-out",
path === "/profile" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ", path === "/profile" &&
)}> "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
)}
>
Profile Profile
</Link> </Link>
<span <span
className={clsx("transition ease-in-out duration-300 w-fit justify-self-end cursor-pointer")} className={clsx(
onClick={logout}> "w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out",
)}
onClick={logout}
>
Logout Logout
</span> </span>
</div> </div>

View File

@@ -11,7 +11,7 @@ interface Props {
export default function Modal({isOpen, title, onClose, children}: Props) { export default function Modal({isOpen, title, onClose, children}: Props) {
return ( return (
<Transition appear show={isOpen} as={Fragment}> <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 <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"

View File

@@ -3,7 +3,7 @@ import Link from "next/link";
import FocusLayer from "@/components/FocusLayer"; import FocusLayer from "@/components/FocusLayer";
import {preventNavigation} from "@/utils/navigation.disabled"; import {preventNavigation} from "@/utils/navigation.disabled";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {BsList} from "react-icons/bs"; import {BsList, BsQuestionCircle, BsQuestionCircleFill} from "react-icons/bs";
import clsx from "clsx"; import clsx from "clsx";
import moment from "moment"; import moment from "moment";
import MobileMenu from "./MobileMenu"; import MobileMenu from "./MobileMenu";
@@ -12,6 +12,10 @@ import {Type} from "@/interfaces/user";
import {USER_TYPE_LABELS} from "@/resources/user"; import {USER_TYPE_LABELS} from "@/resources/user";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import {isUserFromCorporate} from "@/utils/groups"; 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 { interface Props {
user: User; user: User;
@@ -25,9 +29,9 @@ interface Props {
export default function Navbar({user, path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) { export default function Navbar({user, path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false);
const [disablePaymentPage, setDisablePaymentPage] = useState(true); const [disablePaymentPage, setDisablePaymentPage] = useState(true);
const [isTicketOpen, setIsTicketOpen] = useState(false);
const disableNavigation = preventNavigation(navDisabled, focusMode); const disableNavigation = preventNavigation(navDisabled, focusMode);
const router = useRouter();
const expirationDateColor = (date: Date) => { const expirationDateColor = (date: Date) => {
const momentDate = moment(date); const momentDate = moment(date);
@@ -48,44 +52,59 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
}; };
useEffect(() => { 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)); isUserFromCorporate(user.id).then((result) => setDisablePaymentPage(result));
}, [user]); }, [user]);
return ( 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} />} {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"> <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=" md:px-8 flex gap-8 items-center"> <Link href={disableNavigation ? "" : "/"} className=" flex items-center gap-8 md:px-8">
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" /> <img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" />
<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> </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() && ( {showExpirationDate() && (
<Link <Link
href={disablePaymentPage ? "/payment" : ""} href={disablePaymentPage ? "/payment" : ""}
data-tip="Expiry date" data-tip="Expiry date"
className={clsx( className={clsx(
"py-2 px-6 w-fit flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", "flex w-fit cursor-pointer justify-center rounded-full border px-6 py-2 text-sm font-normal focus:outline-none",
"transition duration-300 ease-in-out tooltip tooltip-bottom", "tooltip tooltip-bottom transition duration-300 ease-in-out",
!user.subscriptionExpirationDate !user.subscriptionExpirationDate
? "bg-mti-green-ultralight border-mti-green-light" ? "bg-mti-green-ultralight border-mti-green-light"
: expirationDateColor(user.subscriptionExpirationDate), : expirationDateColor(user.subscriptionExpirationDate),
"bg-white border-mti-gray-platinum", "border-mti-gray-platinum bg-white",
)}> )}>
{!user.subscriptionExpirationDate && "Unlimited"} {!user.subscriptionExpirationDate && "Unlimited"}
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")} {user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
</Link> </Link>
)} )}
<Link href={disableNavigation ? "" : "/profile"} className="flex gap-6 items-center justify-end -md:hidden"> <Link href={disableNavigation ? "" : "/profile"} className="-md:hidden flex items-center justify-end gap-6">
<img src={user.profilePicture} alt={user.name} className="w-10 h-10 rounded-full object-cover" /> <img src={user.profilePicture} alt={user.name} className="h-10 w-10 rounded-full object-cover" />
<span className="text-right -md:hidden"> <span className="-md:hidden text-right">
{user.type === "corporate" ? `${user.corporateInformation?.companyInformation.name} |` : ""} {user.name} |{" "} {user.type === "corporate" ? `${user.corporateInformation?.companyInformation.name} |` : ""} {user.name} |{" "}
{USER_TYPE_LABELS[user.type]} {USER_TYPE_LABELS[user.type]}
</span> </span>
</Link> </Link>
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}> <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>
</div> </div>
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />} {focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}

View File

@@ -11,11 +11,12 @@ interface Props {
price: number; price: number;
duration: number; duration: number;
duration_unit: DurationUnit; duration_unit: DurationUnit;
loadScript?: boolean;
setIsLoading: (isLoading: boolean) => void; setIsLoading: (isLoading: boolean) => void;
onSuccess: (duration: number, duration_unit: DurationUnit) => 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> => { const createOrder = async (data: CreateOrderData, actions: CreateOrderActions): Promise<string> => {
setIsLoading(true); setIsLoading(true);
@@ -45,7 +46,7 @@ export default function PayPalPayment({clientID, price, currency, duration, dura
setIsLoading(false); setIsLoading(false);
}; };
return ( return loadScript ? (
<PayPalScriptProvider <PayPalScriptProvider
options={{ options={{
clientId: clientID, clientId: clientID,
@@ -60,7 +61,17 @@ export default function PayPalPayment({clientID, price, currency, duration, dura
createOrder={createOrder} createOrder={createOrder}
onApprove={onApprove} onApprove={onApprove}
onCancel={onCancel} onCancel={onCancel}
onError={onError}></PayPalButtons> onError={onError}
/>
</PayPalScriptProvider> </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; icon: ReactElement;
value: string | number; value: string | number;
label: string; label: string;
tooltip?: string;
}[]; }[];
children?: ReactElement; children?: ReactElement;
} }
@@ -48,7 +49,10 @@ export default function ProfileSummary({user, items}: Props) {
<div className="flex justify-between w-full mt-8 -md:hidden"> <div className="flex justify-between w-full mt-8 -md:hidden">
{items.map((item) => ( {items.map((item) => (
<div className="flex gap-4 items-center" key={item.label}> <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} {item.icon}
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">

View File

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

View File

@@ -8,6 +8,8 @@ import axios from "axios";
import {speakingReverseMarking} from "@/utils/score"; import {speakingReverseMarking} from "@/utils/score";
import {Tab} from "@headlessui/react"; import {Tab} from "@headlessui/react";
import clsx from "clsx"; import clsx from "clsx";
import Modal from "../Modal";
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false}); const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
@@ -22,9 +24,10 @@ export default function InteractiveSpeaking({
onBack, onBack,
}: InteractiveSpeakingExercise & CommonProps) { }: InteractiveSpeakingExercise & CommonProps) {
const [solutionsURL, setSolutionsURL] = useState<string[]>([]); const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
const [diffNumber, setDiffNumber] = useState<0 | 1 | 2 | 3>(0);
useEffect(() => { 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( Promise.all(userSolutions[0].solution.map((x) => axios.post(`/api/speaking`, {path: x.answer}, {responseType: "arraybuffer"}))).then(
(values) => { (values) => {
setSolutionsURL( setSolutionsURL(
@@ -42,6 +45,44 @@ export default function InteractiveSpeaking({
return ( 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 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 w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@@ -67,10 +108,23 @@ export default function InteractiveSpeaking({
{solutionsURL.map((x, index) => ( {solutionsURL.map((x, index) => (
<div <div
key={index} 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"> <div className="flex gap-8 items-center justify-center py-8">
<Waveform audio={x} waveColor="#FCDDEC" progressColor="#EF5DA8" /> <Waveform audio={x} waveColor="#FCDDEC" progressColor="#EF5DA8" />
</div> </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>
))} ))}
</div> </div>
@@ -185,7 +239,11 @@ export default function InteractiveSpeaking({
onNext({ onNext({
exercise: id, exercise: id,
solutions: userSolutions, 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, type,
}) })
} }

View File

@@ -8,14 +8,18 @@ import axios from "axios";
import {speakingReverseMarking} from "@/utils/score"; import {speakingReverseMarking} from "@/utils/score";
import {Tab} from "@headlessui/react"; import {Tab} from "@headlessui/react";
import clsx from "clsx"; 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}); const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
export default function Speaking({id, type, title, video_url, text, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) { export default function Speaking({id, type, title, video_url, text, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
const [solutionURL, setSolutionURL] = useState<string>(); const [solutionURL, setSolutionURL] = useState<string>();
const [showDiff, setShowDiff] = useState(false);
useEffect(() => { 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}) => { axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => {
const blob = new Blob([data], {type: "audio/wav"}); const blob = new Blob([data], {type: "audio/wav"});
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -27,6 +31,42 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
return ( 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 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 w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@@ -65,10 +105,19 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
</div> </div>
</div> </div>
<div className="w-full h-full flex flex-col gap-8"> <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"> <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"> <div className="flex gap-8 items-center justify-center py-8">
{solutionURL && <Waveform audio={solutionURL} waveColor="#FCDDEC" progressColor="#EF5DA8" />} {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>
</div> </div>
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && ( {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({ onNext({
exercise: id, exercise: id,
solutions: userSolutions, 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, type,
}) })
} }

View File

@@ -76,7 +76,7 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
</div> </div>
</> </>
)} )}
{showDiff && ( {showDiff && userSolutions[0].evaluation && (
<> <>
<span>Correction:</span> <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"> <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({ onNext({
exercise: id, exercise: id,
solutions: userSolutions, 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, type,
}) })
} }

View File

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

View File

@@ -1,13 +1,13 @@
{ {
"type": "service_account", "type": "service_account",
"project_id": "mti-ielts", "project_id": "storied-phalanx-349916",
"private_key_id": "22b783a14c760d1215a8d1f5de0fa40a33a840e7", "private_key_id": "c9e05f6fe413b1031a71f981160075ff4b044444",
"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", "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-dyg6p@mti-ielts.iam.gserviceaccount.com", "client_email": "firebase-adminsdk-3ml0u@storied-phalanx-349916.iam.gserviceaccount.com",
"client_id": "104980563453519094431", "client_id": "114163760341944984396",
"auth_uri": "https://accounts.google.com/o/oauth2/auth", "auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token", "token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", "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" "universe_domain": "googleapis.com"
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,10 +6,11 @@ interface Props {
label: string; label: string;
value: string | number; value: string | number;
color: "purple" | "rose" | "red"; color: "purple" | "rose" | "red";
tooltip?: string;
onClick?: () => void; 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} = { const colorClasses: {[key in typeof color]: string} = {
purple: "text-mti-purple-light", purple: "text-mti-purple-light",
red: "text-mti-red-light", red: "text-mti-red-light",
@@ -19,7 +20,11 @@ export default function IconCard({Icon, label, value, color, onClick}: Props) {
return ( return (
<div <div
onClick={onClick} 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])} /> <Icon className={clsx("text-6xl", colorClasses[color])} />
<span className="flex flex-col gap-1 items-center text-xl"> <span className="flex flex-col gap-1 items-center text-xl">
<span className="text-lg">{label}</span> <span className="text-lg">{label}</span>

View File

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

View File

@@ -19,6 +19,7 @@ import {
BsEnvelopePaper, BsEnvelopePaper,
BsGlobeCentralSouthAsia, BsGlobeCentralSouthAsia,
BsPaperclip, BsPaperclip,
BsPeople,
BsPerson, BsPerson,
BsPersonAdd, BsPersonAdd,
BsPersonFill, BsPersonFill,
@@ -150,8 +151,9 @@ export default function TeacherDashboard({user}: Props) {
}; };
const AssignmentsPage = () => { const AssignmentsPage = () => {
const activeFilter = (a: Assignment) => moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()); const activeFilter = (a: Assignment) =>
const pastFilter = (a: Assignment) => moment(a.endDate).isBefore(moment()); 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()); const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
return ( 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)} value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
color="purple" 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 <div
onClick={() => setPage("assignments")} 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"> 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 ( return (
<div className="flex flex-col items-center justify-center gap-1"> <div className="flex flex-col items-center justify-center gap-1">
<span className="text-xl font-bold">{levelStr}</span> <span className="text-xl font-bold">{levelStr}</span>
<span className="text-xl">{grade}</span>
</div> </div>
); );
} }
@@ -86,7 +85,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
return ( 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 <ModuleTitle
module={selectedModule} module={selectedModule}
totalExercises={getTotalExercises()} totalExercises={getTotalExercises()}
@@ -99,10 +98,10 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
<div <div
onClick={() => setSelectedModule("reading")} onClick={() => setSelectedModule("reading")}
className={clsx( 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", 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> <span className="font-semibold">Reading</span>
</div> </div>
)} )}
@@ -110,10 +109,10 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
<div <div
onClick={() => setSelectedModule("listening")} onClick={() => setSelectedModule("listening")}
className={clsx( 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", 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> <span className="font-semibold">Listening</span>
</div> </div>
)} )}
@@ -121,10 +120,10 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
<div <div
onClick={() => setSelectedModule("writing")} onClick={() => setSelectedModule("writing")}
className={clsx( 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", 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> <span className="font-semibold">Writing</span>
</div> </div>
)} )}
@@ -132,10 +131,10 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
<div <div
onClick={() => setSelectedModule("speaking")} onClick={() => setSelectedModule("speaking")}
className={clsx( 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", 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> <span className="font-semibold">Speaking</span>
</div> </div>
)} )}
@@ -143,18 +142,18 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
<div <div
onClick={() => setSelectedModule("level")} onClick={() => setSelectedModule("level")}
className={clsx( 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", 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> <span className="font-semibold">Level</span>
</div> </div>
)} )}
</div> </div>
{isLoading && ( {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("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... Evaluating your answers, please be patient...
<br /> <br />
You can also check it later on your records page! You can also check it later on your records page!
@@ -162,17 +161,21 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
</div> </div>
)} )}
{!isLoading && ( {!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> <span className="max-w-3xl">{moduleResultText(selectedModule, bandScore)}</span>
<div className="flex gap-9 px-16"> <div className="flex gap-9 px-16">
<div <div
className={clsx("radial-progress overflow-hidden", moduleColors[selectedModule].progress)} className={clsx("radial-progress overflow-hidden", moduleColors[selectedModule].progress)}
style={ 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 <div
className={clsx( 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, moduleColors[selectedModule].inner,
)}> )}>
<span className="text-xl">Level</span> <span className="text-xl">Level</span>
@@ -181,7 +184,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
</div> </div>
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<div className="flex gap-2"> <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"> <div className="flex flex-col">
<span className="text-mti-red-light"> <span className="text-mti-red-light">
{(((selectedScore.total - selectedScore.missing) / selectedScore.total) * 100).toFixed(0)}% {(((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> </div>
<div className="flex gap-2"> <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"> <div className="flex flex-col">
<span className="text-mti-purple-light">{selectedScore.correct.toString().padStart(2, "0")}</span> <span className="text-mti-purple-light">{selectedScore.correct.toString().padStart(2, "0")}</span>
<span className="text-lg">Correct</span> <span className="text-lg">Correct</span>
</div> </div>
</div> </div>
<div className="flex gap-2"> <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"> <div className="flex flex-col">
<span className="text-mti-rose-light"> <span className="text-mti-rose-light">
{(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")} {(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")}
@@ -212,28 +215,28 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
</div> </div>
{!isLoading && ( {!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="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 <button
onClick={() => window.location.reload()} 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"> 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="text-white w-7 h-7" /> <BsArrowCounterclockwise className="h-7 w-7 text-white" />
</button> </button>
<span>Play Again</span> <span>Play Again</span>
</div> </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 <button
onClick={onViewResults} 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"> 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="text-white w-7 h-7" /> <BsEyeFill className="h-7 w-7 text-white" />
</button> </button>
<span>Review Answers</span> <span>Review Answers</span>
</div> </div>
</div> </div>
<Link href="/" className="max-w-[200px] w-full self-end"> <Link href="/" className="w-full max-w-[200px] self-end">
<Button color="purple" className="max-w-[200px] self-end w-full"> <Button color="purple" className="w-full max-w-[200px] self-end">
Dashboard Dashboard
</Button> </Button>
</Link> </Link>

View File

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

View File

@@ -7,7 +7,6 @@ import AudioPlayer from "@/components/Low/AudioPlayer";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import BlankQuestionsModal from "@/components/BlankQuestionsModal"; import BlankQuestionsModal from "@/components/BlankQuestionsModal";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils"; import {countExercises} from "@/utils/moduleUtils";
interface Props { interface Props {
@@ -16,24 +15,35 @@ interface Props {
onFinish: (userSolutions: UserSolution[]) => void; 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) { export default function Listening({exam, showSolutions = false, onFinish}: Props) {
const [questionIndex, setQuestionIndex] = useState(0); const [questionIndex, setQuestionIndex] = useState(0);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [exerciseIndex, setExerciseIndex] = useState(showSolutions ? 0 : -1);
const [partIndex, setPartIndex] = useState(0);
const [timesListened, setTimesListened] = 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 [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(() => { useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) { if (hasExamEnded && exerciseIndex === -1) {
setExerciseIndex((prev) => prev + 1); setExerciseIndex(exerciseIndex + 1);
} }
}, [hasExamEnded, exerciseIndex]); }, [hasExamEnded, exerciseIndex, setExerciseIndex]);
useEffect(() => { useEffect(() => {
setCurrentQuestionIndex(0); setCurrentQuestionIndex(0);
@@ -49,18 +59,19 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
}; };
const nextExercise = (solution?: UserSolution) => { const nextExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) { 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); setQuestionIndex((prev) => prev + currentQuestionIndex);
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) { if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
setExerciseIndex((prev) => prev + 1); setExerciseIndex(exerciseIndex + 1);
return; return;
} }
if (partIndex + 1 < exam.parts.length && !hasExamEnded) { if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
setPartIndex((prev) => prev + 1); setPartIndex(partIndex + 1);
setExerciseIndex(showSolutions ? 0 : -1); setExerciseIndex(showSolutions ? 0 : -1);
return; return;
} }
@@ -89,11 +100,12 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
}; };
const previousExercise = (solution?: UserSolution) => { const previousExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) { 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 = () => { 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 = () => ( 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 gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
<div className="flex flex-col w-full gap-2"> <div className="flex flex-col w-full gap-2">
@@ -133,7 +156,9 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
<div className="flex flex-col h-full w-full gap-8 justify-between"> <div className="flex flex-col h-full w-full gap-8 justify-between">
<ModuleTitle <ModuleTitle
exerciseIndex={ exerciseIndex={
(exam.parts partIndex === -1
? 0
: (exam.parts
.flatMap((x) => x.exercises) .flatMap((x) => x.exercises)
.findIndex( .findIndex(
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id, (x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
@@ -147,24 +172,37 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))} totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
disableTimer={showSolutions} disableTimer={showSolutions}
/> />
{renderAudioPlayer()} {/* Audio Player for the Instructions */}
{partIndex === -1 && renderAudioInstructionsPlayer()}
{/* Part's audio player */}
{partIndex > -1 && renderAudioPlayer()}
{/* Exercise renderer */}
{exerciseIndex > -1 && {exerciseIndex > -1 &&
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length && exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions && !showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)} renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
{/* Solution renderer */}
{exerciseIndex > -1 && {exerciseIndex > -1 &&
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length && exerciseIndex < exam.parts[partIndex].exercises.length &&
showSolutions && showSolutions &&
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)} renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
</div> </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"> <div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button <Button
color="purple" color="purple"
variant="outline" variant="outline"
onClick={() => { onClick={() => {
if (partIndex === 0) return setPartIndex(-1);
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1); setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
setPartIndex((prev) => prev - 1); setPartIndex(partIndex - 1);
}} }}
className="max-w-[200px] w-full"> className="max-w-[200px] w-full">
Back Back
@@ -175,7 +213,13 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
</Button> </Button>
</div> </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"> <Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end">
Start now Start now
</Button> </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) { export default function Reading({exam, showSolutions = false, onFinish}: Props) {
const [questionIndex, setQuestionIndex] = useState(0); const [questionIndex, setQuestionIndex] = useState(0);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [exerciseIndex, setExerciseIndex] = useState(showSolutions ? 0 : -1);
const [partIndex, setPartIndex] = useState(0);
const [showTextModal, setShowTextModal] = useState(false); 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 [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(() => { useEffect(() => {
const listener = (e: KeyboardEvent) => { const listener = (e: KeyboardEvent) => {
@@ -113,9 +118,9 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
useEffect(() => { useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) { if (hasExamEnded && exerciseIndex === -1) {
setExerciseIndex((prev) => prev + 1); setExerciseIndex(exerciseIndex + 1);
} }
}, [hasExamEnded, exerciseIndex]); }, [hasExamEnded, exerciseIndex, setExerciseIndex]);
const confirmFinishModule = (keepGoing?: boolean) => { const confirmFinishModule = (keepGoing?: boolean) => {
if (!keepGoing) { if (!keepGoing) {
@@ -127,18 +132,20 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
}; };
const nextExercise = (solution?: UserSolution) => { const nextExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) { 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); setQuestionIndex((prev) => prev + currentQuestionIndex);
setStoreQuestionIndex(0);
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) { if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
setExerciseIndex((prev) => prev + 1); setExerciseIndex(exerciseIndex + 1);
return; return;
} }
if (partIndex + 1 < exam.parts.length && !hasExamEnded) { if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
setPartIndex((prev) => prev + 1); setPartIndex(partIndex + 1);
setExerciseIndex(showSolutions ? 0 : -1); setExerciseIndex(showSolutions ? 0 : -1);
return; return;
} }
@@ -167,11 +174,13 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
}; };
const previousExercise = (solution?: UserSolution) => { const previousExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) { 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 = () => { 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"> <div className="flex flex-col h-full w-full gap-8">
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} /> <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 <ModuleTitle
minTimer={exam.minTimer} minTimer={exam.minTimer}
exerciseIndex={ exerciseIndex={
(exam.parts (exam.parts
.flatMap((x) => x.exercises) .flatMap((x) => x.exercises)
.findIndex( .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) + ) || 0) +
(exerciseIndex === -1 ? 0 : 1) + (exerciseIndex === -1 ? 0 : 1) +
questionIndex + 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)} label={exerciseIndex === -1 ? undefined : convertCamelCaseToReadable(exam.parts[partIndex].exercises[exerciseIndex].type)}
/> />
<div className={clsx("mb-20 w-full", exerciseIndex > -1 && "grid grid-cols-2 gap-4")}> <div className={clsx("mb-20 w-full", exerciseIndex > -1 && "grid grid-cols-2 gap-4")}>
{renderText()} {partIndex > -1 && renderText()}
{exerciseIndex > -1 && {exerciseIndex > -1 &&
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length && exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions && !showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)} renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
{exerciseIndex > -1 && {exerciseIndex > -1 &&
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length && exerciseIndex < exam.parts[partIndex].exercises.length &&
showSolutions && showSolutions &&
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)} renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
</div> </div>
{exerciseIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && ( {exerciseIndex > -1 && partIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
<Button <Button
color="purple" color="purple"
variant="outline" variant="outline"
@@ -252,7 +268,7 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
variant="outline" variant="outline"
onClick={() => { onClick={() => {
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1); setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
setPartIndex((prev) => prev - 1); setPartIndex(partIndex - 1);
}} }}
className="max-w-[200px] w-full"> className="max-w-[200px] w-full">
Back Back

View File

@@ -4,7 +4,7 @@ import {Module} from "@/interfaces";
import clsx from "clsx"; import clsx from "clsx";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import ProgressBar from "@/components/Low/ProgressBar"; import ProgressBar from "@/components/Low/ProgressBar";
import {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 {totalExamsByModule} from "@/utils/stats";
import useStats from "@/hooks/useStats"; import useStats from "@/hooks/useStats";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
@@ -12,53 +12,78 @@ import {calculateAverageLevel} from "@/utils/score";
import {sortByModuleName} from "@/utils/moduleUtils"; import {sortByModuleName} from "@/utils/moduleUtils";
import {capitalize} from "lodash"; import {capitalize} from "lodash";
import ProfileSummary from "@/components/ProfileSummary"; import ProfileSummary from "@/components/ProfileSummary";
import {Variant} from "@/interfaces/exam";
import useSessions, {Session} from "@/hooks/useSessions";
import SessionCard from "@/components/Medium/SessionCard";
import useExamStore from "@/stores/examStore";
import moment from "moment";
interface Props { interface Props {
user: User; user: User;
page: "exercises" | "exams"; page: "exercises" | "exams";
onStart: (modules: Module[], avoidRepeated: boolean) => void; onStart: (modules: Module[], avoidRepeated: boolean, variant: Variant) => void;
disableSelection?: boolean; disableSelection?: boolean;
} }
export default function Selection({user, page, onStart, disableSelection = false}: Props) { export default function Selection({user, page, onStart, disableSelection = false}: Props) {
const [selectedModules, setSelectedModules] = useState<Module[]>([]); const [selectedModules, setSelectedModules] = useState<Module[]>([]);
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true); const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
const [variant, setVariant] = useState<Variant>("full");
const {stats} = useStats(user?.id); const {stats} = useStats(user?.id);
const {sessions, isLoading, reload} = useSessions(user.id);
const state = useExamStore((state) => state);
const toggleModule = (module: Module) => { const toggleModule = (module: Module) => {
const modules = selectedModules.filter((x) => x !== module); const modules = selectedModules.filter((x) => x !== module);
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module])); setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module]));
}; };
const loadSession = async (session: Session) => {
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 ( 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 && ( {user && (
<ProfileSummary <ProfileSummary
user={user} user={user}
items={[ 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", label: "Reading",
value: totalExamsByModule(stats, "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", label: "Listening",
value: totalExamsByModule(stats, "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", label: "Writing",
value: totalExamsByModule(stats, "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", label: "Speaking",
value: totalExamsByModule(stats, "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", label: "Level",
value: totalExamsByModule(stats, "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"> <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"> <span className="text-mti-gray-taupe">
{page === "exercises" && ( {page === "exercises" && (
<> <>
@@ -92,133 +117,171 @@ export default function Selection({user, page, onStart, disableSelection = false
)} )}
</span> </span>
</section> </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 <div
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("reading") : undefined} onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
className={clsx( 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", 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"> <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="text-white w-7 h-7" /> <BsBook className="h-7 w-7 text-white" />
</div> </div>
<span className="font-semibold">Reading:</span> <span className="font-semibold">Reading:</span>
<p className="text-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. Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
</p> </p>
{!selectedModules.includes("reading") && !selectedModules.includes("level") && !disableSelection && ( {!selectedModules.includes("reading") && !selectedModules.includes("level") && !disableSelection && (
<div className="border 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) && ( {(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>
<div <div
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("listening") : undefined} onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
className={clsx( 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", 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"> <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="text-white w-7 h-7" /> <BsHeadphones className="h-7 w-7 text-white" />
</div> </div>
<span className="font-semibold">Listening:</span> <span className="font-semibold">Listening:</span>
<p className="text-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. Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
</p> </p>
{!selectedModules.includes("listening") && !selectedModules.includes("level") && !disableSelection && ( {!selectedModules.includes("listening") && !selectedModules.includes("level") && !disableSelection && (
<div className="border 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) && ( {(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>
<div <div
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("writing") : undefined} onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
className={clsx( 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", 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"> <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="text-white w-7 h-7" /> <BsPen className="h-7 w-7 text-white" />
</div> </div>
<span className="font-semibold">Writing:</span> <span className="font-semibold">Writing:</span>
<p className="text-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. Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
</p> </p>
{!selectedModules.includes("writing") && !selectedModules.includes("level") && !disableSelection && ( {!selectedModules.includes("writing") && !selectedModules.includes("level") && !disableSelection && (
<div className="border 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) && ( {(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>
<div <div
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("speaking") : undefined} onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
className={clsx( 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", 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"> <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="text-white w-7 h-7" /> <BsMegaphone className="h-7 w-7 text-white" />
</div> </div>
<span className="font-semibold">Speaking:</span> <span className="font-semibold">Speaking:</span>
<p className="text-center text-xs"> <p className="text-left text-xs">
You&apos;ll have access to interactive dialogs, pronunciation exercises and speech recordings. You&apos;ll have access to interactive dialogs, pronunciation exercises and speech recordings.
</p> </p>
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && !disableSelection && ( {!selectedModules.includes("speaking") && !selectedModules.includes("level") && !disableSelection && (
<div className="border 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) && ( {(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> </div>
{!disableSelection && ( {!disableSelection && (
<div <div
onClick={selectedModules.length === 0 || selectedModules.includes("level") ? () => toggleModule("level") : undefined} onClick={selectedModules.length === 0 || selectedModules.includes("level") ? () => toggleModule("level") : undefined}
className={clsx( className={clsx(
"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", 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"> <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="text-white w-7 h-7" /> <BsClipboard className="h-7 w-7 text-white" />
</div> </div>
<span className="font-semibold">Level:</span> <span className="font-semibold">Level:</span>
<p className="text-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 && ( {!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) && ( {(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 && ( {!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> </div>
)} )}
</section> </section>
<div className="flex w-full -md:flex-col -md:gap-4 -md:justify-center md:justify-between items-center"> <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 <div
className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer tooltip w-full -md:justify-center" className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
data-tip="If possible, the platform will choose exams not yet done"
onClick={() => setAvoidRepeatedExams((prev) => !prev)}> onClick={() => setAvoidRepeatedExams((prev) => !prev)}>
<input type="checkbox" className="hidden" /> <input type="checkbox" className="hidden" />
<div <div
className={clsx( className={clsx(
"w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white", "border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
avoidRepeatedExams && "!bg-mti-purple-light ", avoidRepeatedExams && "!bg-mti-purple-light ",
)}> )}>
<BsCheck color="white" className="w-full h-full" /> <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> </div>
<span>Avoid Repeated Questions</span>
</div> </div>
<div className="tooltip w-full" data-tip={`Your screen size is too small to do ${page}`}> <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 Start Exam
</Button> </Button>
</div> </div>
@@ -227,10 +290,11 @@ export default function Selection({user, page, onStart, disableSelection = false
onStart( onStart(
!disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"], !disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"],
avoidRepeatedExams, avoidRepeatedExams,
variant,
) )
} }
color="purple" 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}> disabled={selectedModules.length === 0 && !disableSelection}>
Start Exam Start Exam
</Button> </Button>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,11 @@ import React from "react";
import { styles } from "./styles"; import { styles } from "./styles";
import { View, Text } from "@react-pdf/renderer"; import { View, Text } from "@react-pdf/renderer";
const TestReportFooter = () => ( interface Props {
userId?: string;
}
const TestReportFooter = ({ userId }: Props) => (
<View <View
style={[ style={[
{ {
@@ -25,10 +29,15 @@ const TestReportFooter = () => (
</Text> </Text>
</View> </View>
<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> </View>
<View style={{ paddingTop: 10 }}> <View>
<Text style={styles.textBold}>Declaration</Text> <Text style={styles.textBold}>Declaration</Text>
<Text style={{ paddingTop: 5 }}> <Text style={{ paddingTop: 5 }}>
We hereby declare that exam results on our platform, assessed by AI, are 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. continuously enhance our system to ensure accuracy and reliability.
</Text> </Text>
</View> </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 }]}> <View style={[styles.textColor, { paddingTop: 5 }]}>
<Text style={styles.textUnderline}>info@encoach.com</Text> <Text style={styles.textUnderline}>info@encoach.com</Text>
<Text>https://encoach.com</Text> <Text>https://encoach.com</Text>

View File

@@ -124,7 +124,7 @@ const TestReport = ({
</View> </View>
</View> </View>
<View style={[{ paddingTop: 30 }, styles.separator]}></View> <View style={[{ paddingTop: 30 }, styles.separator]}></View>
<TestReportFooter /> <TestReportFooter userId={id}/>
</Page> </Page>
<Page style={styles.body}> <Page style={styles.body}>
<View> <View>
@@ -165,7 +165,7 @@ const TestReport = ({
</View> </View>
<View style={[{ paddingBottom: 30 }, styles.separator]}></View> <View style={[{ paddingBottom: 30 }, styles.separator]}></View>
<View style={{ flexGrow: 1 }}></View> <View style={{ flexGrow: 1 }}></View>
<TestReportFooter /> <TestReportFooter userId={id}/>
</Page> </Page>
</Document> </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 "."; import {Module} from ".";
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam; 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 { export interface ReadingExam {
parts: ReadingPart[]; parts: ReadingPart[];
@@ -82,6 +83,7 @@ export interface SpeakingExam {
minTimer: number; minTimer: number;
isDiagnostic: boolean; isDiagnostic: boolean;
variant?: Variant; variant?: Variant;
instructorGender: InstructorGender;
} }
export type Exercise = export type Exercise =
@@ -103,13 +105,24 @@ export interface Evaluation {
interface InteractiveSpeakingEvaluation extends Evaluation { interface InteractiveSpeakingEvaluation extends Evaluation {
perfect_answer_1?: string; perfect_answer_1?: string;
transcript_1?: string;
fixed_text_1?: string;
perfect_answer_2?: string; perfect_answer_2?: string;
transcript_2?: string;
fixed_text_2?: string;
perfect_answer_3?: 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 { interface CommonEvaluation extends Evaluation {
perfect_answer?: string; perfect_answer?: string;
perfect_answer_1?: string;
fixed_text?: string; fixed_text?: string;
} }
@@ -141,7 +154,7 @@ export interface SpeakingExercise {
userSolutions: { userSolutions: {
id: string; id: string;
solution: string; solution: string;
evaluation?: CommonEvaluation; evaluation?: SpeakingEvaluation;
}[]; }[];
} }
@@ -153,7 +166,7 @@ export interface InteractiveSpeakingExercise {
prompts: {text: string; video_url: string}[]; prompts: {text: string; video_url: string}[];
userSolutions: { userSolutions: {
id: string; id: string;
solution: {question: string; answer: string}[]; solution: {questionIndex: number; question: string; answer: string}[];
evaluation?: InteractiveSpeakingEvaluation; 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

@@ -4,13 +4,13 @@ export interface ModuleScore {
score: number; score: number;
total: number; total: number;
code: Module; code: Module;
module: Module | 'Overall'; module: Module | "Overall";
png?: string, png?: string;
evaluation?: string, evaluation?: string;
suggestions?: string, suggestions?: string;
} }
export interface StudentData { export interface StudentData {
id: string; id: string;
name: string; name: string;
email: string; email: string;
@@ -19,4 +19,5 @@ export interface ModuleScore {
result: string; result: string;
level?: string; level?: string;
bandScore: number; bandScore: number;
} passportId?: string;
}

View File

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

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 {Module} from ".";
import {InstructorGender} from "./exam";
export type User = StudentUser | TeacherUser | CorporateUser | AgentUser | AdminUser | DeveloperUser; export type User = StudentUser | TeacherUser | CorporateUser | AgentUser | AdminUser | DeveloperUser;
@@ -21,6 +22,7 @@ export interface BasicUser {
export interface StudentUser extends BasicUser { export interface StudentUser extends BasicUser {
type: "student"; type: "student";
preferredGender?: InstructorGender;
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
} }
@@ -48,6 +50,7 @@ export interface AdminUser extends BasicUser {
export interface DeveloperUser extends BasicUser { export interface DeveloperUser extends BasicUser {
type: "developer"; type: "developer";
preferredGender?: InstructorGender;
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
} }
@@ -127,5 +130,16 @@ export interface Group {
disableEditing?: boolean; 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 type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent";
export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent"]; export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent"];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
import Input from "@/components/Low/Input"; 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 useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams"; import {getExamById} from "@/utils/exams";
import {playSound} from "@/utils/sound"; import {playSound} from "@/utils/sound";
@@ -7,21 +9,27 @@ import {convertCamelCaseToReadable} from "@/utils/string";
import {Tab} from "@headlessui/react"; import {Tab} from "@headlessui/react";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize, sample, uniq} from "lodash";
import moment from "moment";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {useState} from "react"; import {useEffect, useState} from "react";
import {BsArrowRepeat} from "react-icons/bs"; import {BsArrowRepeat, BsCheck} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import {v4} from "uuid";
const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; setPart: (part?: SpeakingPart) => void}) => { 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 [isLoading, setIsLoading] = useState(false);
const generate = () => { const generate = () => {
setPart(undefined); setPart(undefined);
setIsLoading(true); setIsLoading(true);
axios axios
.get(`/api/exam/speaking/generate/speaking_task_${index}`) .get(`/api/exam/speaking/generate/speaking_task_${index}`)
.then((result) => { .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); setPart(result.data);
}) })
.catch((error) => { .catch((error) => {
@@ -31,8 +39,45 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
.finally(() => setIsLoading(false)); .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 ( return (
<Tab.Panel className="w-full bg-ielts-speaking/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4"> <Tab.Panel className="w-full bg-ielts-speaking/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Gender</label>
<Select
options={[
{value: "male", label: "Male"},
{value: "female", label: "Female"},
]}
value={{value: gender, label: capitalize(gender)}}
onChange={(value) => (value ? setGender(value.value as typeof gender) : null)}
disabled={isLoading}
/>
</div>
<div className="flex gap-4 items-end"> <div className="flex gap-4 items-end">
<button <button
onClick={generate} onClick={generate}
@@ -52,6 +97,24 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
"Generate" "Generate"
)} )}
</button> </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> </div>
{isLoading && ( {isLoading && (
<div className="w-fit h-fit mt-12 self-center animate-pulse flex flex-col gap-8 items-center"> <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> <span className={clsx("font-bold text-2xl text-ielts-speaking")}>Generating...</span>
</div> </div>
)} )}
{part && ( {part && !isLoading && (
<div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide h-96"> <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> <h3 className="text-xl font-semibold">{part.topic}</h3>
{part.question && <span className="w-full">{part.question}</span>} {part.question && <span className="w-full">{part.question}</span>}
@@ -82,6 +145,12 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
))} ))}
</div> </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> </div>
)} )}
</Tab.Panel> </Tab.Panel>
@@ -93,27 +162,48 @@ interface SpeakingPart {
question?: string; question?: string;
questions?: string[]; questions?: string[];
topic: string; topic: string;
result?: SpeakingExercise | InteractiveSpeakingExercise;
gender?: "male" | "female";
avatar?: (typeof AVATARS)[number];
} }
const SpeakingGeneration = () => { const SpeakingGeneration = () => {
const [part1, setPart1] = useState<SpeakingPart>(); const [part1, setPart1] = useState<SpeakingPart>();
const [part2, setPart2] = useState<SpeakingPart>(); const [part2, setPart2] = useState<SpeakingPart>();
const [part3, setPart3] = useState<SpeakingPart>(); const [part3, setPart3] = useState<SpeakingPart>();
const [minTimer, setMinTimer] = useState(14);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<SpeakingExam>(); const [resultingExam, setResultingExam] = useState<SpeakingExam>();
useEffect(() => {
const parts = [part1, part2, part3].filter((x) => !!x);
setMinTimer(parts.length === 0 ? 5 : parts.length * 5);
}, [part1, part2, part3]);
const router = useRouter(); const router = useRouter();
const setExams = useExamStore((state) => state.setExams); const setExams = useExamStore((state) => state.setExams);
const setSelectedModules = useExamStore((state) => state.setSelectedModules); const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const submitExam = () => { 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); 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 axios
.post(`/api/exam/speaking/generate/speaking`, {exercises: [part1, part2, part3]}) .post(`/api/exam/speaking`, exam)
.then((result) => { .then((result) => {
playSound("sent"); playSound("sent");
console.log(`Generated Exam ID: ${result.data.id}`); console.log(`Generated Exam ID: ${result.data.id}`);
@@ -123,10 +213,11 @@ const SpeakingGeneration = () => {
setPart1(undefined); setPart1(undefined);
setPart2(undefined); setPart2(undefined);
setPart3(undefined); setPart3(undefined);
setMinTimer(14);
}) })
.catch((error) => { .catch((error) => {
console.log(error); console.log(error);
toast.error("Something went wrong!"); toast.error("Something went wrong while generating, please try again later.");
}) })
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
@@ -149,40 +240,51 @@ const SpeakingGeneration = () => {
return ( 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.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1"> <Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
<Tab <Tab
className={({selected}) => className={({selected}) =>
clsx( 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", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking", 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>
<Tab <Tab
className={({selected}) => className={({selected}) =>
clsx( 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", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking", 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>
<Tab <Tab
className={({selected}) => className={({selected}) =>
clsx( 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", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking", 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>
</Tab.List> </Tab.List>
<Tab.Panels> <Tab.Panels>
@@ -209,14 +311,14 @@ const SpeakingGeneration = () => {
</button> </button>
)} )}
<button <button
disabled={!part1 || !part2 || !part3 || isLoading} disabled={(!part1?.result && !part2?.result && !part3?.result) || isLoading}
data-tip="Please generate all three passages" data-tip="Please generate all three passages"
onClick={submitExam} onClick={submitExam}
className={clsx( className={clsx(
"bg-ielts-speaking/70 border border-ielts-speaking text-white w-full max-w-[200px] rounded-xl h-[70px] self-end", "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", "hover:bg-ielts-speaking disabled:bg-ielts-speaking/40 disabled:cursor-not-allowed",
"transition ease-in-out duration-300", "transition ease-in-out duration-300",
(!part1 || !part2 || !part3) && "tooltip", !part1 && !part2 && !part3 && "tooltip",
)}> )}>
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">

View File

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

View File

@@ -1,13 +1,13 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import {User} from "@/interfaces/user"; import { User } from "@/interfaces/user";
import {sendEmailVerification} from "@/utils/email"; import { sendEmailVerification } from "@/utils/email";
import axios from "axios"; import axios from "axios";
import {Divider} from "primereact/divider"; import { Divider } from "primereact/divider";
import {useState} from "react"; import { useState } from "react";
import {toast} from "react-toastify"; import { toast } from "react-toastify";
import {KeyedMutator} from "swr"; import { KeyedMutator } from "swr";
import Select from "react-select"; import Select from "react-select";
import moment from "moment"; import moment from "moment";
@@ -19,13 +19,18 @@ interface Props {
} }
const availableDurations = { const availableDurations = {
"1_month": {label: "1 Month", number: 1}, "1_month": { label: "1 Month", number: 1 },
"3_months": {label: "3 Months", number: 3}, "3_months": { label: "3 Months", number: 3 },
"6_months": {label: "6 Months", number: 6}, "6_months": { label: "6 Months", number: 6 },
"12_months": {label: "12 Months", number: 12}, "12_months": { label: "12 Months", number: 12 },
}; };
export default function RegisterCorporate({isLoading, setIsLoading, mutateUser, sendEmailVerification}: Props) { export default function RegisterCorporate({
isLoading,
setIsLoading,
mutateUser,
sendEmailVerification,
}: Props) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
@@ -36,20 +41,27 @@ export default function RegisterCorporate({isLoading, setIsLoading, mutateUser,
const [companyUsers, setCompanyUsers] = useState(0); const [companyUsers, setCompanyUsers] = useState(0);
const [subscriptionDuration, setSubscriptionDuration] = useState(1); 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) => { const onError = (e: Error) => {
console.error(e); console.error(e);
toast.error("Something went wrong, please logout and re-login.", {toastId: "send-verify-error"}); toast.error("Something went wrong, please logout and re-login.", {
toastId: "send-verify-error",
});
}; };
const register = (e: any) => { const register = (e: any) => {
e.preventDefault(); e.preventDefault();
if (confirmPassword !== password) { if (confirmPassword !== password) {
toast.error("Your passwords do not match!", {toastId: "password-not-match"}); toast.error("Your passwords do not match!", {
toastId: "password-not-match",
});
return; return;
} }
@@ -72,7 +84,9 @@ export default function RegisterCorporate({isLoading, setIsLoading, mutateUser,
}, },
}) })
.then((response) => { .then((response) => {
mutateUser(response.data.user).then(() => sendEmailVerification(setIsLoading, onSuccess, onError)); mutateUser(response.data.user).then(() =>
sendEmailVerification(setIsLoading, onSuccess, onError),
);
}) })
.catch((error) => { .catch((error) => {
console.log(error.response.data); console.log(error.response.data);
@@ -93,13 +107,30 @@ export default function RegisterCorporate({isLoading, setIsLoading, mutateUser,
}; };
return ( return (
<form className="flex flex-col items-center gap-4 w-full" onSubmit={register}> <form
<div className="w-full flex gap-4"> className="flex w-full flex-col items-center gap-4"
<Input type="text" name="name" onChange={(e) => setName(e)} placeholder="Enter your name" defaultValue={name} required /> onSubmit={register}
<Input type="email" name="email" onChange={(e) => setEmail(e)} placeholder="Enter email address" defaultValue={email} required /> >
<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>
<div className="w-full flex gap-4"> <div className="flex w-full gap-4">
<Input <Input
type="password" type="password"
name="password" name="password"
@@ -118,9 +149,9 @@ export default function RegisterCorporate({isLoading, setIsLoading, mutateUser,
/> />
</div> </div>
<Divider className="w-full !my-2" /> <Divider className="!my-2 w-full" />
<div className="w-full flex gap-4"> <div className="flex w-full gap-4">
<Input <Input
type="text" type="text"
name="companyName" name="companyName"
@@ -140,16 +171,20 @@ export default function RegisterCorporate({isLoading, setIsLoading, mutateUser,
/> />
</div> </div>
<div className="w-full flex gap-4"> <div className="flex w-full gap-4">
<div className="flex flex-col gap-3 w-full"> <div className="flex w-full flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Referral *</label> <label className="text-mti-gray-dim text-base font-normal">
Referral *
</label>
<Select <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" 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={[ options={[
{value: "", label: "No referral"}, { value: "", label: "No referral" },
...users.filter((u) => u.type === "agent").map((x) => ({value: x.id, label: `${x.name} - ${x.email}`})), ...users
.filter((u) => u.type === "agent")
.map((x) => ({ value: x.id, label: `${x.name} - ${x.email}` })),
]} ]}
defaultValue={{value: "", label: "No referral"}} defaultValue={{ value: "", label: "No referral" }}
onChange={(value) => setReferralAgent(value?.value)} onChange={(value) => setReferralAgent(value?.value)}
styles={{ styles={{
control: (styles) => ({ control: (styles) => ({
@@ -163,24 +198,41 @@ export default function RegisterCorporate({isLoading, setIsLoading, mutateUser,
}), }),
option: (styles, state) => ({ option: (styles, state) => ({
...styles, ...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", backgroundColor: state.isFocused
? "#D5D9F0"
: state.isSelected
? "#7872BF"
: "white",
color: state.isFocused ? "black" : styles.color, color: state.isFocused ? "black" : styles.color,
}), }),
}} }}
/> />
</div> </div>
<div className="flex flex-col gap-3 w-full"> <div className="flex w-full flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Subscription Duration *</label> <label className="text-mti-gray-dim text-base font-normal">
Subscription Duration *
</label>
<Select <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" 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) => ({ options={Object.keys(availableDurations).map((value) => ({
value, value,
label: availableDurations[value as keyof typeof availableDurations].label, label:
availableDurations[value as keyof typeof availableDurations]
.label,
}))} }))}
defaultValue={{value: "1_month", label: availableDurations["1_month"].label}} defaultValue={{
value: "1_month",
label: availableDurations["1_month"].label,
}}
onChange={(value) => onChange={(value) =>
setSubscriptionDuration(value ? availableDurations[value.value as keyof typeof availableDurations].number : 1) setSubscriptionDuration(
value
? availableDurations[
value.value as keyof typeof availableDurations
].number
: 1,
)
} }
styles={{ styles={{
control: (styles) => ({ control: (styles) => ({
@@ -194,7 +246,11 @@ export default function RegisterCorporate({isLoading, setIsLoading, mutateUser,
}), }),
option: (styles, state) => ({ option: (styles, state) => ({
...styles, ...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", backgroundColor: state.isFocused
? "#D5D9F0"
: state.isSelected
? "#7872BF"
: "white",
color: state.isFocused ? "black" : styles.color, color: state.isFocused ? "black" : styles.color,
}), }),
}} }}
@@ -203,11 +259,19 @@ export default function RegisterCorporate({isLoading, setIsLoading, mutateUser,
</div> </div>
<Button <Button
className="lg:mt-8 w-full" className="w-full lg:mt-8"
color="purple" color="purple"
disabled={ disabled={
isLoading || !email || !name || !password || !confirmPassword || password !== confirmPassword || !companyName || companyUsers <= 0 isLoading ||
}> !email ||
!name ||
!password ||
!confirmPassword ||
password !== confirmPassword ||
!companyName ||
companyUsers <= 0
}
>
Create account Create account
</Button> </Button>
</form> </form>

View File

@@ -1,12 +1,12 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import {User} from "@/interfaces/user"; import { User } from "@/interfaces/user";
import {sendEmailVerification} from "@/utils/email"; import { sendEmailVerification } from "@/utils/email";
import axios from "axios"; import axios from "axios";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import {toast} from "react-toastify"; import { toast } from "react-toastify";
import {KeyedMutator} from "swr"; import { KeyedMutator } from "swr";
interface Props { interface Props {
queryCode?: string; queryCode?: string;
@@ -21,7 +21,14 @@ interface Props {
sendEmailVerification: typeof sendEmailVerification; sendEmailVerification: typeof sendEmailVerification;
} }
export default function RegisterIndividual({queryCode, defaultInformation, isLoading, setIsLoading, mutateUser, sendEmailVerification}: Props) { export default function RegisterIndividual({
queryCode,
defaultInformation,
isLoading,
setIsLoading,
mutateUser,
sendEmailVerification,
}: Props) {
const [name, setName] = useState(defaultInformation?.name || ""); const [name, setName] = useState(defaultInformation?.name || "");
const [email, setEmail] = useState(defaultInformation?.email || ""); const [email, setEmail] = useState(defaultInformation?.email || "");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
@@ -29,18 +36,25 @@ export default function RegisterIndividual({queryCode, defaultInformation, isLoa
const [code, setCode] = useState(queryCode || ""); const [code, setCode] = useState(queryCode || "");
const [hasCode, setHasCode] = useState<boolean>(!!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) => { const onError = (e: Error) => {
console.error(e); console.error(e);
toast.error("Something went wrong, please logout and re-login.", {toastId: "send-verify-error"}); toast.error("Something went wrong, please logout and re-login.", {
toastId: "send-verify-error",
});
}; };
const register = (e: any) => { const register = (e: any) => {
e.preventDefault(); e.preventDefault();
if (confirmPassword !== password) { if (confirmPassword !== password) {
toast.error("Your passwords do not match!", {toastId: "password-not-match"}); toast.error("Your passwords do not match!", {
toastId: "password-not-match",
});
return; return;
} }
@@ -56,7 +70,9 @@ export default function RegisterIndividual({queryCode, defaultInformation, isLoa
profilePicture: "/defaultAvatar.png", profilePicture: "/defaultAvatar.png",
}) })
.then((response) => { .then((response) => {
mutateUser(response.data.user).then(() => sendEmailVerification(setIsLoading, onSuccess, onError)); mutateUser(response.data.user).then(() =>
sendEmailVerification(setIsLoading, onSuccess, onError),
);
}) })
.catch((error) => { .catch((error) => {
console.log(error.response.data); console.log(error.response.data);
@@ -77,12 +93,22 @@ export default function RegisterIndividual({queryCode, defaultInformation, isLoa
}; };
return ( return (
<form className="flex flex-col items-center gap-6 w-full" onSubmit={register}> <form
<Input type="text" name="name" onChange={(e) => setName(e)} placeholder="Enter your name" value={name} required /> 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 <Input
type="email" type="email"
name="email" name="email"
onChange={(e) => setEmail(e)} onChange={(e) => setEmail(e.toLowerCase())}
placeholder="Enter email address" placeholder="Enter email address"
value={email} value={email}
disabled={!!defaultInformation?.email} disabled={!!defaultInformation?.email}
@@ -105,7 +131,7 @@ export default function RegisterIndividual({queryCode, defaultInformation, isLoa
required required
/> />
<div className="flex flex-col gap-4 w-full items-start"> <div className="flex w-full flex-col items-start gap-4">
<Checkbox isChecked={hasCode} onChange={setHasCode}> <Checkbox isChecked={hasCode} onChange={setHasCode}>
I have a code I have a code
</Checkbox> </Checkbox>
@@ -122,9 +148,18 @@ export default function RegisterIndividual({queryCode, defaultInformation, isLoa
</div> </div>
<Button <Button
className="lg:mt-8 w-full" className="w-full lg:mt-8"
color="purple" color="purple"
disabled={isLoading || !email || !name || !password || !confirmPassword || password !== confirmPassword || (hasCode ? !code : false)}> disabled={
isLoading ||
!email ||
!name ||
!password ||
!confirmPassword ||
password !== confirmPassword ||
(hasCode ? !code : false)
}
>
Create account Create account
</Button> </Button>
</form> </form>

View File

@@ -9,6 +9,11 @@ import clsx from "clsx";
import {capitalize} from "lodash"; import {capitalize} from "lodash";
import {useState} from "react"; import {useState} from "react";
import getSymbolFromCurrency from "currency-symbol-map"; 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 { interface Props {
user: User; user: User;
@@ -20,9 +25,12 @@ interface Props {
export default function PaymentDue({user, hasExpired = false, clientID, reload}: Props) { export default function PaymentDue({user, hasExpired = false, clientID, reload}: Props) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const {packages} = usePackages(); const {packages} = usePackages();
const {users} = useUsers(); const {users} = useUsers();
const {groups} = useGroups(); const {groups} = useGroups();
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user?.id});
const isIndividual = () => { const isIndividual = () => {
if (user?.type === "developer") return true; if (user?.type === "developer") return true;
@@ -38,35 +46,69 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
return ( return (
<> <>
{isLoading && ( {isLoading && (
<div className="w-screen h-screen absolute top-0 left-0 overflow-hidden z-[999] bg-black/60"> <div className="absolute left-0 top-0 z-[999] h-screen w-screen overflow-hidden 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-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("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>
</div> </div>
)} )}
{user ? ( {user ? (
<Layout user={user} navDisabled={hasExpired}> <Layout user={user} navDisabled={hasExpired}>
<div className="flex flex-col items-center justify-center text-center w-full gap-4"> {invites.length > 0 && (
{hasExpired && <span className="font-bold text-lg">You do not have time credits for your account type!</span>} <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() && ( {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"> <span className="max-w-lg">
To add to your use of EnCoach, please purchase one of the time packages available below: To add to your use of EnCoach, please purchase one of the time packages available below:
</span> </span>
<div className="w-full flex flex-wrap justify-center gap-8"> <div className="flex w-full flex-wrap justify-center gap-8">
<PayPalScriptProvider
options={{
clientId: clientID,
currency: "USD",
intent: "capture",
commit: true,
vault: true,
}}>
{packages.map((p) => ( {packages.map((p) => (
<div key={p.id} className={clsx("p-4 bg-white rounded-xl flex flex-col gap-6 items-start")}> <div key={p.id} className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}>
<div className="flex flex-col items-start mb-2"> <div className="mb-2 flex flex-col items-start">
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" /> <img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" />
<span className="font-semibold text-xl"> <span className="text-xl font-semibold">
EnCoach - {p.duration}{" "} EnCoach - {p.duration}{" "}
{capitalize( {capitalize(
p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit, p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit,
)} )}
</span> </span>
</div> </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"> <span className="text-2xl">
{p.price} {p.price}
{getSymbolFromCurrency(p.currency)} {getSymbolFromCurrency(p.currency)}
@@ -81,7 +123,7 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
}} }}
/> />
</div> </div>
<div className="flex flex-col gap-1 items-start"> <div className="flex flex-col items-start gap-1">
<span>This includes:</span> <span>This includes:</span>
<ul className="flex flex-col items-start text-sm"> <ul className="flex flex-col items-start text-sm">
<li>- Train your abilities for the IELTS exam</li> <li>- Train your abilities for the IELTS exam</li>
@@ -91,6 +133,7 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
</div> </div>
</div> </div>
))} ))}
</PayPalScriptProvider>
</div> </div>
</div> </div>
)} )}
@@ -99,12 +142,12 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
<span className="max-w-lg"> <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: To add to your use of EnCoach and that of your students and teachers, please pay your designated package below:
</span> </span>
<div className={clsx("p-4 bg-white rounded-xl flex flex-col gap-6 items-start")}> <div className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}>
<div className="flex flex-col items-start mb-2"> <div className="mb-2 flex flex-col items-start">
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" /> <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>
<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"> <span className="text-2xl">
{user.corporateInformation.payment.value} {user.corporateInformation.payment.value}
{getSymbolFromCurrency(user.corporateInformation.payment.currency)} {getSymbolFromCurrency(user.corporateInformation.payment.currency)}
@@ -121,9 +164,10 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
setIsLoading(false); setIsLoading(false);
setTimeout(reload, 500); setTimeout(reload, 500);
}} }}
loadScript
/> />
</div> </div>
<div className="flex flex-col gap-1 items-start"> <div className="flex flex-col items-start gap-1">
<span>This includes:</span> <span>This includes:</span>
<ul className="flex flex-col items-start text-sm"> <ul className="flex flex-col items-start text-sm">
<li> <li>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,41 +1,85 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import {app} from "@/firebase"; import { app } from "@/firebase";
import {getFirestore, setDoc, doc, query, collection, where, getDocs} from "firebase/firestore"; import {
import {withIronSessionApiRoute} from "iron-session/next"; getFirestore,
import {sessionOptions} from "@/lib/session"; setDoc,
import {Type} from "@/interfaces/user"; doc,
import {PERMISSIONS} from "@/constants/userPermissions"; query,
import {uuidv4} from "@firebase/util"; collection,
import {prepareMailer, prepareMailOptions} from "@/email"; 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); const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return get(req, res);
if (req.method === "POST") return post(req, res);
return res.status(404).json({ ok: false });
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ok: false, reason: "You must be logged in to generate a code!"}); res
.status(401)
.json({ ok: false, reason: "You must be logged in to generate a code!" });
return; return;
} }
const {type, codes, infos, expiryDate} = req.body as { 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; type: Type;
codes: string[]; codes: string[];
infos?: {email: string; name: string; passport_id: string}[]; infos?: { email: string; name: string; passport_id?: string }[];
expiryDate: null | Date; expiryDate: null | Date;
}; };
const permission = PERMISSIONS.generateCode[type]; const permission = PERMISSIONS.generateCode[type];
if (!permission.includes(req.session.user.type)) { if (!permission.includes(req.session.user.type)) {
res.status(403).json({ok: false, reason: "Your account type does not have permissions to generate a code for that type of user!"}); res
.status(403)
.json({
ok: false,
reason:
"Your account type does not have permissions to generate a code for that type of user!",
});
return; return;
} }
if (req.session.user.type === "corporate") { if (req.session.user.type === "corporate") {
const codesGeneratedByUserSnapshot = await getDocs(query(collection(db, "codes"), where("creator", "==", req.session.user.id))); const codesGeneratedByUserSnapshot = await getDocs(
query(
collection(db, "codes"),
where("creator", "==", req.session.user.id),
),
);
const totalCodes = codesGeneratedByUserSnapshot.docs.length + codes.length; const totalCodes = codesGeneratedByUserSnapshot.docs.length + codes.length;
const allowedCodes = req.session.user.corporateInformation?.companyInformation.userAmount || 0; const allowedCodes =
req.session.user.corporateInformation?.companyInformation.userAmount || 0;
if (totalCodes > allowedCodes) { if (totalCodes > allowedCodes) {
res.status(403).json({ res.status(403).json({
@@ -50,11 +94,15 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const codePromises = codes.map(async (code, index) => { const codePromises = codes.map(async (code, index) => {
const codeRef = doc(db, "codes", code); const codeRef = doc(db, "codes", code);
await setDoc(codeRef, {type, code, creator: req.session.user!.id, expiryDate}); const codeInformation = {
type,
code,
creator: req.session.user!.id,
expiryDate,
};
if (infos && infos.length > index) { if (infos && infos.length > index) {
const {email, name, passport_id} = infos[index]; const { email, name, passport_id } = infos[index];
await setDoc(codeRef, {email: email.trim(), name: name.trim(), passport_id: passport_id.trim()}, {merge: true});
const transport = prepareMailer(); const transport = prepareMailer();
const mailOptions = prepareMailOptions( const mailOptions = prepareMailOptions(
@@ -62,16 +110,34 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
type, type,
code, code,
}, },
[email.trim()], [email.toLowerCase().trim()],
"EnCoach Registration", "EnCoach Registration",
"main", "main",
); );
try {
await transport.sendMail(mailOptions); 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(() => { Promise.all(codePromises).then((results) => {
res.status(200).json({ok: true}); 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) return res.status(401).json({ok: false});
if (req.session.user.type !== "developer") return res.status(403).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 {endpoint, topic, exercises} = req.query as {module: Module; endpoint: string[]; topic?: string; exercises?: string[]};
const url = `${process.env.BACKEND_URL}/${endpoint}`; const url = `${process.env.BACKEND_URL}/${endpoint.join("/")}`;
const result = await axios.post( const result = await axios.post(
`${url}${topic && exercises ? `?topic=${topic.toLowerCase()}&exercises=${exercises.join("&exercises=")}` : ""}`, `${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 {getFirestore, setDoc, doc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {Exam} from "@/interfaces/exam"; import {Exam, InstructorGender, Variant} from "@/interfaces/exam";
import { getExams } from "@/utils/exams.be"; import {getExams} from "@/utils/exams.be";
const db = getFirestore(app); const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -23,12 +23,14 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
return; return;
} }
const { const {module, avoidRepeated, variant, instructorGender} = req.query as {
module, module: string;
avoidRepeated, avoidRepeated: string;
} = 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); res.status(200).json(exams);
} }

View File

@@ -1,10 +1,19 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import {app} from "@/firebase"; import { app } from "@/firebase";
import {getFirestore, collection, getDocs, getDoc, doc, deleteDoc, setDoc} from "firebase/firestore"; import {
import {withIronSessionApiRoute} from "iron-session/next"; getFirestore,
import {sessionOptions} from "@/lib/session"; collection,
import {Group} from "@/interfaces/user"; 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); const db = getFirestore(app);
@@ -20,16 +29,16 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ok: false}); res.status(401).json({ ok: false });
return; return;
} }
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()) { if (snapshot.exists()) {
res.status(200).json({...snapshot.data(), id: snapshot.id}); res.status(200).json({ ...snapshot.data(), id: snapshot.id });
} else { } else {
res.status(404).json(undefined); res.status(404).json(undefined);
} }
@@ -37,44 +46,63 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
async function del(req: NextApiRequest, res: NextApiResponse) { async function del(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ok: false}); res.status(401).json({ ok: false });
return; return;
} }
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));
const group = {...snapshot.data(), id: snapshot.id} as Group; const group = { ...snapshot.data(), id: snapshot.id } as Group;
const user = req.session.user; const user = req.session.user;
if (user.type === "admin" || user.type === "developer" || user.id === group.admin) { if (
user.type === "admin" ||
user.type === "developer" ||
user.id === group.admin
) {
await deleteDoc(snapshot.ref); await deleteDoc(snapshot.ref);
res.status(200).json({ok: true}); res.status(200).json({ ok: true });
return; return;
} }
res.status(403).json({ok: false}); res.status(403).json({ ok: false });
} }
async function patch(req: NextApiRequest, res: NextApiResponse) { async function patch(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ok: false}); res.status(401).json({ ok: false });
return; return;
} }
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));
const group = {...snapshot.data(), id: snapshot.id} as Group; const group = { ...snapshot.data(), id: snapshot.id } as Group;
const user = req.session.user; const user = req.session.user;
if (user.type === "admin" || user.type === "developer" || user.id === group.admin) { if (
await setDoc(snapshot.ref, req.body, {merge: true}); 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}); await setDoc(snapshot.ref, req.body, { merge: true });
res.status(200).json({ ok: true });
return; return;
} }
res.status(403).json({ok: false}); res.status(403).json({ ok: false });
} }

View File

@@ -1,11 +1,20 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import {app} from "@/firebase"; import { app } from "@/firebase";
import {getFirestore, collection, getDocs, setDoc, doc, query, where} from "firebase/firestore"; import {
import {withIronSessionApiRoute} from "iron-session/next"; getFirestore,
import {sessionOptions} from "@/lib/session"; collection,
import {Group} from "@/interfaces/user"; getDocs,
import {v4} from "uuid"; 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); const db = getFirestore(app);
@@ -13,7 +22,7 @@ export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ok: false}); res.status(401).json({ ok: false });
return; return;
} }
@@ -22,13 +31,22 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
} }
async function get(req: NextApiRequest, res: NextApiResponse) { 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 = [ const queryConstraints = [
...(admin ? [where("admin", "==", admin)] : []), ...(admin ? [where("admin", "==", admin)] : []),
...(participant ? [where("participants", "array-contains", participant)] : []), ...(participant
? [where("participants", "array-contains", participant)]
: []),
]; ];
const snapshot = await getDocs(queryConstraints.length > 0 ? query(collection(db, "groups"), ...queryConstraints) : collection(db, "groups")); const snapshot = await getDocs(
queryConstraints.length > 0
? query(collection(db, "groups"), ...queryConstraints)
: collection(db, "groups"),
);
const groups = snapshot.docs.map((doc) => ({ const groups = snapshot.docs.map((doc) => ({
id: doc.id, id: doc.id,
...doc.data(), ...doc.data(),
@@ -40,6 +58,16 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
async function post(req: NextApiRequest, res: NextApiResponse) { 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}); await Promise.all(
res.status(200).json({ok: true}); 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 { NextApiRequest, NextApiResponse } from "next";
import {getAuth, signInWithEmailAndPassword} from "firebase/auth"; import { getAuth, signInWithEmailAndPassword } from "firebase/auth";
import {app} from "@/firebase"; import { app } from "@/firebase";
import {sessionOptions} from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import {withIronSessionApiRoute} from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import {User} from "@/interfaces/user"; import { User } from "@/interfaces/user";
import {getFirestore, getDoc, doc} from "firebase/firestore"; import { getFirestore, getDoc, doc } from "firebase/firestore";
const auth = getAuth(app); const auth = getAuth(app);
const db = getFirestore(app); const db = getFirestore(app);
@@ -12,27 +12,27 @@ const db = getFirestore(app);
export default withIronSessionApiRoute(login, sessionOptions); export default withIronSessionApiRoute(login, sessionOptions);
async function login(req: NextApiRequest, res: NextApiResponse) { 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) signInWithEmailAndPassword(auth, email.toLowerCase(), password)
.then(async (userCredentials) => { .then(async (userCredentials) => {
const userId = userCredentials.user.uid; const userId = userCredentials.user.uid;
const docUser = await getDoc(doc(db, "users", userId)); const docUser = await getDoc(doc(db, "users", userId));
if (!docUser.exists()) { if (!docUser.exists()) {
res.status(401).json({error: 401, message: "User does not exist!"}); res.status(401).json({ error: 401, message: "User does not exist!" });
return; return;
} }
const user = docUser.data() as User; const user = docUser.data() as User;
req.session.user = {...user, id: userId}; req.session.user = { ...user, id: userId };
await req.session.save(); await req.session.save();
res.status(200).json({user: {...user, id: userId}}); res.status(200).json({ user: { ...user, id: userId } });
}) })
.catch((error) => { .catch((error) => {
console.log(error); console.log(error);
res.status(401).json({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") { if (user.type === "admin" || user.type === "developer") {
await setDoc(snapshot.ref, req.body, {merge: true}); 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}); 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; const updatedDoc = (await getDoc(doc(db, "payments", paymentId))).data() as Payment;
if (updatedDoc.commissionTransfer && updatedDoc.corporateTransfer) { if (updatedDoc.commissionTransfer && updatedDoc.corporateTransfer) {
await setDoc(doc(db, "payments", paymentId), {isPaid: true}, {merge: true}); 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}); res.status(200).json({ref});
} catch (error) { } catch (error) {

View File

@@ -1,11 +1,23 @@
import {NextApiRequest, NextApiResponse} from "next"; import { NextApiRequest, NextApiResponse } from "next";
import {createUserWithEmailAndPassword, getAuth} from "firebase/auth"; import { createUserWithEmailAndPassword, getAuth } from "firebase/auth";
import {app} from "@/firebase"; import { app } from "@/firebase";
import {sessionOptions} from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import {withIronSessionApiRoute} from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import {getFirestore, doc, setDoc, query, collection, where, getDocs} from "firebase/firestore"; import {
import {CorporateInformation, DemographicInformation, Type} from "@/interfaces/user"; getFirestore,
import {addUserToGroupOnCreation} from "@/utils/registration"; 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"; import moment from "moment";
const auth = getAuth(app); const auth = getAuth(app);
@@ -28,7 +40,7 @@ const DEFAULT_LEVELS = {
}; };
async function register(req: NextApiRequest, res: NextApiResponse) { async function register(req: NextApiRequest, res: NextApiResponse) {
const {type} = req.body as { const { type } = req.body as {
type: "individual" | "corporate"; type: "individual" | "corporate";
}; };
@@ -37,7 +49,7 @@ async function register(req: NextApiRequest, res: NextApiResponse) {
} }
async function registerIndividual(req: NextApiRequest, res: NextApiResponse) { async function registerIndividual(req: NextApiRequest, res: NextApiResponse) {
const {email, passport_id, password, code} = req.body as { const { email, passport_id, password, code } = req.body as {
email: string; email: string;
passport_id?: string; passport_id?: string;
password: string; password: string;
@@ -45,66 +57,89 @@ async function registerIndividual(req: NextApiRequest, res: NextApiResponse) {
}; };
const codeQuery = query(collection(db, "codes"), where("code", "==", code)); const codeQuery = query(collection(db, "codes"), where("code", "==", code));
const codeDocs = (await getDocs(codeQuery)).docs.filter((x) => !Object.keys(x.data()).includes("userId")); const codeDocs = (await getDocs(codeQuery)).docs.filter(
(x) => !Object.keys(x.data()).includes("userId"),
);
if (code && code.length > 0 && codeDocs.length === 0) { if (code && code.length > 0 && codeDocs.length === 0) {
res.status(400).json({error: "Invalid Code!"}); res.status(400).json({ error: "Invalid Code!" });
return; 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) createUserWithEmailAndPassword(auth, email.toLowerCase(), password)
.then(async (userCredentials) => { .then(async (userCredentials) => {
const userId = userCredentials.user.uid; const userId = userCredentials.user.uid;
delete req.body.password; delete req.body.password;
const user = { const user = {
...req.body, ...req.body,
email: email.toLowerCase(),
desiredLevels: DEFAULT_DESIRED_LEVELS, desiredLevels: DEFAULT_DESIRED_LEVELS,
levels: DEFAULT_LEVELS, levels: DEFAULT_LEVELS,
bio: "", bio: "",
isFirstLogin: codeData ? codeData.type === "student" : true, isFirstLogin: codeData ? codeData.type === "student" : true,
focus: "academic", focus: "academic",
type: email.endsWith("@ecrop.dev") ? "developer" : codeData ? codeData.type : "student", type: email.endsWith("@ecrop.dev")
subscriptionExpirationDate: codeData ? codeData.expiryDate : moment().subtract(1, "days").toISOString(), ? "developer"
...(passport_id ? {demographicInformation: {passport_id}} : {}), : codeData
registrationDate: new Date(), ? 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", status: code ? "active" : "paymentDue",
}; };
await setDoc(doc(db, "users", userId), user); await setDoc(doc(db, "users", userId), user);
if (codeDocs.length > 0 && codeData) { if (codeDocs.length > 0 && codeData) {
await setDoc(codeDocs[0].ref, {userId: userId}, {merge: true}); await setDoc(codeDocs[0].ref, { userId: userId }, { merge: true });
if (codeData.creator) await addUserToGroupOnCreation(userId, codeData.type, codeData.creator); if (codeData.creator)
await addUserToGroupOnCreation(
userId,
codeData.type,
codeData.creator,
);
} }
req.session.user = {...user, id: userId}; req.session.user = { ...user, id: userId };
await req.session.save(); await req.session.save();
res.status(200).json({user: {...user, id: userId}}); res.status(200).json({ user: { ...user, id: userId } });
}) })
.catch((error) => { .catch((error) => {
console.log(error); console.log(error);
res.status(401).json({error}); res.status(401).json({ error });
}); });
} }
async function registerCorporate(req: NextApiRequest, res: NextApiResponse) { async function registerCorporate(req: NextApiRequest, res: NextApiResponse) {
const {email, password} = req.body as { const { email, password } = req.body as {
email: string; email: string;
password: string; password: string;
corporateInformation: CorporateInformation; corporateInformation: CorporateInformation;
}; };
createUserWithEmailAndPassword(auth, email, password) createUserWithEmailAndPassword(auth, email.toLowerCase(), password)
.then(async (userCredentials) => { .then(async (userCredentials) => {
const userId = userCredentials.user.uid; const userId = userCredentials.user.uid;
delete req.body.password; delete req.body.password;
const user = { const user = {
...req.body, ...req.body,
email: email.toLowerCase(),
desiredLevels: DEFAULT_DESIRED_LEVELS, desiredLevels: DEFAULT_DESIRED_LEVELS,
levels: DEFAULT_LEVELS, levels: DEFAULT_LEVELS,
bio: "", bio: "",
@@ -118,13 +153,13 @@ async function registerCorporate(req: NextApiRequest, res: NextApiResponse) {
await setDoc(doc(db, "users", userId), user); await setDoc(doc(db, "users", userId), user);
req.session.user = {...user, id: userId}; req.session.user = { ...user, id: userId };
await req.session.save(); await req.session.save();
res.status(200).json({user: {...user, id: userId}}); res.status(200).json({ user: { ...user, id: userId } });
}) })
.catch((error) => { .catch((error) => {
console.log(error); console.log(error);
res.status(401).json({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 type {NextApiRequest, NextApiResponse} from "next";
import { app, storage } from "@/firebase"; import {app, storage} from "@/firebase";
import { import {getFirestore, doc, getDoc, updateDoc, getDocs, query, collection, where} from "firebase/firestore";
getFirestore, import {withIronSessionApiRoute} from "iron-session/next";
doc, import {sessionOptions} from "@/lib/session";
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 ReactPDF from "@react-pdf/renderer";
import TestReport from "@/exams/pdf/test.report"; import TestReport from "@/exams/pdf/test.report";
import { ref, uploadBytes, getDownloadURL } from "firebase/storage"; import {ref, uploadBytes, getDownloadURL} from "firebase/storage";
import { DemographicInformation, User } from "@/interfaces/user"; import {DemographicInformation, User} from "@/interfaces/user";
import { Module } from "@/interfaces"; import {Module} from "@/interfaces";
import { ModuleScore } from "@/interfaces/module.scores"; import {ModuleScore} from "@/interfaces/module.scores";
import { SkillExamDetails } from "@/exams/pdf/details/skill.exam"; import {SkillExamDetails} from "@/exams/pdf/details/skill.exam";
import { LevelExamDetails } from "@/exams/pdf/details/level.exam"; import {LevelExamDetails} from "@/exams/pdf/details/level.exam";
import { calculateBandScore } from "@/utils/score"; import {calculateBandScore} from "@/utils/score";
import axios from "axios"; import axios from "axios";
import { moduleLabels } from "@/utils/moduleUtils"; import {moduleLabels} from "@/utils/moduleUtils";
import { import {generateQRCode, getRadialProgressPNG, streamToBuffer} from "@/utils/pdf";
generateQRCode,
getRadialProgressPNG,
streamToBuffer,
} from "@/utils/pdf";
import moment from "moment-timezone"; import moment from "moment-timezone";
const db = getFirestore(app); const db = getFirestore(app);
@@ -97,21 +84,19 @@ interface SkillsFeedbackResponse extends SkillsFeedbackRequest {
const getSkillsFeedback = async (sections: SkillsFeedbackRequest[]) => { const getSkillsFeedback = async (sections: SkillsFeedbackRequest[]) => {
const backendRequest = await axios.post( const backendRequest = await axios.post(
`${process.env.BACKEND_URL}/grading_summary`, `${process.env.BACKEND_URL}/grading_summary`,
{ sections }, {sections},
{ {
headers: { headers: {
Authorization: `Bearer ${process.env.BACKEND_JWT}`, Authorization: `Bearer ${process.env.BACKEND_JWT}`,
}, },
} },
); );
return backendRequest.data?.sections; return backendRequest.data?.sections;
}; };
// perform the request with several retries if needed // perform the request with several retries if needed
const handleSkillsFeedbackRequest = async ( const handleSkillsFeedbackRequest = async (sections: SkillsFeedbackRequest[]): Promise<SkillsFeedbackResponse[] | null> => {
sections: SkillsFeedbackRequest[]
): Promise<SkillsFeedbackResponse[] | null> => {
let i = 0; let i = 0;
try { try {
const data = await getSkillsFeedback(sections); const data = await getSkillsFeedback(sections);
@@ -129,15 +114,9 @@ const handleSkillsFeedbackRequest = async (
async function post(req: NextApiRequest, res: NextApiResponse) { async function post(req: NextApiRequest, res: NextApiResponse) {
// verify if it's a logged user that is trying to export // verify if it's a logged user that is trying to export
if (req.session.user) { if (req.session.user) {
const { id } = req.query as { id: string }; const {id} = req.query as {id: string};
// fetch stats entries for this particular user with the requested exam session // fetch stats entries for this particular user with the requested exam session
const docsSnap = await getDocs( const docsSnap = await getDocs(query(collection(db, "stats"), where("session", "==", id)));
query(
collection(db, "stats"),
where("session", "==", id),
where("user", "==", req.session.user.id)
)
);
if (docsSnap.empty) { if (docsSnap.empty) {
res.status(400).end(); res.status(400).end();
@@ -146,11 +125,20 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const stats = docsSnap.docs.map((d) => d.data()); const stats = docsSnap.docs.map((d) => d.data());
// verify if the stats already have a pdf generated // verify if the stats already have a pdf generated
const hasPDF = stats.find((s) => s.pdf); 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(statIndex === -1) {
res.status(401).json({ok: false});
return;
}
const userId = stats[statIndex].user;
if (hasPDF) { if (hasPDF) {
// if it does, return the pdf url // if it does, return the pdf url
const fileRef = ref(storage, hasPDF.pdf); const fileRef = ref(storage, hasPDF.pdf.path);
const url = await getDownloadURL(fileRef); const url = await getDownloadURL(fileRef);
res.status(200).end(url); res.status(200).end(url);
@@ -159,27 +147,24 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
try { try {
// generate the pdf report // generate the pdf report
const docUser = await getDoc(doc(db, "users", req.session.user.id)); const docUser = await getDoc(doc(db, "users", userId));
if (docUser.exists()) { if (docUser.exists()) {
// we'll need the user in order to get the user data (name, email, focus, etc); // we'll need the user in order to get the user data (name, email, focus, etc);
const user = docUser.data() as User; const user = docUser.data() as User;
// generate the QR code for the report // generate the QR code for the report
const qrcode = await generateQRCode( const qrcode = await generateQRCode((req.headers.origin || "") + req.url);
(req.headers.origin || "") + req.url
);
if (!qrcode) { if (!qrcode) {
res.status(500).json({ ok: false }); res.status(500).json({ok: false});
return; return;
} }
// stats may contain multiple exams of the same type so we need to aggregate them // stats may contain multiple exams of the same type so we need to aggregate them
const results = ( const results = (
stats.reduce((accm: ModuleScore[], { module, score }) => { stats.reduce((accm: ModuleScore[], {module, score}) => {
const fixedModuleStr = const fixedModuleStr = module[0].toUpperCase() + module.substring(1);
module[0].toUpperCase() + module.substring(1);
if (accm.find((e: ModuleScore) => e.module === fixedModuleStr)) { if (accm.find((e: ModuleScore) => e.module === fixedModuleStr)) {
return accm.map((e: ModuleScore) => { return accm.map((e: ModuleScore) => {
if (e.module === fixedModuleStr) { if (e.module === fixedModuleStr) {
@@ -205,14 +190,9 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
]; ];
}, []) as ModuleScore[] }, []) as ModuleScore[]
).map((moduleScore) => { ).map((moduleScore) => {
const { score, total } = moduleScore; const {score, total} = moduleScore;
// with all the scores aggreated we can calculate the band score for each module // with all the scores aggreated we can calculate the band score for each module
const bandScore = calculateBandScore( const bandScore = calculateBandScore(score, total, moduleScore.code as Module, user.focus);
score,
total,
moduleScore.code as Module,
user.focus
);
return { return {
...moduleScore, ...moduleScore,
@@ -224,23 +204,21 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
// get the skills feedback from the backend based on the module grade // get the skills feedback from the backend based on the module grade
const skillsFeedback = (await handleSkillsFeedbackRequest( const skillsFeedback = (await handleSkillsFeedbackRequest(
results.map(({ code, bandScore }) => ({ results.map(({code, bandScore}) => ({
code, code,
name: moduleLabels[code], name: moduleLabels[code],
grade: bandScore, grade: bandScore,
})) })),
)) as SkillsFeedbackResponse[]; )) as SkillsFeedbackResponse[];
if (!skillsFeedback) { if (!skillsFeedback) {
res.status(500).json({ ok: false }); res.status(500).json({ok: false});
return; return;
} }
// assign the feedback to the results // assign the feedback to the results
const finalResults = results.map((result) => { const finalResults = results.map((result) => {
const feedback = skillsFeedback.find( const feedback = skillsFeedback.find((f: SkillsFeedbackResponse) => f.code === result.code);
(f: SkillsFeedbackResponse) => f.code === result.code
);
if (feedback) { if (feedback) {
return { return {
@@ -254,14 +232,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
}); });
// calculate the overall score out of all the aggregated results // calculate the overall score out of all the aggregated results
const overallScore = results.reduce( const overallScore = results.reduce((accm, {score}) => accm + score, 0);
(accm, { score }) => accm + score, const overallTotal = results.reduce((accm, {total}) => accm + total, 0);
0
);
const overallTotal = results.reduce(
(accm, { total }) => accm + total,
0
);
const overallResult = overallScore / overallTotal; const overallResult = overallScore / overallTotal;
const overallPNG = getRadialProgressPNG("laranja", overallScore, overallTotal); const overallPNG = getRadialProgressPNG("laranja", overallScore, overallTotal);
@@ -278,22 +250,14 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const [stat] = stats; const [stat] = stats;
// generate the performance summary based on the overall result // generate the performance summary based on the overall result
const performanceSummary = getPerformanceSummary( const performanceSummary = getPerformanceSummary(stat.module, overallResult);
stat.module,
overallResult
);
// level exams have a different report structure than the skill exams // level exams have a different report structure than the skill exams
const getCustomData = () => { const getCustomData = () => {
if (stat.module === "level") { if (stat.module === "level") {
return { return {
title: "ENGLISH LEVEL TEST RESULT REPORT ", title: "ENGLISH LEVEL TEST RESULT REPORT ",
details: ( details: <LevelExamDetails detail={overallDetail} title="Level as per CEFR Levels" />,
<LevelExamDetails
detail={overallDetail}
title="Level as per CEFR Levels"
/>
),
}; };
} }
@@ -303,16 +267,18 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
}; };
}; };
const { title, details } = getCustomData(); const {title, details} = getCustomData();
const demographicInformation = user.demographicInformation as DemographicInformation; const demographicInformation = user.demographicInformation as DemographicInformation;
const pdfStream = await ReactPDF.renderToStream( const pdfStream = await ReactPDF.renderToStream(
<TestReport <TestReport
title={title} title={title}
date={moment(stat.date).tz(user.demographicInformation?.timezone || 'UTC').format('ll HH:mm:ss')} date={moment(stat.date)
.tz(user.demographicInformation?.timezone || "UTC")
.format("ll HH:mm:ss")}
name={user.name} name={user.name}
email={user.email} email={user.email}
id={user.id} id={userId}
gender={demographicInformation?.gender} gender={demographicInformation?.gender}
summary={performanceSummary} summary={performanceSummary}
testDetails={testDetails} testDetails={testDetails}
@@ -322,7 +288,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
summaryPNG={overallPNG} summaryPNG={overallPNG}
summaryScore={`${(overallResult * 100).toFixed(0)}%`} summaryScore={`${(overallResult * 100).toFixed(0)}%`}
passportId={demographicInformation?.passport_id || ""} passportId={demographicInformation?.passport_id || ""}
/> />,
); );
// generate the file ref for storage // generate the file ref for storage
@@ -339,7 +305,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
// update the stats entries with the pdf url to prevent duplication // update the stats entries with the pdf url to prevent duplication
docsSnap.docs.forEach(async (doc) => { docsSnap.docs.forEach(async (doc) => {
await updateDoc(doc.ref, { await updateDoc(doc.ref, {
pdf: refName, pdf: {
path: refName,
version: process.env.PDF_VERSION,
},
}); });
}); });
const url = await getDownloadURL(fileRef); const url = await getDownloadURL(fileRef);
@@ -347,23 +316,21 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
return; return;
} }
res.status(401).json({ ok: false }); res.status(401).json({ok: false});
return; return;
} catch (err) { } catch (err) {
res.status(500).json({ ok: false }); res.status(500).json({ok: false});
return; return;
} }
} }
res.status(401).json({ ok: false }); res.status(401).json({ok: false});
return; return;
} }
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.query as { id: string }; const {id} = req.query as {id: string};
const docsSnap = await getDocs( const docsSnap = await getDocs(query(collection(db, "stats"), where("session", "==", id)));
query(collection(db, "stats"), where("session", "==", id))
);
if (docsSnap.empty) { if (docsSnap.empty) {
res.status(404).end(); res.status(404).end();
@@ -372,10 +339,10 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
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) { if (hasPDF) {
const fileRef = ref(storage, hasPDF.pdf); const fileRef = ref(storage, hasPDF.pdf.path);
const url = await getDownloadURL(fileRef); const url = await getDownloadURL(fileRef);
return res.redirect(url); return res.redirect(url);
} }

View File

@@ -1,7 +1,7 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase"; import {app} from "@/firebase";
import {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 {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {Stat} from "@/interfaces/user"; import {Stat} from "@/interfaces/user";
@@ -43,6 +43,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const stats = req.body as Stat[]; const stats = req.body as Stat[];
await stats.forEach(async (stat) => await setDoc(doc(db, "stats", stat.id), 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( const groupedStatsByAssignment = groupBy(
stats.filter((x) => !!x.assignment), 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