Compare commits

...

109 Commits

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

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

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

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

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

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

Approved-by: Tiago Ribeiro
2024-03-05 16:04:28 +00:00
125 changed files with 7697 additions and 5399 deletions

View File

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

Binary file not shown.

BIN
public/manuals/student.pdf Normal file

Binary file not shown.

BIN
public/manuals/teacher.pdf Normal file

Binary file not shown.

View File

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

View File

@@ -103,7 +103,6 @@ export default function InteractiveSpeaking({
useEffect(() => { useEffect(() => {
if (userSolutions.length > 0 && answers.length === 0) { if (userSolutions.length > 0 && answers.length === 0) {
console.log(userSolutions);
const solutions = userSolutions as unknown as typeof answers; const solutions = userSolutions as unknown as typeof answers;
setAnswers(solutions); setAnswers(solutions);
@@ -112,10 +111,6 @@ export default function InteractiveSpeaking({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [userSolutions, mediaBlob, answers]); }, [userSolutions, mediaBlob, answers]);
useEffect(() => {
console.log({answers});
}, [answers]);
useEffect(() => { useEffect(() => {
if (updateIndex) updateIndex(questionIndex); if (updateIndex) updateIndex(questionIndex);
}, [questionIndex, updateIndex]); }, [questionIndex, updateIndex]);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -55,7 +55,14 @@ export default function PayPalPayment({
trackingId, trackingId,
}) })
.then((response) => response.data) .then((response) => response.data)
.then((data) => data.id); .then((data) => {
setIsLoading(false);
return data.id;
})
.catch((err) => {
setIsLoading(false);
return err;
});
}; };
const onApprove = async (data: OnApproveData, actions: OnApproveActions) => { const onApprove = async (data: OnApproveData, actions: OnApproveActions) => {
@@ -63,18 +70,26 @@ export default function PayPalPayment({
throw new Error("trackingId is not set"); throw new Error("trackingId is not set");
} }
const request = await axios.post<{ ok: boolean; reason?: string }>( axios
"/api/paypal/approve", .post<{ ok: boolean; reason?: string }>("/api/paypal/approve", {
{ id: data.orderID, duration, duration_unit, trackingId } id: data.orderID,
); duration,
duration_unit,
trackingId,
})
.then((request) => {
if (request.status !== 200) {
toast.error("Something went wrong, please try again later");
return;
}
if (request.status !== 200) { toast.success("Your account has been credited more time!");
toast.error("Something went wrong, please try again later"); return onSuccess(duration, duration_unit);
return; })
} .catch((err) => {
console.error(err);
toast.success("Your account has been credited more time!"); toast.error("Something went wrong, please try again later");
return onSuccess(duration, duration_unit); });
}; };
const onError = async (data: Record<string, unknown>) => { const onError = async (data: Record<string, unknown>) => {
@@ -96,7 +111,6 @@ export default function PayPalPayment({
currency, currency,
intent: "capture", intent: "capture",
commit: true, commit: true,
vault: true,
}} }}
> >
<PayPalButtons <PayPalButtons

View File

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

View File

@@ -79,8 +79,6 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
const {totalAssignedTickets} = useTicketsListener(userId); const {totalAssignedTickets} = useTicketsListener(userId);
useEffect(() => console.log(totalAssignedTickets), [totalAssignedTickets]);
const logout = async () => { const logout = async () => {
axios.post("/api/logout").finally(() => { axios.post("/api/logout").finally(() => {
setTimeout(() => router.reload(), 500); setTimeout(() => router.reload(), 500);
@@ -118,8 +116,12 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
/> />
</> </>
)} )}
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} /> {(userType || "") !== 'agent' && (
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} /> <>
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
</>
)}
{["admin", "developer", "agent", "corporate"].includes(userType || "") && ( {["admin", "developer", "agent", "corporate"].includes(userType || "") && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
@@ -166,8 +168,12 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} /> <Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} />
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} /> <Nav disabled={disableNavigation} Icon={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={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} /> {(userType || "") !== 'agent' && (
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" 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} />
)} )}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ import {CorporateUser, User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams"; import {getExamById} from "@/utils/exams";
import {getUserCorporate} from "@/utils/groups"; import {getUserCorporate} from "@/utils/groups";
import {MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils"; import {countExamModules, countFullExams, MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
import {getLevelLabel, getLevelScore} from "@/utils/score"; import {getLevelLabel, getLevelScore} from "@/utils/score";
import {averageScore, groupBySession} from "@/utils/stats"; import {averageScore, groupBySession} from "@/utils/stats";
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js"; import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js";
@@ -35,7 +35,7 @@ interface Props {
export default function StudentDashboard({user}: Props) { export default function StudentDashboard({user}: Props) {
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>(); const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
const {stats} = useStats(user.id); const {stats} = useStats(user.id, !user?.id);
const {users} = useUsers(); const {users} = useUsers();
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id}); const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id});
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user.id}); const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user.id});
@@ -84,16 +84,16 @@ export default function StudentDashboard({user}: Props) {
user={user} user={user}
items={[ items={[
{ {
icon: <BsFileEarmarkText className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />, icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: Object.keys(groupBySession(stats)).length, value: countFullExams(stats),
label: "Exams", label: "Exams",
tooltip: "Number of all conducted completed exams", tooltip: "Number of all conducted completed exams",
}, },
{ {
icon: <BsPencil className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />, icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: stats.length, value: countExamModules(stats),
label: "Exercises", label: "Modules",
tooltip: "Number of all conducted exercises including Level Test", tooltip: "Number of all exam modules performed including Level Test",
}, },
{ {
icon: <BsStar className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />, icon: <BsStar className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,

View File

@@ -151,8 +151,10 @@ export default function TeacherDashboard({user}: Props) {
}; };
const AssignmentsPage = () => { const AssignmentsPage = () => {
const activeFilter = (a: Assignment) => moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()) && a.assignees.length > a.results.length; const activeFilter = (a: Assignment) =>
moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()) && a.assignees.length > a.results.length;
const pastFilter = (a: Assignment) => (moment(a.endDate).isBefore(moment()) || a.assignees.length === a.results.length) && !a.archived; const pastFilter = (a: Assignment) => (moment(a.endDate).isBefore(moment()) || a.assignees.length === a.results.length) && !a.archived;
const archivedFilter = (a: Assignment) => a.archived;
const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment()); const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
return ( return (
@@ -234,7 +236,29 @@ export default function TeacherDashboard({user}: Props) {
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2> <h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{assignments.filter(pastFilter).map((a) => ( {assignments.filter(pastFilter).map((a) => (
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} allowDownload reload={reloadAssignments} allowArchive/> <AssignmentCard
{...a}
onClick={() => setSelectedAssignment(a)}
key={a.id}
allowDownload
reload={reloadAssignments}
allowArchive
/>
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Archived Assignments ({assignments.filter(archivedFilter).length})</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(archivedFilter).map((a) => (
<AssignmentCard
{...a}
onClick={() => setSelectedAssignment(a)}
key={a.id}
allowDownload
reload={reloadAssignments}
allowUnarchive
/>
))} ))}
</div> </div>
</section> </section>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -64,6 +64,7 @@ export interface UserSolution {
missing: number; missing: number;
}; };
exercise: string; exercise: string;
isDisabled?: boolean;
} }
export interface WritingExam { export interface WritingExam {
@@ -232,17 +233,21 @@ export interface MatchSentencesExercise {
id: string; id: string;
prompt: string; prompt: string;
userSolutions: {question: string; option: string}[]; userSolutions: {question: string; option: string}[];
sentences: { sentences: MatchSentenceExerciseSentence[];
id: string;
sentence: string;
solution: string;
color: string;
}[];
allowRepetition: boolean; allowRepetition: boolean;
options: { options: MatchSentenceExerciseOption[];
id: string; }
sentence: string;
}[]; export interface MatchSentenceExerciseSentence {
id: string;
sentence: string;
solution: string;
color: string;
}
export interface MatchSentenceExerciseOption {
id: string;
sentence: string;
} }
export interface MultipleChoiceExercise { export interface MultipleChoiceExercise {

View File

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

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

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

View File

@@ -1,50 +1,55 @@
export interface TokenSuccess { export interface TokenSuccess {
scope: string; scope: string;
access_token: string; access_token: string;
token_type: string; token_type: string;
app_id: string; app_id: string;
expires_in: number; expires_in: number;
nonce: string; nonce: string;
} }
export interface TokenError { export interface TokenError {
error: string; error: string;
error_description: string; error_description: string;
} }
export interface Package { export interface Package {
id: string; id: string;
currency: string; currency: string;
duration: number; duration: number;
duration_unit: DurationUnit; duration_unit: DurationUnit;
price: number; price: number;
}
export interface Discount {
id: string;
percentage: number;
domain: string;
} }
export type DurationUnit = "weeks" | "days" | "months" | "years"; export type DurationUnit = "weeks" | "days" | "months" | "years";
export interface Payment { export interface Payment {
id: string; id: string;
corporate: string; corporate: string;
agent?: string; agent?: string;
agentCommission: number; agentCommission: number;
agentValue: number; agentValue: number;
currency: string; currency: string;
value: number; value: number;
isPaid: boolean; isPaid: boolean;
date: Date | string; date: Date | string;
corporateTransfer?: string; corporateTransfer?: string;
commissionTransfer?: string; commissionTransfer?: string;
} }
export interface PaypalPayment { export interface PaypalPayment {
orderId: string; orderId: string;
userId: string; userId: string;
status: string; status: string;
createdAt: Date; createdAt: Date;
value: number; value: number;
currency: string; currency: string;
subscriptionDuration: number; subscriptionDuration: number;
subscriptionDurationUnit: DurationUnit; subscriptionDurationUnit: DurationUnit;
subscriptionExpirationDate: Date; subscriptionExpirationDate: Date;
} }

View File

@@ -1,38 +1,48 @@
import { Type } from "./user"; import {Module} from ".";
import {Type} from "./user";
export interface Ticket { export interface Ticket {
id: string; id: string;
date: string; date: string;
status: TicketStatus; status: TicketStatus;
type: TicketType; type: TicketType;
reporter: TicketReporter; reporter: TicketReporter;
reportedFrom: string; reportedFrom: string;
description: string; description: string;
subject: string; subject: string;
assignedTo?: string; assignedTo?: string;
examInformation?: {
exams: string[];
exam: string;
selectedModules: Module[];
moduleIndex: number;
partIndex: number;
exerciseIndex: number;
questionIndex: number;
};
} }
export interface TicketReporter { export interface TicketReporter {
id: string; id: string;
name: string; name: string;
email: string; email: string;
type: Type; type: Type;
} }
export type TicketType = "feedback" | "bug" | "help"; export type TicketType = "feedback" | "bug" | "help";
export const TicketTypeLabel: { [key in TicketType]: string } = { export const TicketTypeLabel: {[key in TicketType]: string} = {
feedback: "Feedback", feedback: "Feedback",
bug: "Bug", bug: "Bug",
help: "Help", help: "Help",
}; };
export type TicketStatus = "submitted" | "in-progress" | "completed"; export type TicketStatus = "submitted" | "in-progress" | "completed";
export const TicketStatusLabel: { [key in TicketStatus]: string } = { export const TicketStatusLabel: {[key in TicketStatus]: string} = {
submitted: "Submitted", submitted: "Submitted",
"in-progress": "In Progress", "in-progress": "In Progress",
completed: "Completed", completed: "Completed",
}; };
export interface TicketWithCorporate extends Ticket { export interface TicketWithCorporate extends Ticket {
corporate?: string; corporate?: string;
} }

View File

@@ -1,147 +1,177 @@
import {Module} from "."; import { Module } from ".";
import {InstructorGender} from "./exam"; import { InstructorGender } from "./exam";
export type User = StudentUser | TeacherUser | CorporateUser | AgentUser | AdminUser | DeveloperUser; export type User =
| StudentUser
| TeacherUser
| CorporateUser
| AgentUser
| AdminUser
| DeveloperUser;
export type UserStatus = "active" | "disabled" | "paymentDue";
export interface BasicUser { export interface BasicUser {
email: string; email: string;
name: string; name: string;
profilePicture: string; profilePicture: string;
id: string; id: string;
isFirstLogin: boolean; isFirstLogin: boolean;
focus: "academic" | "general"; focus: "academic" | "general";
levels: {[key in Module]: number}; levels: { [key in Module]: number };
desiredLevels: {[key in Module]: number}; desiredLevels: { [key in Module]: number };
type: Type; type: Type;
bio: string; bio: string;
isVerified: boolean; isVerified: boolean;
subscriptionExpirationDate?: null | Date; subscriptionExpirationDate?: null | Date;
registrationDate?: Date; registrationDate?: Date;
status: "active" | "disabled" | "paymentDue"; status: UserStatus;
} }
export interface StudentUser extends BasicUser { export interface StudentUser extends BasicUser {
type: "student"; type: "student";
preferredGender?: InstructorGender; preferredGender?: InstructorGender;
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
preferredTopics?: string[]; preferredTopics?: string[];
} }
export interface TeacherUser extends BasicUser { export interface TeacherUser extends BasicUser {
type: "teacher"; type: "teacher";
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
} }
export interface CorporateUser extends BasicUser { export interface CorporateUser extends BasicUser {
type: "corporate"; type: "corporate";
corporateInformation: CorporateInformation; corporateInformation: CorporateInformation;
demographicInformation?: DemographicCorporateInformation; demographicInformation?: DemographicCorporateInformation;
} }
export interface AgentUser extends BasicUser { export interface AgentUser extends BasicUser {
type: "agent"; type: "agent";
agentInformation: AgentInformation; agentInformation: AgentInformation;
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
} }
export interface AdminUser extends BasicUser { export interface AdminUser extends BasicUser {
type: "admin"; type: "admin";
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
} }
export interface DeveloperUser extends BasicUser { export interface DeveloperUser extends BasicUser {
type: "developer"; type: "developer";
preferredGender?: InstructorGender; preferredGender?: InstructorGender;
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
preferredTopics?: string[]; preferredTopics?: string[];
} }
export interface CorporateInformation { export interface CorporateInformation {
companyInformation: CompanyInformation; companyInformation: CompanyInformation;
monthlyDuration: number; monthlyDuration: number;
payment?: { payment?: {
value: number; value: number;
currency: string; currency: string;
commission: number; commission: number;
}; };
referralAgent?: string; referralAgent?: string;
} }
export interface AgentInformation { export interface AgentInformation {
companyName: string; companyName: string;
commercialRegistration: string; commercialRegistration: string;
companyArabName?: string;
} }
export interface CompanyInformation { export interface CompanyInformation {
name: string; name: string;
userAmount: number; userAmount: number;
} }
export interface DemographicInformation { export interface DemographicInformation {
country: string; country: string;
phone: string; phone: string;
gender: Gender; gender: Gender;
employment: EmploymentStatus; employment: EmploymentStatus;
passport_id?: string; passport_id?: string;
timezone?: string; timezone?: string;
} }
export interface DemographicCorporateInformation { export interface DemographicCorporateInformation {
country: string; country: string;
phone: string; phone: string;
gender: Gender; gender: Gender;
position: string; position: string;
timezone?: string; timezone?: string;
} }
export type Gender = "male" | "female" | "other"; export type Gender = "male" | "female" | "other";
export type EmploymentStatus = "employed" | "student" | "self-employed" | "unemployed" | "retired" | "other"; export type EmploymentStatus =
export const EMPLOYMENT_STATUS: {status: EmploymentStatus; label: string}[] = [ | "employed"
{status: "student", label: "Student"}, | "student"
{status: "employed", label: "Employed"}, | "self-employed"
{status: "unemployed", label: "Unemployed"}, | "unemployed"
{status: "self-employed", label: "Self-employed"}, | "retired"
{status: "retired", label: "Retired"}, | "other";
{status: "other", label: "Other"}, export const EMPLOYMENT_STATUS: { status: EmploymentStatus; label: string }[] =
]; [
{ status: "student", label: "Student" },
{ status: "employed", label: "Employed" },
{ status: "unemployed", label: "Unemployed" },
{ status: "self-employed", label: "Self-employed" },
{ status: "retired", label: "Retired" },
{ status: "other", label: "Other" },
];
export interface Stat { export interface Stat {
id: string; id: string;
user: string; user: string;
exam: string; exam: string;
exercise: string; exercise: string;
session: string; session: string;
date: number; date: number;
module: Module; module: Module;
solutions: any[]; solutions: any[];
type: string; type: string;
timeSpent?: number; timeSpent?: number;
assignment?: string; assignment?: string;
score: { score: {
correct: number; correct: number;
total: number; total: number;
missing: number; missing: number;
}; };
isDisabled?: boolean;
} }
export interface Group { export interface Group {
admin: string; admin: string;
name: string; name: string;
participants: string[]; participants: string[];
id: string; id: string;
disableEditing?: boolean; disableEditing?: boolean;
} }
export interface Code { export interface Code {
code: string; code: string;
creator: string; creator: string;
expiryDate: Date; expiryDate: Date;
type: Type; type: Type;
userId?: string; creationDate?: string;
email?: string; userId?: string;
name?: string; email?: string;
passport_id?: string; name?: string;
passport_id?: string;
} }
export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent"; export type Type =
export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent"]; | "student"
| "teacher"
| "corporate"
| "admin"
| "developer"
| "agent";
export const userTypes: Type[] = [
"student",
"teacher",
"corporate",
"admin",
"developer",
"agent",
];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import {Module} from "@/interfaces"; import { Module } from "@/interfaces";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import AbandonPopup from "@/components/AbandonPopup"; import AbandonPopup from "@/components/AbandonPopup";
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
@@ -12,429 +12,567 @@ import Selection from "@/exams/Selection";
import Speaking from "@/exams/Speaking"; import Speaking from "@/exams/Speaking";
import Writing from "@/exams/Writing"; import Writing from "@/exams/Writing";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
import {Exam, UserSolution, Variant} from "@/interfaces/exam"; import { Exam, UserSolution, Variant } from "@/interfaces/exam";
import {Stat} from "@/interfaces/user"; import { Stat } from "@/interfaces/user";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {evaluateSpeakingAnswer, evaluateWritingAnswer} from "@/utils/evaluation"; import {
import {defaultExamUserSolutions, getExam} from "@/utils/exams"; evaluateSpeakingAnswer,
evaluateWritingAnswer,
} from "@/utils/evaluation";
import { defaultExamUserSolutions, getExam } from "@/utils/exams";
import axios from "axios"; import axios from "axios";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import {toast, ToastContainer} from "react-toastify"; import { toast, ToastContainer } from "react-toastify";
import {v4 as uuidv4} from "uuid"; import { v4 as uuidv4 } from "uuid";
import useSessions from "@/hooks/useSessions"; import useSessions from "@/hooks/useSessions";
import ShortUniqueId from "short-unique-id"; import ShortUniqueId from "short-unique-id";
interface Props { interface Props {
page: "exams" | "exercises"; page: "exams" | "exercises";
} }
export default function ExamPage({page}: Props) { export default function ExamPage({ page }: Props) {
const [variant, setVariant] = useState<Variant>("full"); const [variant, setVariant] = useState<Variant>("full");
const [avoidRepeated, setAvoidRepeated] = useState(false); const [avoidRepeated, setAvoidRepeated] = useState(false);
const [hasBeenUploaded, setHasBeenUploaded] = useState(false); const [hasBeenUploaded, setHasBeenUploaded] = useState(false);
const [showAbandonPopup, setShowAbandonPopup] = useState(false); const [showAbandonPopup, setShowAbandonPopup] = useState(false);
const [isEvaluationLoading, setIsEvaluationLoading] = useState(false); const [isEvaluationLoading, setIsEvaluationLoading] = useState(false);
const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<string[]>([]); const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<
const [timeSpent, setTimeSpent] = useState(0); string[]
>([]);
const [timeSpent, setTimeSpent] = useState(0);
const resetStore = useExamStore((state) => state.reset); const resetStore = useExamStore((state) => state.reset);
const assignment = useExamStore((state) => state.assignment); const assignment = useExamStore((state) => state.assignment);
const initialTimeSpent = useExamStore((state) => state.timeSpent); const initialTimeSpent = useExamStore((state) => state.timeSpent);
const {exam, setExam} = useExamStore((state) => state); const examStore = useExamStore;
const {exams, setExams} = useExamStore((state) => state);
const {sessionId, setSessionId} = useExamStore((state) => state);
const {partIndex, setPartIndex} = useExamStore((state) => state);
const {moduleIndex, setModuleIndex} = useExamStore((state) => state);
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
const {showSolutions, setShowSolutions} = useExamStore((state) => state);
const {selectedModules, setSelectedModules} = useExamStore((state) => state);
const {user} = useUser({redirectTo: "/login"}); const { exam, setExam } = useExamStore((state) => state);
const router = useRouter(); 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 reset = () => { const { user } = useUser({ redirectTo: "/login" });
resetStore(); const router = useRouter();
setVariant("full");
setAvoidRepeated(false);
setHasBeenUploaded(false);
setShowAbandonPopup(false);
setIsEvaluationLoading(false);
setStatsAwaitingEvaluation([]);
setTimeSpent(0);
};
// eslint-disable-next-line react-hooks/exhaustive-deps const reset = () => {
const saveSession = async () => { resetStore();
console.log("Saving your session..."); setVariant("full");
setAvoidRepeated(false);
setHasBeenUploaded(false);
setShowAbandonPopup(false);
setIsEvaluationLoading(false);
setStatsAwaitingEvaluation([]);
setTimeSpent(0);
};
await axios.post("/api/sessions", { // eslint-disable-next-line react-hooks/exhaustive-deps
id: sessionId, const saveSession = async () => {
sessionId, console.log("Saving your session...");
date: new Date().toISOString(),
userSolutions,
moduleIndex,
selectedModules,
assignment,
timeSpent,
exams,
exam,
partIndex,
exerciseIndex,
questionIndex,
user: user?.id,
});
};
useEffect(() => setTimeSpent((prev) => prev + initialTimeSpent), [initialTimeSpent]); 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(() => { useEffect(
if (userSolutions.length === 0 && exams.length > 0) { () => setTimeSpent((prev) => prev + initialTimeSpent),
const defaultSolutions = exams.map(defaultExamUserSolutions).flat(); [initialTimeSpent],
setUserSolutions(defaultSolutions); );
}
}, [exams, setUserSolutions, userSolutions]);
useEffect(() => { useEffect(() => {
if ( if (userSolutions.length === 0 && exams.length > 0) {
sessionId.length > 0 && const defaultSolutions = exams.map(defaultExamUserSolutions).flat();
userSolutions.length > 0 && setUserSolutions(defaultSolutions);
selectedModules.length > 0 && }
exams.length > 0 && }, [exams, setUserSolutions, userSolutions]);
!!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(() => { useEffect(() => {
if (timeSpent % 20 === 0 && timeSpent > 0 && moduleIndex < selectedModules.length && !showSolutions) saveSession(); if (
// eslint-disable-next-line react-hooks/exhaustive-deps sessionId.length > 0 &&
}, [timeSpent]); 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(() => { useEffect(() => {
if (selectedModules.length > 0 && sessionId.length === 0) { if (
const shortUID = new ShortUniqueId(); timeSpent % 20 === 0 &&
setSessionId(shortUID.randomUUID(8)); timeSpent > 0 &&
} moduleIndex < selectedModules.length &&
}, [setSessionId, selectedModules, sessionId]); !showSolutions
)
saveSession();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeSpent]);
useEffect(() => { useEffect(() => {
if (user?.type === "developer") console.log(exam); if (selectedModules.length > 0 && sessionId.length === 0) {
}, [exam, user]); const shortUID = new ShortUniqueId();
setSessionId(shortUID.randomUUID(8));
}
}, [setSessionId, selectedModules, sessionId]);
useEffect(() => { useEffect(() => {
if (selectedModules.length > 0 && timeSpent === 0 && !showSolutions) { if (user?.type === "developer") console.log(exam);
const timerInterval = setInterval(() => { }, [exam, user]);
setTimeSpent((prev) => prev + 1);
}, 1000);
return () => { useEffect(() => {
clearInterval(timerInterval); if (selectedModules.length > 0 && timeSpent === 0 && !showSolutions) {
}; const timerInterval = setInterval(() => {
} setTimeSpent((prev) => prev + 1);
// eslint-disable-next-line react-hooks/exhaustive-deps }, 1000);
}, [selectedModules.length]);
useEffect(() => { return () => {
if (showSolutions) setModuleIndex(-1); clearInterval(timerInterval);
}, [setModuleIndex, showSolutions]); };
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules.length]);
useEffect(() => { useEffect(() => {
(async () => { if (showSolutions) setModuleIndex(-1);
if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) { }, [setModuleIndex, showSolutions]);
const nextExam = exams[moduleIndex];
if (partIndex === -1 && nextExam.module !== "listening") setPartIndex(0); useEffect(() => {
if (exerciseIndex === -1 && !["reading", "listening"].includes(nextExam.module)) setExerciseIndex(0); (async () => {
setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined); if (
} selectedModules.length > 0 &&
})(); exams.length > 0 &&
// eslint-disable-next-line react-hooks/exhaustive-deps moduleIndex < selectedModules.length
}, [selectedModules, moduleIndex, exams]); ) {
const nextExam = exams[moduleIndex];
useEffect(() => { if (partIndex === -1 && nextExam.module !== "listening")
(async () => { setPartIndex(0);
if (selectedModules.length > 0 && exams.length === 0) { if (
const examPromises = selectedModules.map((module) => exerciseIndex === -1 &&
getExam( !["reading", "listening"].includes(nextExam?.module)
module, )
avoidRepeated, setExerciseIndex(0);
variant, setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined);
user?.type === "student" || user?.type === "developer" ? user.preferredGender : undefined, }
), })();
); // eslint-disable-next-line react-hooks/exhaustive-deps
Promise.all(examPromises).then((values) => { }, [selectedModules, moduleIndex, exams]);
if (values.every((x) => !!x)) {
setExams(values.map((x) => x!));
} else {
toast.error("Something went wrong, please try again");
setTimeout(router.reload, 500);
}
});
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules, setExams, exams]);
useEffect(() => { useEffect(() => {
if (selectedModules.length > 0 && exams.length !== 0 && moduleIndex >= selectedModules.length && !hasBeenUploaded && !showSolutions) { (async () => {
const newStats: Stat[] = userSolutions.map((solution) => ({ if (selectedModules.length > 0 && exams.length === 0) {
...solution, const examPromises = selectedModules.map((module) =>
id: solution.id || uuidv4(), getExam(
timeSpent, module,
session: sessionId, avoidRepeated,
exam: exam!.id, variant,
module: exam!.module, user?.type === "student" || user?.type === "developer"
user: user?.id || "", ? user.preferredGender
date: new Date().getTime(), : undefined,
...(assignment ? {assignment: assignment.id} : {}), ),
})); );
Promise.all(examPromises).then((values) => {
if (values.every((x) => !!x)) {
setExams(values.map((x) => x!));
} else {
toast.error("Something went wrong, please try again");
setTimeout(router.reload, 500);
}
});
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules, setExams, exams]);
axios useEffect(() => {
.post<{ok: boolean}>("/api/stats", newStats) if (
.then((response) => setHasBeenUploaded(response.data.ok)) selectedModules.length > 0 &&
.catch(() => setHasBeenUploaded(false)); exams.length !== 0 &&
} moduleIndex >= selectedModules.length &&
// eslint-disable-next-line react-hooks/exhaustive-deps !hasBeenUploaded &&
}, [selectedModules, moduleIndex, hasBeenUploaded]); !showSolutions
) {
const newStats: Stat[] = userSolutions.map((solution) => ({
...solution,
id: solution.id || uuidv4(),
timeSpent,
session: sessionId,
exam: solution.exam!,
module: solution.module!,
user: user?.id || "",
date: new Date().getTime(),
isDisabled: solution.isDisabled,
...(assignment ? { assignment: assignment.id } : {}),
}));
useEffect(() => { axios
setIsEvaluationLoading(statsAwaitingEvaluation.length !== 0); .post<{ ok: boolean }>("/api/stats", newStats)
}, [statsAwaitingEvaluation]); .then((response) => setHasBeenUploaded(response.data.ok))
.catch(() => setHasBeenUploaded(false));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules, moduleIndex, hasBeenUploaded]);
useEffect(() => { useEffect(() => {
if (statsAwaitingEvaluation.length > 0) { setIsEvaluationLoading(statsAwaitingEvaluation.length !== 0);
checkIfStatsHaveBeenEvaluated(statsAwaitingEvaluation); }, [statsAwaitingEvaluation]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [statsAwaitingEvaluation]);
const checkIfStatsHaveBeenEvaluated = (ids: string[]) => { useEffect(() => {
setTimeout(async () => { if (statsAwaitingEvaluation.length > 0) {
const awaitedStats = await Promise.all(ids.map(async (id) => (await axios.get<Stat>(`/api/stats/${id}`)).data)); checkIfStatsHaveBeenEvaluated(statsAwaitingEvaluation);
const solutionsEvaluated = awaitedStats.every((stat) => stat.solutions.every((x) => x.evaluation !== null)); }
if (solutionsEvaluated) { // eslint-disable-next-line react-hooks/exhaustive-deps
const statsUserSolutions: UserSolution[] = awaitedStats.map((stat) => ({ }, [statsAwaitingEvaluation]);
id: stat.id,
exercise: stat.exercise,
score: stat.score,
solutions: stat.solutions,
type: stat.type,
exam: stat.exam,
module: stat.module,
}));
const updatedUserSolutions = userSolutions.map((x) => { const checkIfStatsHaveBeenEvaluated = (ids: string[]) => {
const respectiveSolution = statsUserSolutions.find((y) => y.exercise === x.exercise); setTimeout(async () => {
return respectiveSolution ? respectiveSolution : x; try {
}); const awaitedStats = await Promise.all(
ids.map(
async (id) => (await axios.get<Stat>(`/api/stats/${id}`)).data,
),
);
const solutionsEvaluated = awaitedStats.every((stat) =>
stat.solutions.every((x) => x.evaluation !== null),
);
if (solutionsEvaluated) {
const statsUserSolutions: UserSolution[] = awaitedStats.map(
(stat) => ({
id: stat.id,
exercise: stat.exercise,
score: stat.score,
solutions: stat.solutions,
type: stat.type,
exam: stat.exam,
module: stat.module,
}),
);
setUserSolutions(updatedUserSolutions); const updatedUserSolutions = userSolutions.map((x) => {
return setStatsAwaitingEvaluation((prev) => prev.filter((x) => !ids.includes(x))); const respectiveSolution = statsUserSolutions.find(
} (y) => y.exercise === x.exercise,
);
return respectiveSolution ? respectiveSolution : x;
});
return checkIfStatsHaveBeenEvaluated(ids); setUserSolutions(updatedUserSolutions);
}, 5 * 1000); return setStatsAwaitingEvaluation((prev) =>
}; prev.filter((x) => !ids.includes(x)),
);
}
const updateExamWithUserSolutions = (exam: Exam): Exam => { return checkIfStatsHaveBeenEvaluated(ids);
if (exam.module === "reading" || exam.module === "listening") { } catch {
const parts = exam.parts.map((p) => return checkIfStatsHaveBeenEvaluated(ids);
Object.assign(p, { }
exercises: p.exercises.map((x) => }, 5 * 1000);
Object.assign(x, { };
userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions,
}),
),
}),
);
return Object.assign(exam, {parts});
}
const exercises = exam.exercises.map((x) => const updateExamWithUserSolutions = (exam: Exam): Exam => {
Object.assign(x, { if (exam.module === "reading" || exam.module === "listening") {
userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions, const parts = exam.parts.map((p) =>
}), Object.assign(p, {
); exercises: p.exercises.map((x) =>
return Object.assign(exam, {exercises}); Object.assign(x, {
}; userSolutions: userSolutions.find((y) => x.id === y.exercise)
?.solutions,
}),
),
}),
);
return Object.assign(exam, { parts });
}
const onFinish = (solutions: UserSolution[]) => { const exercises = exam.exercises.map((x) =>
const solutionIds = solutions.map((x) => x.exercise); Object.assign(x, {
const solutionExams = solutions.map((x) => x.exam); userSolutions: userSolutions.find((y) => x.id === y.exercise)
?.solutions,
}),
);
return Object.assign(exam, { exercises });
};
if (exam && !solutionExams.includes(exam.id)) return; const onFinish = async (solutions: UserSolution[]) => {
const solutionIds = solutions.map((x) => x.exercise);
const solutionExams = solutions.map((x) => x.exam);
if (exam && (exam.module === "writing" || exam.module === "speaking") && solutions.length > 0 && !showSolutions) { let newSolutions = [...solutions];
setHasBeenUploaded(true);
setIsEvaluationLoading(true);
Promise.all( if (exam && !solutionExams.includes(exam.id)) return;
exam.exercises.map(async (exercise) => {
const evaluationID = uuidv4();
if (exercise.type === "writing")
return await evaluateWritingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!, evaluationID);
if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking") if (
return await evaluateSpeakingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!, evaluationID); exam &&
}), (exam.module === "writing" || exam.module === "speaking") &&
) solutions.length > 0 &&
.then((responses) => { !showSolutions
setStatsAwaitingEvaluation((prev) => [...prev, ...responses.filter((x) => !!x).map((r) => (r as any).id)]); ) {
setUserSolutions([...userSolutions, ...responses.filter((x) => !!x)] as any); setHasBeenUploaded(true);
}) setIsEvaluationLoading(true);
.finally(() => {
setHasBeenUploaded(false);
});
}
axios.get("/api/stats/update"); const responses: UserSolution[] = (
await Promise.all(
exam.exercises.map(async (exercise, index) => {
const evaluationID = uuidv4();
if (exercise.type === "writing")
return await evaluateWritingAnswer(
exercise,
index + 1,
solutions.find((x) => x.exercise === exercise.id)!,
evaluationID,
);
setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...solutions]); if (
setModuleIndex(moduleIndex + 1); exercise.type === "interactiveSpeaking" ||
exercise.type === "speaking"
)
return await evaluateSpeakingAnswer(
exercise,
solutions.find((x) => x.exercise === exercise.id)!,
evaluationID,
);
}),
)
).filter((x) => !!x) as UserSolution[];
setPartIndex(-1); newSolutions = [
setExerciseIndex(-1); ...newSolutions.filter(
setQuestionIndex(0); (x) => !responses.map((y) => y.exercise).includes(x.exercise),
}; ),
...responses,
];
setStatsAwaitingEvaluation((prev) => [
...prev,
...responses.filter((x) => !!x).map((r) => (r as any).id),
]);
setHasBeenUploaded(false);
}
const aggregateScoresByModule = (answers: UserSolution[]): {module: Module; total: number; missing: number; correct: number}[] => { axios.get("/api/stats/update");
const scores: {
[key in Module]: {total: number; missing: number; correct: number};
} = {
reading: {
total: 0,
correct: 0,
missing: 0,
},
listening: {
total: 0,
correct: 0,
missing: 0,
},
writing: {
total: 0,
correct: 0,
missing: 0,
},
speaking: {
total: 0,
correct: 0,
missing: 0,
},
level: {
total: 0,
correct: 0,
missing: 0,
},
};
answers.forEach((x) => { setUserSolutions([
const examModule = ...userSolutions.filter((x) => !solutionIds.includes(x.exercise)),
x.module || (x.type === "writing" ? "writing" : x.type === "speaking" || x.type === "interactiveSpeaking" ? "speaking" : undefined); ...newSolutions,
]);
setModuleIndex(moduleIndex + 1);
scores[examModule!] = { setPartIndex(-1);
total: scores[examModule!].total + x.score.total, setExerciseIndex(-1);
correct: scores[examModule!].correct + x.score.correct, setQuestionIndex(0);
missing: scores[examModule!].missing + x.score.missing, };
};
});
return Object.keys(scores) const aggregateScoresByModule = (): {
.filter((x) => scores[x as Module].total > 0) module: Module;
.map((x) => ({module: x as Module, ...scores[x as Module]})); total: number;
}; missing: number;
correct: number;
}[] => {
const scores: {
[key in Module]: { total: number; missing: number; correct: number };
} = {
reading: {
total: 0,
correct: 0,
missing: 0,
},
listening: {
total: 0,
correct: 0,
missing: 0,
},
writing: {
total: 0,
correct: 0,
missing: 0,
},
speaking: {
total: 0,
correct: 0,
missing: 0,
},
level: {
total: 0,
correct: 0,
missing: 0,
},
};
const renderScreen = () => { userSolutions.forEach((x) => {
if (selectedModules.length === 0) { const examModule =
return ( x.module ||
<Selection (x.type === "writing"
page={page} ? "writing"
user={user!} : x.type === "speaking" || x.type === "interactiveSpeaking"
disableSelection={page === "exams"} ? "speaking"
onStart={(modules: Module[], avoid: boolean, variant: Variant) => { : undefined);
setModuleIndex(0);
setAvoidRepeated(avoid);
setSelectedModules(modules);
setVariant(variant);
}}
/>
);
}
if (moduleIndex >= selectedModules.length || moduleIndex === -1) { scores[examModule!] = {
return ( total: scores[examModule!].total + x.score.total,
<Finish correct: scores[examModule!].correct + x.score.correct,
isLoading={isEvaluationLoading} missing: scores[examModule!].missing + x.score.missing,
user={user!} };
modules={selectedModules} });
onViewResults={() => {
setShowSolutions(true);
setModuleIndex(0);
setExerciseIndex(["reading", "listening"].includes(exams[0].module) ? -1 : 0);
setPartIndex(exams[0].module === "listening" ? -1 : 0);
setExam(exams[0]);
}}
scores={aggregateScoresByModule(userSolutions)}
/>
);
}
if (exam && exam.module === "reading") { return Object.keys(scores)
return <Reading exam={exam} onFinish={onFinish} showSolutions={showSolutions} />; .filter((x) => scores[x as Module].total > 0)
} .map((x) => ({ module: x as Module, ...scores[x as Module] }));
};
if (exam && exam.module === "listening") { const renderScreen = () => {
return <Listening exam={exam} onFinish={onFinish} showSolutions={showSolutions} />; if (selectedModules.length === 0) {
} return (
<Selection
page={page}
user={user!}
disableSelection={page === "exams"}
onStart={(modules: Module[], avoid: boolean, variant: Variant) => {
setModuleIndex(0);
setAvoidRepeated(avoid);
setSelectedModules(modules);
setVariant(variant);
}}
/>
);
}
if (exam && exam.module === "writing") { if (moduleIndex >= selectedModules.length || moduleIndex === -1) {
return <Writing exam={exam} onFinish={onFinish} showSolutions={showSolutions} />; return (
} <Finish
isLoading={isEvaluationLoading}
user={user!}
modules={selectedModules}
onViewResults={(index?: number) => {
setShowSolutions(true);
setModuleIndex(index || 0);
setExerciseIndex(
["reading", "listening"].includes(exams[0].module) ? -1 : 0,
);
setPartIndex(exams[0].module === "listening" ? -1 : 0);
setExam(exams[0]);
}}
scores={aggregateScoresByModule()}
/>
);
}
if (exam && exam.module === "speaking") { if (exam && exam.module === "reading") {
return <Speaking exam={exam} onFinish={onFinish} showSolutions={showSolutions} />; return (
} <Reading
exam={exam}
onFinish={onFinish}
showSolutions={showSolutions}
/>
);
}
if (exam && exam.module === "level") { if (exam && exam.module === "listening") {
return <Level exam={exam} onFinish={onFinish} showSolutions={showSolutions} />; return (
} <Listening
exam={exam}
onFinish={onFinish}
showSolutions={showSolutions}
/>
);
}
return <>Loading...</>; if (exam && exam.module === "writing") {
}; return (
<Writing
exam={exam}
onFinish={onFinish}
showSolutions={showSolutions}
/>
);
}
return ( if (exam && exam.module === "speaking") {
<> return (
<ToastContainer /> <Speaking
{user && ( exam={exam}
<Layout onFinish={onFinish}
user={user} showSolutions={showSolutions}
className="justify-between" />
focusMode={selectedModules.length !== 0 && !showSolutions && moduleIndex < selectedModules.length} );
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}> }
<>
{renderScreen()} if (exam && exam.module === "level") {
{!showSolutions && moduleIndex < selectedModules.length && ( return (
<AbandonPopup <Level exam={exam} onFinish={onFinish} showSolutions={showSolutions} />
isOpen={showAbandonPopup} );
abandonPopupTitle="Leave Exercise" }
abandonPopupDescription="Are you sure you want to leave the exercise? Your progress will be saved and this exam can be resumed on the Dashboard."
abandonConfirmButtonText="Confirm" return <>Loading...</>;
onAbandon={() => { };
reset();
}} return (
onCancel={() => setShowAbandonPopup(false)} <>
/> <ToastContainer />
)} {user && (
</> <Layout
</Layout> user={user}
)} className="justify-between"
</> focusMode={
); selectedModules.length !== 0 &&
!showSolutions &&
moduleIndex < selectedModules.length
}
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}
>
<>
{renderScreen()}
{!showSolutions && moduleIndex < selectedModules.length && (
<AbandonPopup
isOpen={showAbandonPopup}
abandonPopupTitle="Leave Exercise"
abandonPopupDescription="Are you sure you want to leave the exercise? Your progress will be saved and this exam can be resumed on the Dashboard."
abandonConfirmButtonText="Confirm"
onAbandon={() => {
reset();
}}
onCancel={() => setShowAbandonPopup(false)}
/>
)}
</>
</Layout>
)}
</>
);
} }

View File

@@ -1,5 +1,5 @@
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import {Difficulty, LevelExam, MultipleChoiceExercise} from "@/interfaces/exam"; import {Difficulty, LevelExam, MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams"; import {getExamById} from "@/utils/exams";
import {playSound} from "@/utils/sound"; import {playSound} from "@/utils/sound";
@@ -9,12 +9,69 @@ import clsx from "clsx";
import {capitalize, sample} from "lodash"; import {capitalize, sample} from "lodash";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {useState} from "react"; import {useState} from "react";
import {BsArrowRepeat} from "react-icons/bs"; import {BsArrowRepeat, BsCheck, BsPencilSquare, BsX} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import {v4} from "uuid"; import {v4} from "uuid";
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"]; const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
const QuestionDisplay = ({question, onUpdate}: {question: MultipleChoiceQuestion; onUpdate: (question: MultipleChoiceQuestion) => void}) => {
const [isEditing, setIsEditing] = useState(false);
const [options, setOptions] = useState(question.options);
return (
<div key={question.id} className="flex flex-col gap-1">
<span className="font-semibold">
{question.id}. {question.prompt}{" "}
</span>
<div className="flex flex-col gap-1">
{question.options.map((option, index) => (
<span key={option.id} className={clsx(question.solution === option.id && "font-bold")}>
<span className={clsx("font-semibold", question.solution === option.id ? "text-mti-green-light" : "text-ielts-level")}>
({option.id})
</span>{" "}
{isEditing ? (
<input
defaultValue={option.text}
className="w-60"
onChange={(e) => setOptions((prev) => prev.map((x, idx) => (idx === index ? {...x, text: e.target.value} : x)))}
/>
) : (
<span>{option.text}</span>
)}
</span>
))}
</div>
<div className="flex gap-2 mt-2 w-full">
{!isEditing && (
<button
onClick={() => setIsEditing(true)}
className="p-2 border border-neutral-300 bg-white rounded-xl hover:drop-shadow transition ease-in-out duration-300">
<BsPencilSquare />
</button>
)}
{isEditing && (
<>
<button
onClick={() => {
onUpdate({...question, options});
setIsEditing(false);
}}
className="p-2 border border-neutral-300 bg-white rounded-xl hover:drop-shadow transition ease-in-out duration-300">
<BsCheck />
</button>
<button
onClick={() => setIsEditing(false)}
className="p-2 border border-neutral-300 bg-white rounded-xl hover:drop-shadow transition ease-in-out duration-300">
<BsX />
</button>
</>
)}
</div>
</div>
);
};
const TaskTab = ({exam, difficulty, setExam}: {exam?: LevelExam; difficulty: Difficulty; setExam: (exam: LevelExam) => void}) => { const TaskTab = ({exam, difficulty, setExam}: {exam?: LevelExam; difficulty: Difficulty; setExam: (exam: LevelExam) => void}) => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -37,6 +94,20 @@ const TaskTab = ({exam, difficulty, setExam}: {exam?: LevelExam; difficulty: Dif
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
const onUpdate = (question: MultipleChoiceQuestion) => {
if (!exam) return;
const updatedExam = {
...exam,
exercises: exam.exercises.map((x) => ({
...x,
questions: (x as MultipleChoiceExercise).questions.map((q) => (q.id === question.id ? question : q)),
})),
};
console.log(updatedExam);
setExam(updatedExam as any);
};
return ( return (
<Tab.Panel className="w-full bg-ielts-level/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4"> <Tab.Panel className="w-full bg-ielts-level/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
<div className="flex gap-4 items-end"> <div className="flex gap-4 items-end">
@@ -80,25 +151,7 @@ const TaskTab = ({exam, difficulty, setExam}: {exam?: LevelExam; difficulty: Dif
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{exercise.questions.map((question) => ( {exercise.questions.map((question) => (
<div key={question.id} className="flex flex-col gap-1"> <QuestionDisplay question={question} onUpdate={onUpdate} key={question.id} />
<span className="font-semibold">
{question.id}. {question.prompt}
</span>
<div className="flex flex-col gap-1">
{question.options.map((option) => (
<span key={option.id} className={clsx(question.solution === option.id && "font-bold")}>
<span
className={clsx(
"font-semibold",
question.solution === option.id ? "text-mti-green-light" : "text-ielts-level",
)}>
({option.id})
</span>{" "}
{option.text}
</span>
))}
</div>
</div>
))} ))}
</div> </div>
</div> </div>

View File

@@ -124,9 +124,9 @@ const ReadingGeneration = () => {
const availableTypes = [ const availableTypes = [
{type: "fillBlanks", label: "Fill the Blanks"}, {type: "fillBlanks", label: "Fill the Blanks"},
{type: "multipleChoice", label: "Multiple Choice"},
{type: "trueFalse", label: "True or False"}, {type: "trueFalse", label: "True or False"},
{type: "writeBlanks", label: "Write the Blanks"}, {type: "writeBlanks", label: "Write the Blanks"},
{type: "matchSentences", label: "Match Sentences"},
]; ];
const toggleType = (type: string) => setTypes((prev) => (prev.includes(type) ? [...prev.filter((x) => x !== type)] : [...prev, type])); const toggleType = (type: string) => setTypes((prev) => (prev.includes(type) ? [...prev.filter((x) => x !== type)] : [...prev, type]));

View File

@@ -79,8 +79,6 @@ const PartTab = ({
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
useEffect(() => console.log(part), [part]);
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"> <div className="flex flex-col gap-3 w-full">

View File

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

View File

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

View File

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

View File

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

View File

@@ -163,6 +163,7 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
modules: examModulesLabel, modules: examModulesLabel,
assigner: teacher.name, assigner: teacher.name,
}, },
environment: process.env.ENVIRONMENT,
}, },
[assignee.email], [assignee.email],
"EnCoach - New Assignment!", "EnCoach - New Assignment!",

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,10 @@ import {speakingReverseMarking} from "@/utils/score";
const db = getFirestore(app); const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
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});
@@ -46,17 +50,19 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const backendRequest = await evaluate({answers: uploadingAudios}); const backendRequest = await evaluate({answers: uploadingAudios});
console.log("🌱 - Process complete"); console.log("🌱 - Process complete");
const correspondingStat = (await getDoc(doc(db, "stats", fields.id))).data() as Stat; const correspondingStat = await getCorrespondingStat(fields.id, 1);
const solutions = correspondingStat.solutions.map((x) => ({...x, evaluation: backendRequest.data, solution: uploadingAudios})); const solutions = correspondingStat.solutions.map((x) => ({...x, evaluation: backendRequest.data, solution: uploadingAudios}));
await setDoc( await setDoc(
doc(db, "stats", fields.id), doc(db, "stats", fields.id),
{ {
solutions, solutions,
score: { score: {
correct: speakingReverseMarking[backendRequest.data.overall], correct: speakingReverseMarking[backendRequest.data.overall || 0] || 0,
missing: 0, missing: 0,
total: 100, total: 100,
}, },
isDisabled: false,
}, },
{merge: true}, {merge: true},
); );
@@ -64,6 +70,15 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
}); });
} }
async function getCorrespondingStat(id: string, index: number): Promise<Stat> {
console.log(`🌱 - Try number ${index} - ${id}`);
const correspondingStat = await getDoc(doc(db, "stats", id));
if (correspondingStat.exists()) return {...correspondingStat.data(), id} as Stat;
await delay(3 * 10000);
return getCorrespondingStat(id, index + 1);
}
async function evaluate(body: {answers: object[]}): Promise<AxiosResponse> { async function evaluate(body: {answers: object[]}): Promise<AxiosResponse> {
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/speaking_task_3`, body, { const backendRequest = await axios.post(`${process.env.BACKEND_URL}/speaking_task_3`, body, {
headers: { headers: {

View File

@@ -4,7 +4,7 @@ import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import axios, {AxiosResponse} from "axios"; import axios, {AxiosResponse} from "axios";
import formidable from "formidable-serverless"; import formidable from "formidable-serverless";
import {ref, uploadBytes} from "firebase/storage"; import {getDownloadURL, ref, uploadBytes} from "firebase/storage";
import fs from "fs"; import fs from "fs";
import {app, storage} from "@/firebase"; import {app, storage} from "@/firebase";
import {doc, getDoc, getFirestore, setDoc} from "firebase/firestore"; import {doc, getDoc, getFirestore, setDoc} from "firebase/firestore";
@@ -14,6 +14,10 @@ import {speakingReverseMarking} from "@/utils/score";
const db = getFirestore(app); const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
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});
@@ -25,33 +29,37 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
if (err) console.log(err); if (err) console.log(err);
const audioFile = files.audio; const audioFile = files.audio;
const audioFileRef = ref(storage, `speaking_recordings/${(audioFile as any).path.replace("upload_", "")}`); const audioFileRef = ref(storage, `speaking_recordings/${fields.id}.wav`);
const binary = fs.readFileSync((audioFile as any).path).buffer; const binary = fs.readFileSync((audioFile as any).path).buffer;
const snapshot = await uploadBytes(audioFileRef, binary); const snapshot = await uploadBytes(audioFileRef, binary);
const url = await getDownloadURL(snapshot.ref);
const path = snapshot.metadata.fullPath;
res.status(200).json(null); res.status(200).json(null);
console.log("🌱 - Still processing"); console.log("🌱 - Still processing");
const backendRequest = await evaluate({answers: [{question: fields.question, answer: snapshot.metadata.fullPath}]}); const backendRequest = await evaluate({answers: [{question: fields.question, answer: path}]});
fs.rmSync((audioFile as any).path);
console.log("🌱 - Process complete"); console.log("🌱 - Process complete");
const correspondingStat = (await getDoc(doc(db, "stats", fields.id))).data() as Stat; const correspondingStat = await getCorrespondingStat(fields.id, 1);
const solutions = correspondingStat.solutions.map((x) => ({ const solutions = correspondingStat.solutions.map((x) => ({
...x, ...x,
evaluation: backendRequest.data, evaluation: backendRequest.data,
solution: snapshot.metadata.fullPath, solution: url,
})); }));
await setDoc( await setDoc(
doc(db, "stats", fields.id), doc(db, "stats", fields.id),
{ {
solutions, solutions,
score: { score: {
correct: speakingReverseMarking[backendRequest.data.overall], correct: speakingReverseMarking[backendRequest.data.overall || 0] || 0,
total: 100, total: 100,
missing: 0, missing: 0,
}, },
isDisabled: false,
}, },
{merge: true}, {merge: true},
); );
@@ -59,6 +67,15 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
}); });
} }
async function getCorrespondingStat(id: string, index: number): Promise<Stat> {
console.log(`🌱 - Try number ${index} - ${id}`);
const correspondingStat = await getDoc(doc(db, "stats", id));
if (correspondingStat.exists()) return {...correspondingStat.data(), id} as Stat;
await delay(3 * 10000);
return getCorrespondingStat(id, index + 1);
}
async function evaluate(body: {answers: object[]}): Promise<AxiosResponse> { async function evaluate(body: {answers: object[]}): Promise<AxiosResponse> {
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/speaking_task_3`, body, { const backendRequest = await axios.post(`${process.env.BACKEND_URL}/speaking_task_3`, body, {
headers: { headers: {

View File

@@ -11,9 +11,14 @@ import {writingReverseMarking} from "@/utils/score";
interface Body { interface Body {
question: string; question: string;
answer: string; answer: string;
task: 1 | 2;
id: string; id: string;
} }
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const db = getFirestore(app); const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -29,7 +34,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const backendRequest = await evaluate(req.body as Body); const backendRequest = await evaluate(req.body as Body);
console.log("🌱 - Process complete"); console.log("🌱 - Process complete");
const correspondingStat = (await getDoc(doc(db, "stats", req.body.id))).data() as Stat; const correspondingStat = await getCorrespondingStat(req.body.id, 1);
const solutions = correspondingStat.solutions.map((x) => ({...x, evaluation: backendRequest.data})); const solutions = correspondingStat.solutions.map((x) => ({...x, evaluation: backendRequest.data}));
await setDoc( await setDoc(
doc(db, "stats", (req.body as Body).id), doc(db, "stats", (req.body as Body).id),
@@ -40,14 +46,26 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
total: 100, total: 100,
missing: 0, missing: 0,
}, },
isDisabled: false,
}, },
{merge: true}, {merge: true},
); );
console.log("🌱 - Updated the DB"); console.log("🌱 - Updated the DB");
} }
async function getCorrespondingStat(id: string, index: number): Promise<Stat> {
console.log(`🌱 - Try number ${index} - ${id}`);
const correspondingStat = await getDoc(doc(db, "stats", id));
if (correspondingStat.exists()) return {...correspondingStat.data(), id} as Stat;
await delay(3 * 10000);
return getCorrespondingStat(id, index + 1);
}
async function evaluate(body: Body): Promise<AxiosResponse> { async function evaluate(body: Body): Promise<AxiosResponse> {
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/writing_task2`, body as Body, { const taskNumber = body.task.toString() !== "1" && body.task.toString() !== "2" ? "1" : body.task.toString();
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/writing_task${taskNumber}`, body as Body, {
headers: { headers: {
Authorization: `Bearer ${process.env.BACKEND_JWT}`, Authorization: `Bearer ${process.env.BACKEND_JWT}`,
}, },

View File

@@ -29,14 +29,14 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
module: Module; module: Module;
endpoint: string; endpoint: string;
topic?: string; topic?: string;
exercises?: string[]; exercises?: string[] | string;
difficulty?: Difficulty; difficulty?: Difficulty;
}; };
const url = `${process.env.BACKEND_URL}/${endpoint}`; const url = `${process.env.BACKEND_URL}/${endpoint}`;
const params = new URLSearchParams(); const params = new URLSearchParams();
if (topic) params.append("topic", topic); if (topic) params.append("topic", topic);
if (exercises) exercises.forEach((exercise) => params.append("exercises", exercise)); if (exercises) (typeof exercises === "string" ? [exercises] : exercises).forEach((exercise) => params.append("exercises", exercise));
if (difficulty) params.append("difficulty", difficulty); if (difficulty) params.append("difficulty", difficulty);
const result = await axios.get(`${url}${params.toString().length > 0 ? `?${params.toString()}` : ""}`, { const result = await axios.get(`${url}${params.toString().length > 0 ? `?${params.toString()}` : ""}`, {

View File

@@ -113,6 +113,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
corporateName: invitedBy.name, corporateName: invitedBy.name,
name: req.session.user.name, name: req.session.user.name,
decision: "accept", decision: "accept",
environment: process.env.ENVIRONMENT,
}, },
[invitedBy.email], [invitedBy.email],
`${req.session.user.name} has accepted your invite!`, `${req.session.user.name} has accepted your invite!`,

View File

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

View File

@@ -1,20 +1,13 @@
// 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 { sendEmail } from "@/email"; import {sendEmail} from "@/email";
import { app } from "@/firebase"; import {app} from "@/firebase";
import { Invite } from "@/interfaces/invite"; import {Invite} from "@/interfaces/invite";
import { Ticket } from "@/interfaces/ticket"; import {Ticket} from "@/interfaces/ticket";
import { User } from "@/interfaces/user"; import {User} from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import { import {collection, doc, getDoc, getDocs, getFirestore, setDoc} from "firebase/firestore";
collection, import {withIronSessionApiRoute} from "iron-session/next";
doc, import type {NextApiRequest, NextApiResponse} from "next";
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"; import ShortUniqueId from "short-unique-id";
const db = getFirestore(app); const db = getFirestore(app);
@@ -22,67 +15,60 @@ 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.session.user) { if (!req.session.user) {
res.status(401).json({ ok: false }); res.status(401).json({ok: false});
return; return;
} }
if (req.method === "GET") await get(req, res); if (req.method === "GET") await get(req, res);
if (req.method === "POST") await post(req, res); if (req.method === "POST") await post(req, res);
} }
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
const snapshot = await getDocs(collection(db, "invites")); const snapshot = await getDocs(collection(db, "invites"));
res.status(200).json( res.status(200).json(
snapshot.docs.map((doc) => ({ snapshot.docs.map((doc) => ({
id: doc.id, id: doc.id,
...doc.data(), ...doc.data(),
})), })),
); );
} }
async function post(req: NextApiRequest, res: NextApiResponse) { async function post(req: NextApiRequest, res: NextApiResponse) {
const body = req.body as Invite; const body = req.body as Invite;
const existingInvites = (await getDocs(collection(db, "invites"))).docs.map( const existingInvites = (await getDocs(collection(db, "invites"))).docs.map((x) => ({...x.data(), id: x.id})) as Invite[];
(x) => ({ ...x.data(), id: x.id }),
) as Invite[];
const invitedRef = await getDoc(doc(db, "users", body.to)); const invitedRef = await getDoc(doc(db, "users", body.to));
if (!invitedRef.exists()) return res.status(404).json({ ok: false }); if (!invitedRef.exists()) return res.status(404).json({ok: false});
const invitedByRef = await getDoc(doc(db, "users", body.from)); const invitedByRef = await getDoc(doc(db, "users", body.from));
if (!invitedByRef.exists()) return res.status(404).json({ ok: false }); if (!invitedByRef.exists()) return res.status(404).json({ok: false});
const invited = { ...invitedRef.data(), id: invitedRef.id } as User; const invited = {...invitedRef.data(), id: invitedRef.id} as User;
const invitedBy = { ...invitedByRef.data(), id: invitedByRef.id } as User; const invitedBy = {...invitedByRef.data(), id: invitedByRef.id} as User;
try { try {
await sendEmail( await sendEmail(
"receivedInvite", "receivedInvite",
{ {
name: invited.name, name: invited.name,
corporateName: corporateName:
invitedBy.type === "corporate" invitedBy.type === "corporate" ? invitedBy.corporateInformation?.companyInformation?.name || invitedBy.name : invitedBy.name,
? invitedBy.corporateInformation?.companyInformation?.name || environment: process.env.ENVIRONMENT,
invitedBy.name },
: invitedBy.name, [invited.email],
}, "You have been invited to a group!",
[invited.email], );
"You have been invited to a group!", } catch (e) {
); console.log(e);
} catch (e) { }
console.log(e);
}
if ( if (existingInvites.filter((i) => i.to === body.to && i.from === body.from).length == 0) {
existingInvites.filter((i) => i.to === body.to && i.from === body.from) const shortUID = new ShortUniqueId();
.length == 0 await setDoc(doc(db, "invites", body.id || shortUID.randomUUID(8)), body);
) { }
const shortUID = new ShortUniqueId();
await setDoc(doc(db, "invites", body.id || shortUID.randomUUID(8)), body);
}
res.status(200).json({ ok: true }); res.status(200).json({ok: true});
} }

View File

@@ -0,0 +1,52 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, collection, getDocs, setDoc, doc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {Group} from "@/interfaces/user";
import {Payment} from "@/interfaces/paypal";
import {v4} from "uuid";
import ShortUniqueId from "short-unique-id";
import axios from "axios";
import {IntentionResult, PaymentIntention} from "@/interfaces/paymob";
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, "payments"));
res.status(200).json(
snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})),
);
}
async function post(req: NextApiRequest, res: NextApiResponse) {
const intention = req.body as PaymentIntention;
const response = await axios.post<IntentionResult>(
"https://oman.paymob.com/v1/intention/",
{...intention, payment_methods: [parseInt(process.env.PAYMOB_INTEGRATION_ID || "0")], items: []},
{headers: {Authorization: `Token ${process.env.PAYMOB_SECRET_KEY}`}},
);
const intentionResult = response.data;
res.status(200).json({
iframeURL: `https://oman.paymob.com/unifiedcheckout/?publicKey=${process.env.PAYMOB_PUBLIC_KEY}&clientSecret=${intentionResult.client_secret}`,
});
}

View File

@@ -0,0 +1,95 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, collection, getDocs, setDoc, doc, getDoc, query, where} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {Group, User} from "@/interfaces/user";
import {DurationUnit, Package, Payment} from "@/interfaces/paypal";
import {v4} from "uuid";
import ShortUniqueId from "short-unique-id";
import axios from "axios";
import {IntentionResult, PaymentIntention, TransactionResult} from "@/interfaces/paymob";
import moment from "moment";
const db = getFirestore(app);
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") await post(req, res);
}
async function post(req: NextApiRequest, res: NextApiResponse) {
const transactionResult = req.body as TransactionResult;
const authToken = await authenticatePaymob();
if (!checkTransaction(authToken, transactionResult.transaction.order.id)) return res.status(404).json({ok: false});
if (!transactionResult.transaction.success) return res.status(200).json({ok: false});
const {userID, duration, duration_unit} = transactionResult.intention.extras.creation_extras as {
userID: string;
duration: number;
duration_unit: DurationUnit;
};
const userSnapshot = await getDoc(doc(db, "users", userID as string));
if (!userSnapshot.exists() || !duration || !duration_unit) return res.status(404).json({ok: false});
const user = {...userSnapshot.data(), id: userSnapshot.id} as User;
const subscriptionExpirationDate = user.subscriptionExpirationDate;
if (!subscriptionExpirationDate) return res.status(200).json({ok: false});
const initialDate = moment(subscriptionExpirationDate).isAfter(moment()) ? moment(subscriptionExpirationDate) : moment();
const updatedSubscriptionExpirationDate = moment(initialDate).add(duration, duration_unit).toISOString();
await setDoc(userSnapshot.ref, {subscriptionExpirationDate: updatedSubscriptionExpirationDate}, {merge: true});
await setDoc(doc(db, "paypalpayments", v4()), {
createdAt: new Date().toISOString(),
currency: transactionResult.transaction.currency,
orderId: transactionResult.transaction.id,
status: "COMPLETED",
subscriptionDuration: duration,
subscriptionDurationUnit: duration_unit,
subscriptionExpirationDate: updatedSubscriptionExpirationDate,
userId: userID,
value: transactionResult.transaction.amount_cents / 1000,
});
if (user.type === "corporate") {
const groupsSnapshot = await getDocs(query(collection(db, "groups"), where("admin", "==", user.id)));
const groups = groupsSnapshot.docs.map((g) => ({...g.data(), id: g.id})) as Group[];
const participants = (await Promise.all(
groups.flatMap((x) => x.participants).map(async (x) => ({...(await getDoc(doc(db, "users", x))).data(), id: x})),
)) as User[];
const sameExpiryDateParticipants = participants.filter((x) => x.subscriptionExpirationDate === subscriptionExpirationDate);
for (const participant of sameExpiryDateParticipants) {
await setDoc(doc(db, "users", participant.id), {subscriptionExpirationDate: updatedSubscriptionExpirationDate}, {merge: true});
}
}
res.status(200).json({
ok: true,
});
}
const authenticatePaymob = async () => {
const response = await axios.post<{token: string}>(
"https://oman.paymob.com/api/auth/tokens",
{
api_key: process.env.PAYMOB_API_KEY,
},
{headers: {Authorization: `Bearer ${process.env.PAYMOB_SECRET_KEY}`}},
);
return response.data.token;
};
const checkTransaction = async (token: string, orderID: number) => {
const response = await axios.post("https://oman.paymob.com/api/ecommerce/orders/transaction_inquiry", {auth_token: token, order_id: orderID});
return response.status === 200;
};

View File

@@ -42,91 +42,96 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!trackingId) if (!trackingId)
return res.status(401).json({ ok: false, reason: "Missing tracking id!" }); return res.status(401).json({ ok: false, reason: "Missing tracking id!" });
const request = await axios.post( const url = `${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders/${id}/capture`;
`${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders/${id}/capture`, const headers = {
{}, headers: {
{ Authorization: `Bearer ${accessToken}`,
headers: { "PayPal-Client-Metadata-Id": trackingId,
Authorization: `Bearer ${accessToken}`, },
"PayPal-Client-Metadata-Id": trackingId, };
}, axios
} .post(url, {}, headers)
); .then(async (request) => {
if (request.data.status === "COMPLETED") {
const user = req.session.user;
const subscriptionExpirationDate =
user!.subscriptionExpirationDate;
const today = moment(new Date());
const dateToBeAddedTo = !subscriptionExpirationDate
? today
: moment(subscriptionExpirationDate).isAfter(today)
? moment(subscriptionExpirationDate)
: today;
if (request.data.status === "COMPLETED") { const updatedExpirationDate = dateToBeAddedTo.add(
const user = req.session.user; duration,
const subscriptionExpirationDate = duration_unit
req.session.user.subscriptionExpirationDate; );
const today = moment(new Date()); await setDoc(
const dateToBeAddedTo = !subscriptionExpirationDate doc(db, "users", req.session.user!.id),
? today {
: moment(subscriptionExpirationDate).isAfter(today) subscriptionExpirationDate: updatedExpirationDate.toISOString(),
? moment(subscriptionExpirationDate) status: "active",
: today; },
{ merge: true }
);
const updatedExpirationDate = dateToBeAddedTo.add(duration, duration_unit); try {
await setDoc( await setDoc(doc(db, "paypalpayments", v4()), {
doc(db, "users", req.session.user.id), orderId: id,
{ userId: req.session.user!.id,
subscriptionExpirationDate: updatedExpirationDate.toISOString(), status: request.data.status,
status: "active", createdAt: new Date().toISOString(),
}, value:
{ merge: true } request.data.purchase_units[0].payments.captures[0].amount.value,
); currency:
request.data.purchase_units[0].payments.captures[0].amount
.currency_code,
subscriptionDuration: duration,
subscriptionDurationUnit: duration_unit,
subscriptionExpirationDate: updatedExpirationDate.toISOString(),
});
} catch (err) {
console.error("Failed to insert paypal payment!", err);
}
try { if (user!.type === "corporate") {
await setDoc( const snapshot = await getDocs(collection(db, "groups"));
doc(db, 'paypalpayments', v4()), const groups: Group[] = (
{ snapshot.docs.map((doc) => ({
orderId: id, id: doc.id,
userId: req.session.user.id, ...doc.data(),
status: request.data.status, })) as Group[]
createdAt: new Date().toISOString(), ).filter((x) => x.admin === user!.id);
value: request.data.purchase_units[0].payments.captures[0].amount.value,
currency: request.data.purchase_units[0].payments.captures[0].amount.currency_code,
subscriptionDuration: duration,
subscriptionDurationUnit: duration_unit,
subscriptionExpirationDate: updatedExpirationDate.toISOString(),
}
);
} catch(err) {
console.error('Failed to insert paypal payment!', err);
}
if (user.type === "corporate") { await Promise.all(
const snapshot = await getDocs(collection(db, "groups")); groups
const groups: Group[] = ( .flatMap((x) => x.participants)
snapshot.docs.map((doc) => ({ .map(
id: doc.id, async (x) =>
...doc.data(), await setDoc(
})) as Group[] doc(db, "users", x),
).filter((x) => x.admin === user.id); {
subscriptionExpirationDate:
await Promise.all( updatedExpirationDate.toISOString(),
groups status: "active",
.flatMap((x) => x.participants) },
.map( { merge: true }
async (x) => )
await setDoc(
doc(db, "users", x),
{
subscriptionExpirationDate:
updatedExpirationDate.toISOString(),
status: "active",
},
{ merge: true }
) )
) );
); }
}
return res.status(200).json({ ok: true }); return res.status(200).json({ ok: true });
} }
res res.status(404).json({
.status(404) ok: false,
.json({ reason: "Order ID not found or purchase was not approved!",
ok: false, });
reason: "Order ID not found or purchase was not approved!", })
.catch((err) => {
console.error(err.response.status, err.response.data);
res.status(err.response.status).json(err.response.data);
}); });
} }

View File

@@ -1,62 +1,110 @@
// 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} from "firebase/firestore"; import { getFirestore, collection, getDocs } 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 axios from "axios"; import axios from "axios";
import {v4} from "uuid"; import { v4 } from "uuid";
import {OrderResponseBody} from "@paypal/paypal-js"; import { OrderResponseBody } from "@paypal/paypal-js";
import {getAccessToken} from "@/utils/paypal"; import { getAccessToken } from "@/utils/paypal";
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 !== "POST") return res.status(404).json({ok: false, reason: "Method not supported!"}); if (req.method !== "POST")
if (!req.session.user) return res.status(401).json({ok: false}); return res.status(404).json({ ok: false, reason: "Method not supported!" });
if (!req.session.user) return res.status(401).json({ ok: false });
const accessToken = await getAccessToken(); const accessToken = await getAccessToken();
if (!accessToken) return res.status(401).json({ok: false, reason: "Authorization failed!"}); if (!accessToken)
return res.status(401).json({ ok: false, reason: "Authorization failed!" });
const {currencyCode, price, trackingId} = req.body as {currencyCode: string; price: number, trackingId: string}; const { currencyCode, price, trackingId } = req.body as {
currencyCode: string;
price: number;
trackingId: string;
};
if(!trackingId) return res.status(401).json({ok: false, reason: "Missing tracking id!"}); if (!trackingId)
return res.status(401).json({ ok: false, reason: "Missing tracking id!" });
const request = await axios.post<OrderResponseBody>( const url = `${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders`;
`${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders`, const amount = {
{ currency_code: currencyCode,
purchase_units: [ value: price.toString(),
{ };
amount: {
currency_code: currencyCode, const data = {
value: price.toString(), purchase_units: [
}, {
reference_id: v4(), invoice_id: `INV-${v4()}`,
}, amount: {
], ...amount,
payment_source: { breakdown: {
paypal: { item_total: amount,
email_address: req.session.user.email || "",
experience_context: {
payment_method_preference: "IMMEDIATE_PAYMENT_REQUIRED",
locale: "en-US",
landing_page: "LOGIN",
shipping_preference: "NO_SHIPPING",
user_action: "PAY_NOW",
}, },
}, },
items: [
{
name: "Encoach Subscription",
quantity: "1",
category: "DIGITAL_GOODS",
unit_amount: amount,
},
],
}, },
intent: "CAPTURE", ],
}, payment_source: {
{ paypal: {
headers: { email_address: req.session.user.email || "",
Authorization: `Bearer ${accessToken}`, address: {
'PayPal-Client-Metadata-Id': trackingId, address_line_1: "",
}, address_line_2: "",
}, admin_area_1: "",
); admin_area_2: "",
// added default values as requsted by the client, using the default values recommended
// the paypal engineer, otherwise we would have to create something that would detect the location
// of the user and generate a valid postal code for that location...
country_code: "US",
postal_code: "94107",
},
experience_context: {
payment_method_preference: "IMMEDIATE_PAYMENT_REQUIRED",
locale: "en-US",
landing_page: "LOGIN",
shipping_preference: "NO_SHIPPING",
user_action: "PAY_NOW",
brand_name: "Encoach",
},
},
},
intent: "CAPTURE",
};
res.status(request.status).json(request.data); const headers = {
headers: {
Authorization: `Bearer ${accessToken}`,
"PayPal-Client-Metadata-Id": trackingId,
},
};
console.log(
JSON.stringify({
url,
data,
headers,
})
);
axios
.post<OrderResponseBody>(url, data, headers)
.then((request) => {
res.status(request.status).json(request.data);
})
.catch((err) => {
console.error(err.response.status, err.response.data);
res.status(err.response.status).json(err.response.data);
});
} }

View File

@@ -25,29 +25,35 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const trackingId = `${req.session.user.id}-${Date.now()}`; const trackingId = `${req.session.user.id}-${Date.now()}`;
try { const url = `${process.env.PAYPAL_ACCESS_TOKEN_URL}/v1/risk/transaction-contexts/${process.env.PAYPAL_MERCHANT_ID}/${trackingId}`;
const request = await axios.put( const data = {
`${process.env.PAYPAL_ACCESS_TOKEN_URL}/v1/risk/transaction-contexts/${process.env.PAYPAL_MERCHANT_ID}/${trackingId}`, additional_data: [
{ {
additional_data: [ key: "user_id",
{ value: req.session.user.id,
key: "user_id",
value: req.session.user.id,
},
],
}, },
{ ],
headers: { };
Authorization: `Bearer ${accessToken}`,
}, const headers = {
} headers: {
); Authorization: `Bearer ${accessToken}`,
},
};
console.log(JSON.stringify({
url,
data,
headers,
}));
try {
const request = await axios.put(url, data, headers);
return res.status(request.status).json({ return res.status(request.status).json({
ok: true, ok: true,
trackingId, trackingId,
}); });
} catch (err) { } catch (err) {
console.error(url, err);
return res return res
.status(500) .status(500)
.json({ ok: false, reason: "Failed to create tracking ID" }); .json({ ok: false, reason: "Failed to create tracking ID" });

View File

@@ -1,24 +1,13 @@
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 { import {getFirestore, doc, setDoc, query, collection, where, getDocs} from "firebase/firestore";
getFirestore, import {CorporateInformation, DemographicInformation, Group, Type} from "@/interfaces/user";
doc, import {addUserToGroupOnCreation} from "@/utils/registration";
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";
import {v4} from "uuid";
const auth = getAuth(app); const auth = getAuth(app);
const db = getFirestore(app); const db = getFirestore(app);
@@ -26,140 +15,145 @@ const db = getFirestore(app);
export default withIronSessionApiRoute(register, sessionOptions); export default withIronSessionApiRoute(register, sessionOptions);
const DEFAULT_DESIRED_LEVELS = { const DEFAULT_DESIRED_LEVELS = {
reading: 9, reading: 9,
listening: 9, listening: 9,
writing: 9, writing: 9,
speaking: 9, speaking: 9,
}; };
const DEFAULT_LEVELS = { const DEFAULT_LEVELS = {
reading: 0, reading: 0,
listening: 0, listening: 0,
writing: 0, writing: 0,
speaking: 0, speaking: 0,
}; };
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";
}; };
if (type === "individual") return registerIndividual(req, res); if (type === "individual") return registerIndividual(req, res);
if (type === "corporate") return registerCorporate(req, res); if (type === "corporate") return registerCorporate(req, res);
} }
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;
code?: string; code?: string;
}; };
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( const codeDocs = (await getDocs(codeQuery)).docs.filter((x) => !Object.keys(x.data()).includes("userId"));
(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 = const codeData =
codeDocs.length > 0 codeDocs.length > 0
? (codeDocs[0].data() as { ? (codeDocs[0].data() as {
code: string; code: string;
type: Type; type: Type;
creator?: string; creator?: string;
expiryDate: Date | null; expiryDate: Date | null;
}) })
: undefined; : undefined;
createUserWithEmailAndPassword(auth, email.toLowerCase(), 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(), 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") type: email.endsWith("@ecrop.dev") ? "developer" : codeData ? codeData.type : "student",
? "developer" subscriptionExpirationDate: codeData ? codeData.expiryDate : moment().subtract(1, "days").toISOString(),
: codeData ...(passport_id ? {demographicInformation: {passport_id}} : {}),
? codeData.type registrationDate: new Date().toISOString(),
: "student", status: code ? "active" : "paymentDue",
subscriptionExpirationDate: codeData };
? codeData.expiryDate
: moment().subtract(1, "days").toISOString(),
...(passport_id ? { demographicInformation: { passport_id } } : {}),
registrationDate: new Date().toISOString(),
status: code ? "active" : "paymentDue",
};
await setDoc(doc(db, "users", userId), user); await setDoc(doc(db, "users", userId), user);
if (codeDocs.length > 0 && codeData) { 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) if (codeData.creator) await addUserToGroupOnCreation(userId, codeData.type, 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.toLowerCase(), 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(), email: email.toLowerCase(),
desiredLevels: DEFAULT_DESIRED_LEVELS, desiredLevels: DEFAULT_DESIRED_LEVELS,
levels: DEFAULT_LEVELS, levels: DEFAULT_LEVELS,
bio: "", bio: "",
isFirstLogin: false, isFirstLogin: false,
focus: "academic", focus: "academic",
type: "corporate", type: "corporate",
subscriptionExpirationDate: req.body.subscriptionExpirationDate || null, subscriptionExpirationDate: req.body.subscriptionExpirationDate || null,
status: "paymentDue", status: "paymentDue",
registrationDate: new Date().toISOString(), registrationDate: new Date().toISOString(),
}; };
await setDoc(doc(db, "users", userId), user); const defaultTeachersGroup: Group = {
admin: userId,
id: v4(),
name: "Teachers",
participants: [],
disableEditing: true,
};
req.session.user = { ...user, id: userId }; const defaultStudentsGroup: Group = {
await req.session.save(); admin: userId,
id: v4(),
name: "Students",
participants: [],
disableEditing: true,
};
res.status(200).json({ user: { ...user, id: userId } }); await setDoc(doc(db, "users", userId), user);
}) await setDoc(doc(db, "groups", defaultTeachersGroup.id), defaultTeachersGroup);
.catch((error) => { await setDoc(doc(db, "groups", defaultStudentsGroup.id), defaultStudentsGroup);
console.log(error);
res.status(401).json({ error }); req.session.user = {...user, id: userId};
}); await req.session.save();
res.status(200).json({user: {...user, id: userId}});
})
.catch((error) => {
console.log(error);
res.status(401).json({error});
});
} }

View File

@@ -19,6 +19,7 @@ async function sendVerification(req: NextApiRequest, res: NextApiResponse) {
name: req.session.user.name, name: req.session.user.name,
code: short.randomUUID(6), code: short.randomUUID(6),
email: req.session.user.email, email: req.session.user.email,
environment: process.env.ENVIRONMENT,
}, },
[req.session.user.email], [req.session.user.email],
"EnCoach Verification", "EnCoach Verification",

View File

@@ -79,6 +79,7 @@ interface SkillsFeedbackRequest {
interface SkillsFeedbackResponse extends SkillsFeedbackRequest { interface SkillsFeedbackResponse extends SkillsFeedbackRequest {
evaluation: string; evaluation: string;
suggestions: string; suggestions: string;
bullet_points?: string[];
} }
const getSkillsFeedback = async (sections: SkillsFeedbackRequest[]) => { const getSkillsFeedback = async (sections: SkillsFeedbackRequest[]) => {
@@ -225,6 +226,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
...result, ...result,
evaluation: feedback?.evaluation, evaluation: feedback?.evaluation,
suggestions: feedback?.suggestions, suggestions: feedback?.suggestions,
bullet_points: feedback?.bullet_points,
}; };
} }
@@ -286,7 +288,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
logo={"public/logo_title.png"} logo={"public/logo_title.png"}
qrcode={qrcode} qrcode={qrcode}
summaryPNG={overallPNG} summaryPNG={overallPNG}
summaryScore={`${(overallResult * 100).toFixed(0)}%`} summaryScore={`${Math.floor(overallResult * 100)}%`}
passportId={demographicInformation?.passport_id || ""} passportId={demographicInformation?.passport_id || ""}
/>, />,
); );

View File

@@ -18,6 +18,7 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query; const {id} = req.query;
const snapshot = await getDoc(doc(db, "stats", id as string)); const snapshot = await getDoc(doc(db, "stats", id as string));
if (!snapshot.exists()) return res.status(404).json({id: snapshot.id});
res.status(200).json({...snapshot.data(), id: snapshot.id}); res.status(200).json({...snapshot.data(), id: snapshot.id});
} }

View File

@@ -65,6 +65,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
{ {
type: "student", type: "student",
code, code,
environment: process.env.ENVIRONMENT,
}, },
[email], [email],
"EnCoach Registration", "EnCoach Registration",

View File

@@ -1,109 +1,104 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import { app } from "@/firebase"; import {app} from "@/firebase";
import { import {getFirestore, getDoc, doc, deleteDoc, setDoc} from "firebase/firestore";
getFirestore, import {withIronSessionApiRoute} from "iron-session/next";
getDoc, import {sessionOptions} from "@/lib/session";
doc, import {Ticket, TicketTypeLabel, TicketStatusLabel} from "@/interfaces/ticket";
deleteDoc,
setDoc,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Ticket, TicketTypeLabel, TicketStatusLabel } from "@/interfaces/ticket";
import moment from "moment"; import moment from "moment";
import { sendEmail } from "@/email"; import {sendEmail} 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 await get(req, res); if (req.method === "GET") return await get(req, res);
if (req.method === "DELETE") return await del(req, res); if (req.method === "DELETE") return await del(req, res);
if (req.method === "PATCH") return await patch(req, res); if (req.method === "PATCH") return await patch(req, res);
res.status(404).json(undefined); res.status(404).json(undefined);
} }
async function get(req: NextApiRequest, res: NextApiResponse) { 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, "tickets", id)); const snapshot = await getDoc(doc(db, "tickets", 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);
} }
} }
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, "tickets", id)); const snapshot = await getDoc(doc(db, "tickets", id));
const data = snapshot.data() as Ticket; const data = snapshot.data() as Ticket;
const user = req.session.user; const user = req.session.user;
if (user.type === "admin" || user.type === "developer") { if (user.type === "admin" || user.type === "developer") {
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 body = req.body as Ticket; const body = req.body as Ticket;
const snapshot = await getDoc(doc(db, "tickets", id)); const snapshot = await getDoc(doc(db, "tickets", id));
const user = req.session.user; const user = req.session.user;
if (user.type === "admin" || user.type === "developer") { if (user.type === "admin" || user.type === "developer") {
const data = snapshot.data() as Ticket; const data = snapshot.data() as Ticket;
await setDoc(snapshot.ref, body, { merge: true }); await setDoc(snapshot.ref, body, {merge: true});
try { try {
// send email if the status actually changed to completed // send email if the status actually changed to completed
if(data.status !== req.body.status && req.body.status === 'completed') { if (data.status !== req.body.status && req.body.status === "completed") {
await sendEmail( await sendEmail(
"ticketStatusCompleted", "ticketStatusCompleted",
{ {
id, id,
subject: body.subject, subject: body.subject,
reporter: body.reporter, reporter: body.reporter,
date: moment(body.date).format("DD/MM/YYYY - HH:mm"), date: moment(body.date).format("DD/MM/YYYY - HH:mm"),
type: TicketTypeLabel[body.type], type: TicketTypeLabel[body.type],
reportedFrom: body.reportedFrom, reportedFrom: body.reportedFrom,
description: body.description, description: body.description,
}, environment: process.env.ENVIRONMENT,
[data.reporter.email], },
`Ticket ${id}: ${data.subject}`, [data.reporter.email],
); `Ticket ${id}: ${data.subject}`,
} );
} catch(err) { }
console.error(err); } catch (err) {
// doesnt matter if the email fails console.error(err);
} // doesnt matter if the email fails
res.status(200).json({ ok: true }); }
return; res.status(200).json({ok: true});
} return;
}
res.status(403).json({ ok: false }); res.status(403).json({ok: false});
} }

View File

@@ -1,110 +1,103 @@
// 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 { sendEmail } from "@/email"; import {sendEmail} from "@/email";
import { app } from "@/firebase"; import {app} from "@/firebase";
import { Ticket, TicketTypeLabel, TicketWithCorporate } from "@/interfaces/ticket"; import {Ticket, TicketTypeLabel, TicketWithCorporate} from "@/interfaces/ticket";
import { sessionOptions } from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import { import {collection, doc, getDocs, getFirestore, setDoc, where, query} from "firebase/firestore";
collection, import {withIronSessionApiRoute} from "iron-session/next";
doc,
getDocs,
getFirestore,
setDoc,
where,
query,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import moment from "moment"; import moment from "moment";
import type { NextApiRequest, NextApiResponse } from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import ShortUniqueId from "short-unique-id"; import ShortUniqueId from "short-unique-id";
import { Group, CorporateUser } from "@/interfaces/user"; import {Group, CorporateUser} from "@/interfaces/user";
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) {
// due to integration with the homepage the POST request should be public // due to integration with the homepage the POST request should be public
if (req.method === "POST") { if (req.method === "POST") {
await post(req, res); await post(req, res);
return; return;
} }
// specific logic for the preflight request // specific logic for the preflight request
if (req.method === "OPTIONS") { if (req.method === "OPTIONS") {
res.status(200).end(); res.status(200).end();
return; return;
} }
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ ok: false }); res.status(401).json({ok: false});
return; return;
} }
if (req.method === "GET") { if (req.method === "GET") {
await get(req, res); await get(req, res);
} }
} }
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
const snapshot = await getDocs(collection(db, "tickets")); const snapshot = await getDocs(collection(db, "tickets"));
const docs = snapshot.docs.map((doc) => ({ const docs = snapshot.docs.map((doc) => ({
id: doc.id, id: doc.id,
...doc.data(), ...doc.data(),
})) as Ticket[]; })) as Ticket[];
// fetch all groups for these users // fetch all groups for these users
const reporters = [...new Set(docs.map((d) => d.reporter.id).filter((id) => id))]; const reporters = [...new Set(docs.map((d) => d.reporter.id).filter((id) => id))];
const groupsSnapshot = await getDocs(query(collection(db, "groups"), where("participants", "array-contains-any", reporters))); const groupsSnapshot = await getDocs(query(collection(db, "groups"), where("participants", "array-contains-any", reporters)));
const groups = groupsSnapshot.docs.map((doc) => doc.data()) as Group[]; const groups = groupsSnapshot.docs.map((doc) => doc.data()) as Group[];
// based on the admin of each group, verify if it exists and it's of type corporate // based on the admin of each group, verify if it exists and it's of type corporate
const groupsAdmins = [...new Set(groups.map((g) => g.admin).filter((id) => id))]; const groupsAdmins = [...new Set(groups.map((g) => g.admin).filter((id) => id))];
const adminsSnapshot = await getDocs(query(collection(db, "users"), where("id", "in", groupsAdmins), where("type", "==", "corporate"))); const adminsSnapshot = await getDocs(query(collection(db, "users"), where("id", "in", groupsAdmins), where("type", "==", "corporate")));
const admins = adminsSnapshot.docs.map((doc) => doc.data()); const admins = adminsSnapshot.docs.map((doc) => doc.data());
const docsWithAdmins = docs.map((d) => { const docsWithAdmins = docs.map((d) => {
const group = groups.find((g) => g.participants.includes(d.reporter.id)); const group = groups.find((g) => g.participants.includes(d.reporter.id));
const admin = admins.find((a) => a.id === group?.admin) as CorporateUser; const admin = admins.find((a) => a.id === group?.admin) as CorporateUser;
if(admin) { if (admin) {
return { return {
...d, ...d,
corporate: admin.corporateInformation?.companyInformation?.name, corporate: admin.corporateInformation?.companyInformation?.name,
}; };
} }
return d; return d;
}) as TicketWithCorporate[]; }) as TicketWithCorporate[];
res.status(200).json(docsWithAdmins); res.status(200).json(docsWithAdmins);
} }
async function post(req: NextApiRequest, res: NextApiResponse) { async function post(req: NextApiRequest, res: NextApiResponse) {
const body = req.body as Ticket; const body = req.body as Ticket;
const shortUID = new ShortUniqueId(); const shortUID = new ShortUniqueId();
const id = body.id || shortUID.randomUUID(8); const id = body.id || shortUID.randomUUID(8);
await setDoc(doc(db, "tickets", id), body); await setDoc(doc(db, "tickets", id), body);
res.status(200).json({ ok: true }); res.status(200).json({ok: true});
try { try {
await sendEmail( await sendEmail(
"submittedFeedback", "submittedFeedback",
{ {
id, id,
subject: body.subject, subject: body.subject,
reporter: body.reporter, reporter: body.reporter,
date: moment(body.date).format("DD/MM/YYYY - HH:mm"), date: moment(body.date).format("DD/MM/YYYY - HH:mm"),
type: TicketTypeLabel[body.type], type: TicketTypeLabel[body.type],
reportedFrom: body.reportedFrom, reportedFrom: body.reportedFrom,
description: body.description, description: body.description,
}, environment: process.env.ENVIRONMENT,
[body.reporter.email], },
`Ticket ${id}: ${body.subject}` [body.reporter.email],
); `Ticket ${id}: ${body.subject}`,
} catch (e) { );
console.log(e); } catch (e) {
} console.log(e);
}
} }

View File

@@ -0,0 +1,29 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
import {
getFirestore,
collection,
getDocs,
getDoc,
doc,
} 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.session.user) {
res.status(401).json({ ok: false });
return;
}
const { id } = req.query as { id: string };
const snapshot = await getDoc(doc(db, "users", id));
if (!snapshot.exists()) return res.status(404).json({ ok: false });
res.status(200).json({ ...snapshot.data(), id: snapshot.id });
}

View File

@@ -23,12 +23,15 @@ interface Contact {
number: string; number: string;
} }
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
const { code, language = 'en' } = req.query as { code: string, language: string}; const { code, language = "en" } = req.query as {
code: string;
language: string;
};
const usersQuery = query( const usersQuery = query(
collection(db, "users"), collection(db, "users"),
where("type", "==", "agent"), where("type", "==", "agent"),
where("demographicInformation.country", "==", code) where("demographicInformation.country", "==", code),
); );
const docsUser = await getDocs(usersQuery); const docsUser = await getDocs(usersQuery);
@@ -36,15 +39,22 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
const entries = docs.map((user: AgentUser) => { const entries = docs.map((user: AgentUser) => {
const newUser = { const newUser = {
name: user.agentInformation.companyName, name:
(language === "en"
? user.agentInformation?.companyName
: user.agentInformation?.companyArabName ||
user.agentInformation?.companyName) || user.name,
email: user.email, email: user.email,
number: user.demographicInformation?.phone as string, number: user.demographicInformation?.phone as string,
} as Contact; } as Contact;
return newUser; return newUser;
}) as Contact[]; }) as Contact[];
const country = countryCodes.findOne("countryCode" as any, code.toUpperCase()); const country = countryCodes.findOne(
const key = language === 'ar' ? 'countryNameLocal' : 'countryNameEn'; "countryCode" as any,
code.toUpperCase(),
);
const key = language === "ar" ? "countryNameLocal" : "countryNameEn";
res.json({ res.json({
label: country[key], label: country[key],

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