Compare commits
109 Commits
feature-pa
...
feature/EN
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a65b72adad | ||
|
|
e13aea9f7d | ||
|
|
2920fa7f3a | ||
|
|
7af96ecccc | ||
|
|
70716b3483 | ||
|
|
d7bb64e7e0 | ||
|
|
dd19b5746c | ||
|
|
f967282f71 | ||
|
|
8b2459c304 | ||
|
|
72fb934d4f | ||
|
|
ed0b8bcb99 | ||
|
|
6f211d8435 | ||
|
|
b59589b855 | ||
|
|
db20feaa00 | ||
|
|
8fc2cf571e | ||
|
|
3128fea8c9 | ||
|
|
0e53b4a454 | ||
|
|
cbb61d18fe | ||
|
|
dff51cf6ea | ||
|
|
15dbadcc53 | ||
|
|
624a3fb88e | ||
|
|
00feee2179 | ||
|
|
0f8f9bc05b | ||
|
|
f76b7578a6 | ||
|
|
1a17689cd2 | ||
|
|
a958e2ff0d | ||
|
|
36b861266f | ||
|
|
771262fc18 | ||
|
|
0f03ce95e7 | ||
|
|
6a6e010daa | ||
|
|
13496387c4 | ||
|
|
4ecb21e0ae | ||
|
|
8663fe13bd | ||
|
|
de4638bc46 | ||
|
|
c9740fe8ee | ||
|
|
9b9b67c6cd | ||
|
|
fe2abaacae | ||
|
|
11e2ea3249 | ||
|
|
2de4b7c715 | ||
|
|
a8ffebe944 | ||
|
|
9ab7c3ed59 | ||
|
|
f374d91ef8 | ||
|
|
62ecc4e395 | ||
|
|
46764cacfa | ||
|
|
0b9e1bd734 | ||
|
|
bddb2ed18e | ||
|
|
e8fbeff77a | ||
|
|
b64593df90 | ||
|
|
2657cb409c | ||
|
|
329ed573b3 | ||
|
|
bb7558afb8 | ||
|
|
259ed03ee4 | ||
|
|
bf6c805487 | ||
|
|
1086e78936 | ||
|
|
7d0d930140 | ||
|
|
f02fff55e7 | ||
|
|
08e71c4dd8 | ||
|
|
6f5a74844c | ||
|
|
c4011cd456 | ||
|
|
5ef2568aa5 | ||
|
|
6d817e6d27 | ||
|
|
5decfb098d | ||
|
|
c2b6be4425 | ||
|
|
f320fee416 | ||
|
|
445e486cd2 | ||
|
|
ee26b50cf6 | ||
|
|
22f2b43692 | ||
|
|
29b2c8b3b8 | ||
|
|
51cc1e3f36 | ||
|
|
d9fce10538 | ||
|
|
bd74313bd5 | ||
|
|
18df890ef9 | ||
|
|
13ebb9bbd8 | ||
|
|
38c0c823e1 | ||
|
|
b50e15d1d9 | ||
|
|
969698d8b8 | ||
|
|
7d83ebc5c5 | ||
|
|
e99650ecd8 | ||
|
|
7287a9ce9a | ||
|
|
8cc7e6a57d | ||
|
|
0a24cb9978 | ||
|
|
a5c1286748 | ||
|
|
06684a4900 | ||
|
|
1823538058 | ||
|
|
60ccc822b5 | ||
|
|
9abd69c5e5 | ||
|
|
2667891bdd | ||
|
|
65485a0d1f | ||
|
|
74dd96d000 | ||
|
|
49ee3c45e5 | ||
|
|
49d2680a07 | ||
|
|
9dac7fd19e | ||
|
|
528299571c | ||
|
|
dcc630b8e5 | ||
|
|
be5125e5b0 | ||
|
|
0adf45c6ad | ||
|
|
d9b93a3470 | ||
|
|
83e4173750 | ||
|
|
e2d5f6ac9d | ||
|
|
37c3c6f7f4 | ||
|
|
3b4dfb9648 | ||
|
|
330c177ff9 | ||
|
|
0cff310354 | ||
|
|
87a1d7c288 | ||
|
|
8e1fe15a24 | ||
|
|
c95c0eff9b | ||
|
|
eaf94f458a | ||
|
|
ba85596e79 | ||
|
|
c6a478c406 |
@@ -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",
|
||||||
|
|||||||
BIN
public/manuals/corporate.pdf
Normal file
BIN
public/manuals/corporate.pdf
Normal file
Binary file not shown.
BIN
public/manuals/student.pdf
Normal file
BIN
public/manuals/student.pdf
Normal file
Binary file not shown.
BIN
public/manuals/teacher.pdf
Normal file
BIN
public/manuals/teacher.pdf
Normal file
Binary file not shown.
@@ -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}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
||||||
import {MatchSentencesExercise} from "@/interfaces/exam";
|
import {MatchSentenceExerciseOption, MatchSentenceExerciseSentence, MatchSentencesExercise} from "@/interfaces/exam";
|
||||||
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
||||||
import Icon from "@mdi/react";
|
import Icon from "@mdi/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
@@ -9,13 +9,74 @@ import {CommonProps} from ".";
|
|||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import Xarrow from "react-xarrows";
|
import Xarrow from "react-xarrows";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import {DndContext, DragEndEvent, useDraggable, useDroppable} from "@dnd-kit/core";
|
||||||
|
|
||||||
|
function DroppableQuestionArea({question, answer}: {question: MatchSentenceExerciseSentence; answer?: string}) {
|
||||||
|
const {isOver, setNodeRef} = useDroppable({id: `droppable_sentence_${question.id}`});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-4" ref={setNodeRef}>
|
||||||
|
<div className="flex items-center gap-3 cursor-pointer col-span-2">
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
)}>
|
||||||
|
{question.id}
|
||||||
|
</button>
|
||||||
|
<span>{question.sentence}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
key={`answer_${question.id}_${answer}`}
|
||||||
|
className={clsx("w-48 h-10 border rounded-xl flex items-center justify-center", isOver && "border-mti-purple-light")}>
|
||||||
|
{answer && `Paragraph ${answer}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DraggableOptionArea({option}: {option: MatchSentenceExerciseOption}) {
|
||||||
|
const {attributes, listeners, setNodeRef, transform} = useDraggable({
|
||||||
|
id: `draggable_option_${option.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = transform
|
||||||
|
? {
|
||||||
|
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||||
|
zIndex: 99,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx("flex items-center justify-start gap-6 cursor-pointer")} ref={setNodeRef} style={style} {...listeners} {...attributes}>
|
||||||
|
<button
|
||||||
|
id={`option_${option.id}`}
|
||||||
|
// onClick={() => selectOption(id)}
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple px-3 py-2 rounded-full z-10",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
option.id,
|
||||||
|
)}>
|
||||||
|
Paragraph {option.id}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function MatchSentences({id, options, type, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) {
|
export default function MatchSentences({id, options, type, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) {
|
||||||
const [selectedQuestion, setSelectedQuestion] = useState<string>();
|
|
||||||
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
if (event.over && event.over.id.toString().startsWith("droppable")) {
|
||||||
|
const optionID = event.active.id.toString().replace("draggable_option_", "");
|
||||||
|
const sentenceID = event.over.id.toString().replace("droppable_sentence_", "");
|
||||||
|
|
||||||
|
setAnswers((prev) => [...prev.filter((x) => x.question.toString() !== sentenceID), {question: sentenceID, option: optionID}]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = sentences.length;
|
const total = sentences.length;
|
||||||
const correct = answers.filter(
|
const correct = answers.filter(
|
||||||
@@ -26,11 +87,9 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
|||||||
return {total, correct, missing};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectOption = (option: string) => {
|
useEffect(() => {
|
||||||
if (!selectedQuestion) return;
|
console.log(answers);
|
||||||
setAnswers((prev) => [...prev.filter((x) => x.question !== selectedQuestion), {question: selectedQuestion, option}]);
|
}, [answers]);
|
||||||
setSelectedQuestion(undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
@@ -39,7 +98,7 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||||
<span className="text-sm w-full leading-6">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
@@ -48,47 +107,29 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
|
|
||||||
|
<DndContext onDragEnd={handleDragEnd}>
|
||||||
|
<div className="flex flex-col gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{sentences.map(({sentence, id}) => (
|
{sentences.map((question) => (
|
||||||
<div key={`question_${id}`} className="flex items-center justify-end gap-2 cursor-pointer">
|
<DroppableQuestionArea
|
||||||
<span>{sentence} </span>
|
key={`question_${question.id}`}
|
||||||
<button
|
question={question}
|
||||||
id={id}
|
answer={answers.find((x) => x.question.toString() === question.id.toString())?.option}
|
||||||
onClick={() => setSelectedQuestion((prev) => (prev === id ? undefined : id))}
|
/>
|
||||||
className={clsx(
|
|
||||||
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
selectedQuestion === id && "!text-white !bg-mti-purple",
|
|
||||||
id,
|
|
||||||
)}>
|
|
||||||
{id}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{options.map(({sentence, id}) => (
|
<span>Drag one of these paragraphs into the slots above:</span>
|
||||||
<div key={`answer_${id}`} className={clsx("flex items-center justify-start gap-2 cursor-pointer")}>
|
<div className="flex gap-4 flex-wrap justify-center items-center max-w-lg">
|
||||||
<button
|
{options.map((option) => (
|
||||||
id={id}
|
<DraggableOptionArea key={`answer_${option.id}`} option={option} />
|
||||||
onClick={() => selectOption(id)}
|
|
||||||
className={clsx(
|
|
||||||
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
id,
|
|
||||||
)}>
|
|
||||||
{id}
|
|
||||||
</button>
|
|
||||||
<span>{sentence}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{answers.map((solution, index) => (
|
|
||||||
<Xarrow key={index} start={solution.question} end={solution.option} lineColor="#7872BF" showHead={false} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,17 +29,23 @@ export default function Select({value, defaultValue, options, placeholder, disab
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ReactSelect
|
<ReactSelect
|
||||||
className={clsx(
|
className={
|
||||||
|
styles
|
||||||
|
? undefined
|
||||||
|
: clsx(
|
||||||
"placeholder:text-mti-gray-cool border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none",
|
"placeholder:text-mti-gray-cool border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none",
|
||||||
disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
|
disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
|
||||||
)}
|
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={
|
||||||
|
styles || {
|
||||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||||
control: (styles) => ({
|
control: (styles) => ({
|
||||||
...styles,
|
...styles,
|
||||||
@@ -53,7 +61,8 @@ export default function Select({value, defaultValue, options, placeholder, disab
|
|||||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||||
color: state.isFocused ? "black" : styles.color,
|
color: state.isFocused ? "black" : styles.color,
|
||||||
}),
|
}),
|
||||||
}}
|
}
|
||||||
|
}
|
||||||
isDisabled={disabled}
|
isDisabled={disabled}
|
||||||
isClearable={isClearable}
|
isClearable={isClearable}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,11 +70,14 @@ 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) {
|
if (request.status !== 200) {
|
||||||
toast.error("Something went wrong, please try again later");
|
toast.error("Something went wrong, please try again later");
|
||||||
return;
|
return;
|
||||||
@@ -75,6 +85,11 @@ export default function PayPalPayment({
|
|||||||
|
|
||||||
toast.success("Your account has been credited more time!");
|
toast.success("Your account has been credited more time!");
|
||||||
return onSuccess(duration, duration_unit);
|
return onSuccess(duration, duration_unit);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
toast.error("Something went wrong, please try again later");
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onError = async (data: Record<string, unknown>) => {
|
const onError = async (data: Record<string, unknown>) => {
|
||||||
@@ -96,7 +111,6 @@ export default function PayPalPayment({
|
|||||||
currency,
|
currency,
|
||||||
intent: "capture",
|
intent: "capture",
|
||||||
commit: true,
|
commit: true,
|
||||||
vault: true,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PayPalButtons
|
<PayPalButtons
|
||||||
|
|||||||
107
src/components/PaymobPayment.tsx
Normal file
107
src/components/PaymobPayment.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{(userType || "") !== 'agent' && (
|
||||||
|
<>
|
||||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" 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} />
|
<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} />
|
||||||
|
{(userType || "") !== 'agent' && (
|
||||||
|
<>
|
||||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" 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} />
|
<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} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import useStats from "@/hooks/useStats";
|
import useStats from "@/hooks/useStats";
|
||||||
import {EMPLOYMENT_STATUS, User} from "@/interfaces/user";
|
import {CorporateInformation, CorporateUser, EMPLOYMENT_STATUS, User} from "@/interfaces/user";
|
||||||
import {groupBySession, averageScore} from "@/utils/stats";
|
import {groupBySession, averageScore} from "@/utils/stats";
|
||||||
import {RadioGroup} from "@headlessui/react";
|
import {RadioGroup} from "@headlessui/react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -8,7 +8,7 @@ import moment from "moment";
|
|||||||
import {Divider} from "primereact/divider";
|
import {Divider} from "primereact/divider";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import ReactDatePicker from "react-datepicker";
|
import ReactDatePicker from "react-datepicker";
|
||||||
import {BsFileEarmarkText, BsPencil, BsStar} from "react-icons/bs";
|
import {BsFileEarmarkText, BsPencil, BsPerson, BsPersonAdd, BsStar} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import Button from "./Low/Button";
|
import Button from "./Low/Button";
|
||||||
import Checkbox from "./Low/Checkbox";
|
import Checkbox from "./Low/Checkbox";
|
||||||
@@ -19,6 +19,7 @@ import Select from "react-select";
|
|||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
import {CURRENCIES} from "@/resources/paypal";
|
import {CURRENCIES} from "@/resources/paypal";
|
||||||
|
import useCodes from "@/hooks/useCodes";
|
||||||
|
|
||||||
const expirationDateColor = (date: Date) => {
|
const expirationDateColor = (date: Date) => {
|
||||||
const momentDate = moment(date);
|
const momentDate = moment(date);
|
||||||
@@ -37,6 +38,9 @@ interface Props {
|
|||||||
onViewTeachers?: () => void;
|
onViewTeachers?: () => void;
|
||||||
onViewCorporate?: () => void;
|
onViewCorporate?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
disabledFields?: {
|
||||||
|
countryManager?: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const USER_STATUS_OPTIONS = [
|
const USER_STATUS_OPTIONS = [
|
||||||
@@ -59,9 +63,12 @@ const USER_TYPE_OPTIONS = Object.keys(USER_TYPE_LABELS).map((type) => ({
|
|||||||
label: USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS],
|
label: USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const CURRENCIES_OPTIONS = CURRENCIES.map(({label, currency}) => ({value: currency, label}));
|
const CURRENCIES_OPTIONS = CURRENCIES.map(({label, currency}) => ({
|
||||||
|
value: currency,
|
||||||
|
label,
|
||||||
|
}));
|
||||||
|
|
||||||
const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers, onViewCorporate, disabled = false}: Props) => {
|
const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers, onViewCorporate, disabled = false, disabledFields = {}}: Props) => {
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null | undefined>(user.subscriptionExpirationDate);
|
const [expiryDate, setExpiryDate] = useState<Date | null | undefined>(user.subscriptionExpirationDate);
|
||||||
const [type, setType] = useState(user.type);
|
const [type, setType] = useState(user.type);
|
||||||
const [status, setStatus] = useState(user.status);
|
const [status, setStatus] = useState(user.status);
|
||||||
@@ -77,6 +84,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
? user.agentInformation?.companyName
|
? user.agentInformation?.companyName
|
||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
|
const [arabName, setArabName] = useState(user.type === "agent" ? user.agentInformation?.companyArabName : undefined);
|
||||||
const [commercialRegistration, setCommercialRegistration] = useState(
|
const [commercialRegistration, setCommercialRegistration] = useState(
|
||||||
user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined,
|
user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined,
|
||||||
);
|
);
|
||||||
@@ -87,6 +95,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
const [commissionValue, setCommission] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.commission : undefined);
|
const [commissionValue, setCommission] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.commission : undefined);
|
||||||
const {stats} = useStats(user.id);
|
const {stats} = useStats(user.id);
|
||||||
const {users} = useUsers();
|
const {users} = useUsers();
|
||||||
|
const {codes} = useCodes(user.id);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (users && users.length > 0) {
|
if (users && users.length > 0) {
|
||||||
@@ -114,8 +123,9 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
agentInformation:
|
agentInformation:
|
||||||
type === "agent"
|
type === "agent"
|
||||||
? {
|
? {
|
||||||
name: companyName,
|
companyName,
|
||||||
commercialRegistration,
|
commercialRegistration,
|
||||||
|
arabName,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
corporateInformation:
|
corporateInformation:
|
||||||
@@ -144,11 +154,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const generalProfileItems = [
|
||||||
<>
|
|
||||||
<ProfileSummary
|
|
||||||
user={user}
|
|
||||||
items={[
|
|
||||||
{
|
{
|
||||||
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||||
value: Object.keys(groupBySession(stats)).length,
|
value: Object.keys(groupBySession(stats)).length,
|
||||||
@@ -157,25 +163,54 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
{
|
{
|
||||||
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||||
value: stats.length,
|
value: stats.length,
|
||||||
label: "Exercises",
|
label: "Modules",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BsStar className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
icon: <BsStar className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||||
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
|
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
|
||||||
label: "Average Score",
|
label: "Average Score",
|
||||||
},
|
},
|
||||||
]}
|
];
|
||||||
/>
|
|
||||||
|
const corporateProfileItems =
|
||||||
|
user.type === "corporate"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
icon: <BsPerson className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||||
|
value: codes.length,
|
||||||
|
label: "Users Used",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <BsPersonAdd className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||||
|
value: user.corporateInformation.companyInformation.userAmount,
|
||||||
|
label: "Number of Users",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ProfileSummary user={user} items={user.type === "corporate" ? corporateProfileItems : generalProfileItems} />
|
||||||
|
|
||||||
{user.type === "agent" && (
|
{user.type === "agent" && (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 w-full">
|
||||||
<Input
|
<Input
|
||||||
label="Corporate Name"
|
label="Company Name (Arabic)"
|
||||||
|
type="text"
|
||||||
|
name="arabName"
|
||||||
|
onChange={setArabName}
|
||||||
|
placeholder="Enter their company's name in arabic"
|
||||||
|
defaultValue={arabName}
|
||||||
|
required
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Company Name (English)"
|
||||||
type="text"
|
type="text"
|
||||||
name="companyName"
|
name="companyName"
|
||||||
onChange={setCompanyName}
|
onChange={setCompanyName}
|
||||||
placeholder="Enter corporate name"
|
placeholder="Enter their company's name in english"
|
||||||
defaultValue={companyName}
|
defaultValue={companyName}
|
||||||
required
|
required
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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!",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -5,22 +5,18 @@ 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,
|
|
||||||
BsClipboard,
|
|
||||||
BsHeadphones,
|
|
||||||
BsMegaphone,
|
|
||||||
BsPen,
|
|
||||||
} from "react-icons/bs";
|
|
||||||
import {usePDFDownload} from "@/hooks/usePDFDownload";
|
import {usePDFDownload} from "@/hooks/usePDFDownload";
|
||||||
import {useAssignmentArchive} from "@/hooks/useAssignmentArchive";
|
import {useAssignmentArchive} from "@/hooks/useAssignmentArchive";
|
||||||
import {uniqBy} from "lodash";
|
import {uniqBy} from "lodash";
|
||||||
|
import {useAssignmentUnarchive} from "@/hooks/useAssignmentUnarchive";
|
||||||
|
|
||||||
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({
|
||||||
@@ -37,45 +33,35 @@ export default function AssignmentCard({
|
|||||||
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
|
|
||||||
);
|
|
||||||
const total = moduleStats.reduce(
|
|
||||||
(acc, curr) => acc + curr.score.total,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
return calculateBandScore(correct, total, module, r.type);
|
return calculateBandScore(correct, total, module, r.type);
|
||||||
});
|
});
|
||||||
|
|
||||||
return resultModuleBandScores.length === 0
|
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / results.length;
|
||||||
? -1
|
|
||||||
: resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
|
|
||||||
results.length;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="border-mti-gray-platinum flex h-fit w-[350px] cursor-pointer flex-col gap-6 rounded-xl border bg-white p-4 transition duration-300 ease-in-out hover:drop-shadow"
|
className="border-mti-gray-platinum flex h-fit w-[350px] cursor-pointer flex-col gap-6 rounded-xl border bg-white p-4 transition duration-300 ease-in-out hover:drop-shadow">
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex flex-row justify-between">
|
<div className="flex flex-row justify-between">
|
||||||
<h3 className="text-xl font-semibold">{name}</h3>
|
<h3 className="text-xl font-semibold">{name}</h3>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{allowDownload &&
|
{allowDownload && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
|
||||||
renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
|
{allowArchive && !archived && renderArchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
|
||||||
{allowArchive &&
|
{allowUnarchive && archived && renderUnarchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
|
||||||
!archived &&
|
|
||||||
renderArchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
@@ -83,11 +69,7 @@ export default function AssignmentCard({
|
|||||||
percentage={(results.length / assignees.length) * 100}
|
percentage={(results.length / assignees.length) * 100}
|
||||||
label={`${results.length}/${assignees.length}`}
|
label={`${results.length}/${assignees.length}`}
|
||||||
className="h-5"
|
className="h-5"
|
||||||
textClassName={
|
textClassName={results.length / assignees.length < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"}
|
||||||
results.length / assignees.length < 0.5
|
|
||||||
? "!text-mti-gray-dim font-light"
|
|
||||||
: "text-white"
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="flex justify-between gap-1">
|
<span className="flex justify-between gap-1">
|
||||||
@@ -105,18 +87,15 @@ export default function AssignmentCard({
|
|||||||
module === "listening" && "bg-ielts-listening",
|
module === "listening" && "bg-ielts-listening",
|
||||||
module === "writing" && "bg-ielts-writing",
|
module === "writing" && "bg-ielts-writing",
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
module === "level" && "bg-ielts-level"
|
module === "level" && "bg-ielts-level",
|
||||||
)}
|
)}>
|
||||||
>
|
|
||||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||||
{calculateAverageModuleScore(module) > -1 && (
|
{calculateAverageModuleScore(module) > -1 && (
|
||||||
<span className="text-sm">
|
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
|
||||||
{calculateAverageModuleScore(module).toFixed(1)}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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" />,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 didn’t ask to verify this address, you can ignore this email.</p>
|
<p>If you didn’t ask to verify this address, you can ignore this email.</p>
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ import {Fragment, useEffect, useState} from "react";
|
|||||||
import {BsArrowCounterclockwise, BsBook, BsClipboard, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs";
|
import {BsArrowCounterclockwise, BsBook, BsClipboard, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs";
|
||||||
import {LevelScore} from "@/constants/ielts";
|
import {LevelScore} from "@/constants/ielts";
|
||||||
import {getLevelScore} from "@/utils/score";
|
import {getLevelScore} from "@/utils/score";
|
||||||
|
import {capitalize} from "lodash";
|
||||||
|
|
||||||
interface Score {
|
interface Score {
|
||||||
module: Module;
|
module: Module;
|
||||||
@@ -25,7 +26,7 @@ interface Props {
|
|||||||
modules: Module[];
|
modules: Module[];
|
||||||
scores: Score[];
|
scores: Score[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onViewResults: () => void;
|
onViewResults: (moduleIndex?: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Finish({user, scores, modules, isLoading, onViewResults}: Props) {
|
export default function Finish({user, scores, modules, isLoading, onViewResults}: Props) {
|
||||||
@@ -182,7 +183,8 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
{showLevel(bandScore)}
|
{showLevel(bandScore)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-5">
|
{!["writing", "speaking"].includes(selectedModule) ? (
|
||||||
|
<div className="flex flex-col gap-5 w-28">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="bg-mti-red-light mt-1 h-3 w-3 rounded-full" />
|
<div className="bg-mti-red-light mt-1 h-3 w-3 rounded-full" />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
@@ -209,6 +211,9 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-28 h-full" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -220,6 +225,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
|
disabled={user.type === "admin"}
|
||||||
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
||||||
<BsArrowCounterclockwise className="h-7 w-7 text-white" />
|
<BsArrowCounterclockwise className="h-7 w-7 text-white" />
|
||||||
</button>
|
</button>
|
||||||
@@ -227,11 +233,19 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={onViewResults}
|
onClick={() => onViewResults()}
|
||||||
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
||||||
<BsEyeFill className="h-7 w-7 text-white" />
|
<BsEyeFill className="h-7 w-7 text-white" />
|
||||||
</button>
|
</button>
|
||||||
<span>Review Answers</span>
|
<span>Review All</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => onViewResults(modules.findIndex((x) => x === selectedModule))}
|
||||||
|
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
||||||
|
<BsEyeFill className="h-7 w-7 text-white" />
|
||||||
|
</button>
|
||||||
|
<span>Review {capitalize(selectedModule)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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))}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ReadingExam, UserSolution} from "@/interfaces/exam";
|
import {MultipleChoiceExercise, ReadingExam, ReadingPart, UserSolution} from "@/interfaces/exam";
|
||||||
import {Fragment, useEffect, useState} from "react";
|
import {Fragment, useEffect, useState} from "react";
|
||||||
import Icon from "@mdi/react";
|
import Icon from "@mdi/react";
|
||||||
import {mdiArrowRight, mdiNotebook} from "@mdi/js";
|
import {mdiArrowRight, mdiNotebook} from "@mdi/js";
|
||||||
@@ -10,7 +10,7 @@ import {renderExercise} from "@/components/Exercises";
|
|||||||
import {renderSolution} from "@/components/Solutions";
|
import {renderSolution} from "@/components/Solutions";
|
||||||
import {Panel} from "primereact/panel";
|
import {Panel} from "primereact/panel";
|
||||||
import {Steps} from "primereact/steps";
|
import {Steps} from "primereact/steps";
|
||||||
import {BsAlarm, BsBook, BsClock, BsStopwatch} from "react-icons/bs";
|
import {BsAlarm, BsBook, BsChevronDown, BsChevronUp, BsClock, BsStopwatch} from "react-icons/bs";
|
||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||||
import {Divider} from "primereact/divider";
|
import {Divider} from "primereact/divider";
|
||||||
@@ -26,6 +26,8 @@ interface Props {
|
|||||||
onFinish: (userSolutions: UserSolution[]) => void;
|
onFinish: (userSolutions: UserSolution[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const numberToLetter = (number: number) => (number + 9).toString(36).toUpperCase();
|
||||||
|
|
||||||
function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: string; content: string; onClose: () => void}) {
|
function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: string; content: string; onClose: () => void}) {
|
||||||
return (
|
return (
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
@@ -80,17 +82,43 @@ function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: s
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TextComponent({part, exerciseType}: {part: ReadingPart; exerciseType: string}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 w-full">
|
||||||
|
<h3 className="text-xl font-semibold">{part.text.title}</h3>
|
||||||
|
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
|
||||||
|
{part.text.content
|
||||||
|
.split(/\n|(\\n)/g)
|
||||||
|
.filter((x) => x && x.length > 0)
|
||||||
|
.map((line, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
{exerciseType === "matchSentences" && (
|
||||||
|
<div className="flex gap-3 border border-transparent hover:border-mti-purple-light rounded-lg transition ease-in-out duration-300 p-2 px-3 cursor-pointer">
|
||||||
|
<span className="font-bold text-mti-purple-dark">{numberToLetter(index + 1)}</span>
|
||||||
|
<p>{line}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{exerciseType !== "matchSentences" && <p key={index}>{line}</p>}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Reading({exam, showSolutions = false, onFinish}: Props) {
|
export default function Reading({exam, showSolutions = false, onFinish}: Props) {
|
||||||
const [questionIndex, setQuestionIndex] = useState(0);
|
const [questionIndex, setQuestionIndex] = useState(0);
|
||||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||||
const [showTextModal, setShowTextModal] = useState(false);
|
const [showTextModal, setShowTextModal] = useState(false);
|
||||||
const [showBlankModal, setShowBlankModal] = useState(false);
|
const [showBlankModal, setShowBlankModal] = useState(false);
|
||||||
|
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]);
|
||||||
|
const [isTextMinimized, setIsTextMinimzed] = useState(false);
|
||||||
|
const [exerciseType, setExerciseType] = useState("");
|
||||||
|
|
||||||
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||||
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
||||||
const {partIndex, setPartIndex} = useExamStore((state) => state);
|
const {partIndex, setPartIndex} = useExamStore((state) => state);
|
||||||
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
||||||
const setStoreQuestionIndex = useExamStore((state) => state.setQuestionIndex);
|
const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]);
|
||||||
|
|
||||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||||
|
|
||||||
@@ -98,6 +126,21 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
if (showSolutions) setExerciseIndex(-1);
|
if (showSolutions) setExerciseIndex(-1);
|
||||||
}, [setExerciseIndex, showSolutions]);
|
}, [setExerciseIndex, showSolutions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previousParts = exam.parts.filter((_, index) => index < partIndex);
|
||||||
|
let previousMultipleChoice = previousParts.flatMap((x) => x.exercises).filter((x) => x.type === "multipleChoice") as MultipleChoiceExercise[];
|
||||||
|
|
||||||
|
if (partIndex > -1 && exerciseIndex > -1) {
|
||||||
|
const previousPartExercises = exam.parts[partIndex].exercises.filter((_, index) => index < exerciseIndex);
|
||||||
|
const partMultipleChoice = previousPartExercises.filter((x) => x.type === "multipleChoice") as MultipleChoiceExercise[];
|
||||||
|
|
||||||
|
previousMultipleChoice = [...previousMultipleChoice, ...partMultipleChoice];
|
||||||
|
}
|
||||||
|
|
||||||
|
setMultipleChoicesDone(previousMultipleChoice.map((x) => ({id: x.id, amount: x.questions.length - 1})));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const listener = (e: KeyboardEvent) => {
|
const listener = (e: KeyboardEvent) => {
|
||||||
if (e.key === "F3" || ((e.ctrlKey || e.metaKey) && e.key === "f")) {
|
if (e.key === "F3" || ((e.ctrlKey || e.metaKey) && e.key === "f")) {
|
||||||
@@ -128,15 +171,19 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onFinish(userSolutions.map((x) => ({...x, module: "reading", exam: exam.id})));
|
onFinish(userSolutions);
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextExercise = (solution?: UserSolution) => {
|
const nextExercise = (solution?: UserSolution) => {
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "reading", exam: exam.id}]);
|
||||||
|
}
|
||||||
|
if (storeQuestionIndex > 0) {
|
||||||
|
const exercise = getExercise();
|
||||||
|
setExerciseType(exercise.type);
|
||||||
|
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise.id), {id: exercise.id, amount: storeQuestionIndex}]);
|
||||||
}
|
}
|
||||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
|
||||||
setStoreQuestionIndex(0);
|
setStoreQuestionIndex(0);
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
||||||
@@ -165,18 +212,16 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
setHasExamEnded(false);
|
setHasExamEnded(false);
|
||||||
|
|
||||||
if (solution) {
|
if (solution) {
|
||||||
onFinish(
|
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "reading", exam: exam.id}]);
|
||||||
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "reading", exam: exam.id})),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
onFinish(userSolutions.map((x) => ({...x, module: "reading", exam: exam.id})));
|
onFinish(userSolutions);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const previousExercise = (solution?: UserSolution) => {
|
const previousExercise = (solution?: UserSolution) => {
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "reading", exam: exam.id}]);
|
||||||
}
|
}
|
||||||
setStoreQuestionIndex(0);
|
setStoreQuestionIndex(0);
|
||||||
|
|
||||||
@@ -191,23 +236,56 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (partIndex > -1 && exerciseIndex > -1) {
|
||||||
|
const exercise = getExercise();
|
||||||
|
setExerciseType(exercise.type);
|
||||||
|
setMultipleChoicesDone((prev) => prev.filter((x) => x.id !== exercise.id));
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [exerciseIndex, partIndex]);
|
||||||
|
|
||||||
|
const calculateExerciseIndex = () => {
|
||||||
|
if (partIndex === -1) return 0;
|
||||||
|
if (partIndex === 0)
|
||||||
|
return (
|
||||||
|
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) + storeQuestionIndex + multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
const exercisesPerPart = exam.parts.map((x) => x.exercises.length);
|
||||||
|
const exercisesDone = exercisesPerPart.filter((_, index) => index < partIndex).reduce((acc, curr) => curr + acc, 0);
|
||||||
|
return (
|
||||||
|
exercisesDone +
|
||||||
|
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) +
|
||||||
|
storeQuestionIndex +
|
||||||
|
multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renderText = () => (
|
const renderText = () => (
|
||||||
<div className="flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16 mt-4">
|
<div className={clsx("flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl mt-4 relative", isTextMinimized ? "p-2 px-8" : "py-8 px-16")}>
|
||||||
|
<button
|
||||||
|
data-tip={isTextMinimized ? "Maximise" : "Minimize"}
|
||||||
|
className={clsx("absolute right-8 tooltip", isTextMinimized ? "top-1/2 -translate-y-1/2" : "top-8")}
|
||||||
|
onClick={() => setIsTextMinimzed((prev) => !prev)}>
|
||||||
|
{isTextMinimized ? (
|
||||||
|
<BsChevronDown className="text-mti-purple-dark text-lg" />
|
||||||
|
) : (
|
||||||
|
<BsChevronUp className="text-mti-purple-dark text-lg" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{!isTextMinimized && (
|
||||||
|
<>
|
||||||
<div className="flex flex-col w-full gap-2">
|
<div className="flex flex-col w-full gap-2">
|
||||||
<h4 className="text-xl font-semibold">
|
<h4 className="text-xl font-semibold">
|
||||||
Please read the following excerpt attentively, you will then be asked questions about the text you've read.
|
Please read the following excerpt attentively, you will then be asked questions about the text you've read.
|
||||||
</h4>
|
</h4>
|
||||||
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
|
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 w-full">
|
<TextComponent part={exam.parts[partIndex]} exerciseType={exerciseType} />
|
||||||
<h3 className="text-xl font-semibold">{exam.parts[partIndex].text.title}</h3>
|
</>
|
||||||
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
|
)}
|
||||||
<span className="overflow-auto">
|
{isTextMinimized && <span className="font-semibold">Reading Passage</span>}
|
||||||
{exam.parts[partIndex].text.content.split("\\n").map((line, index) => (
|
|
||||||
<p key={index}>{line}</p>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -218,25 +296,18 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
{partIndex > -1 && <TextModal {...exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />}
|
{partIndex > -1 && <TextModal {...exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />}
|
||||||
<ModuleTitle
|
<ModuleTitle
|
||||||
minTimer={exam.minTimer}
|
minTimer={exam.minTimer}
|
||||||
exerciseIndex={
|
exerciseIndex={calculateExerciseIndex()}
|
||||||
(exam.parts
|
|
||||||
.flatMap((x) => x.exercises)
|
|
||||||
.findIndex(
|
|
||||||
(x) =>
|
|
||||||
x.id ===
|
|
||||||
exam.parts[partIndex > -1 ? partIndex : 0].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]
|
|
||||||
?.id,
|
|
||||||
) || 0) +
|
|
||||||
(exerciseIndex === -1 ? 0 : 1) +
|
|
||||||
questionIndex +
|
|
||||||
currentQuestionIndex
|
|
||||||
}
|
|
||||||
module="reading"
|
module="reading"
|
||||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||||
disableTimer={showSolutions}
|
disableTimer={showSolutions}
|
||||||
label={exerciseIndex === -1 ? undefined : convertCamelCaseToReadable(exam.parts[partIndex].exercises[exerciseIndex].type)}
|
label={exerciseIndex === -1 ? undefined : convertCamelCaseToReadable(exam.parts[partIndex].exercises[exerciseIndex].type)}
|
||||||
/>
|
/>
|
||||||
<div className={clsx("mb-20 w-full", exerciseIndex > -1 && "grid grid-cols-2 gap-4")}>
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"mb-20 w-full",
|
||||||
|
exerciseIndex > -1 && !isTextMinimized && "grid grid-cols-2 gap-4",
|
||||||
|
exerciseIndex > -1 && isTextMinimized && "flex flex-col gap-2",
|
||||||
|
)}>
|
||||||
{partIndex > -1 && renderText()}
|
{partIndex > -1 && renderText()}
|
||||||
|
|
||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
|
|||||||
@@ -4,7 +4,17 @@ 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 {
|
||||||
|
BsArrowRepeat,
|
||||||
|
BsBook,
|
||||||
|
BsCheck,
|
||||||
|
BsCheckCircle,
|
||||||
|
BsClipboard,
|
||||||
|
BsHeadphones,
|
||||||
|
BsMegaphone,
|
||||||
|
BsPen,
|
||||||
|
BsXCircle,
|
||||||
|
} from "react-icons/bs";
|
||||||
import { totalExamsByModule } from "@/utils/stats";
|
import { totalExamsByModule } from "@/utils/stats";
|
||||||
import useStats from "@/hooks/useStats";
|
import useStats from "@/hooks/useStats";
|
||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
@@ -21,11 +31,20 @@ import moment from "moment";
|
|||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
page: "exercises" | "exams";
|
page: "exercises" | "exams";
|
||||||
onStart: (modules: Module[], avoidRepeated: boolean, variant: Variant) => void;
|
onStart: (
|
||||||
|
modules: Module[],
|
||||||
|
avoidRepeated: boolean,
|
||||||
|
variant: Variant,
|
||||||
|
) => void;
|
||||||
disableSelection?: boolean;
|
disableSelection?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Selection({user, page, onStart, disableSelection = false}: Props) {
|
export default function Selection({
|
||||||
|
user,
|
||||||
|
page,
|
||||||
|
onStart,
|
||||||
|
disableSelection = false,
|
||||||
|
}: Props) {
|
||||||
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
|
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
|
||||||
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
|
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
|
||||||
const [variant, setVariant] = useState<Variant>("full");
|
const [variant, setVariant] = useState<Variant>("full");
|
||||||
@@ -37,7 +56,9 @@ export default function Selection({user, page, onStart, disableSelection = false
|
|||||||
|
|
||||||
const toggleModule = (module: Module) => {
|
const toggleModule = (module: Module) => {
|
||||||
const modules = selectedModules.filter((x) => x !== module);
|
const modules = selectedModules.filter((x) => x !== module);
|
||||||
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module]));
|
setSelectedModules((prev) =>
|
||||||
|
prev.includes(module) ? modules : [...modules, module],
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadSession = async (session: Session) => {
|
const loadSession = async (session: Session) => {
|
||||||
@@ -63,31 +84,41 @@ export default function Selection({user, page, onStart, disableSelection = false
|
|||||||
user={user}
|
user={user}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
icon: <BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />,
|
icon: (
|
||||||
|
<BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />
|
||||||
|
),
|
||||||
label: "Reading",
|
label: "Reading",
|
||||||
value: totalExamsByModule(stats, "reading"),
|
value: totalExamsByModule(stats, "reading"),
|
||||||
tooltip: "The amount of reading exams performed.",
|
tooltip: "The amount of reading exams performed.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />,
|
icon: (
|
||||||
|
<BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />
|
||||||
|
),
|
||||||
label: "Listening",
|
label: "Listening",
|
||||||
value: totalExamsByModule(stats, "listening"),
|
value: totalExamsByModule(stats, "listening"),
|
||||||
tooltip: "The amount of listening exams performed.",
|
tooltip: "The amount of listening exams performed.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />,
|
icon: (
|
||||||
|
<BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />
|
||||||
|
),
|
||||||
label: "Writing",
|
label: "Writing",
|
||||||
value: totalExamsByModule(stats, "writing"),
|
value: totalExamsByModule(stats, "writing"),
|
||||||
tooltip: "The amount of writing exams performed.",
|
tooltip: "The amount of writing exams performed.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />,
|
icon: (
|
||||||
|
<BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />
|
||||||
|
),
|
||||||
label: "Speaking",
|
label: "Speaking",
|
||||||
value: totalExamsByModule(stats, "speaking"),
|
value: totalExamsByModule(stats, "speaking"),
|
||||||
tooltip: "The amount of speaking exams performed.",
|
tooltip: "The amount of speaking exams performed.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />,
|
icon: (
|
||||||
|
<BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />
|
||||||
|
),
|
||||||
label: "Level",
|
label: "Level",
|
||||||
value: totalExamsByModule(stats, "level"),
|
value: totalExamsByModule(stats, "level"),
|
||||||
tooltip: "The amount of level exams performed.",
|
tooltip: "The amount of level exams performed.",
|
||||||
@@ -101,23 +132,35 @@ export default function Selection({user, page, onStart, disableSelection = false
|
|||||||
<span className="text-mti-gray-taupe">
|
<span className="text-mti-gray-taupe">
|
||||||
{page === "exercises" && (
|
{page === "exercises" && (
|
||||||
<>
|
<>
|
||||||
In the realm of language acquisition, practice makes perfect, and our exercises are the key to unlocking your full
|
In the realm of language acquisition, practice makes perfect,
|
||||||
potential. Dive into a world of interactive and engaging exercises that cater to diverse learning styles. From grammar
|
and our exercises are the key to unlocking your full potential.
|
||||||
drills that build a strong foundation to vocabulary challenges that broaden your lexicon, our exercises are carefully
|
Dive into a world of interactive and engaging exercises that
|
||||||
designed to make learning English both enjoyable and effective. Whether you'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're
|
||||||
|
looking to reinforce specific skills or embark on a holistic
|
||||||
|
language journey, our exercises are your companions in the
|
||||||
|
pursuit of excellence. Embrace the joy of learning as you
|
||||||
|
navigate through a variety of activities that cater to every
|
||||||
|
facet of language acquisition. Your linguistic adventure starts
|
||||||
|
here!
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{page === "exams" && (
|
{page === "exams" && (
|
||||||
<>
|
<>
|
||||||
Welcome to the heart of success on your English language journey! Our exams are crafted with precision to assess and
|
Welcome to the heart of success on your English language
|
||||||
enhance your language skills. Each test is a passport to your linguistic prowess, designed to challenge and elevate
|
journey! Our exams are crafted with precision to assess and
|
||||||
your abilities. Whether you're a beginner or a seasoned learner, our exams cater to all levels, providing a
|
enhance your language skills. Each test is a passport to your
|
||||||
comprehensive evaluation of your reading, writing, speaking, and listening skills. Prepare to embark on a journey of
|
linguistic prowess, designed to challenge and elevate your
|
||||||
self-discovery and language mastery as you navigate through our thoughtfully curated exams. Your success is not just a
|
abilities. Whether you're a beginner or a seasoned learner,
|
||||||
destination; it's a testament to your dedication and our commitment to empowering you with the English language.
|
our exams cater to all levels, providing a comprehensive
|
||||||
|
evaluation of your reading, writing, speaking, and listening
|
||||||
|
skills. Prepare to embark on a journey of self-discovery and
|
||||||
|
language mastery as you navigate through our thoughtfully
|
||||||
|
curated exams. Your success is not just a destination; it's
|
||||||
|
a testament to your dedication and our commitment to empowering
|
||||||
|
you with the English language.
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -128,16 +171,26 @@ export default function Selection({user, page, onStart, disableSelection = false
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div
|
<div
|
||||||
onClick={reload}
|
onClick={reload}
|
||||||
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out">
|
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out"
|
||||||
<span className="text-mti-black text-lg font-bold">Unfinished Sessions</span>
|
>
|
||||||
<BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} />
|
<span className="text-mti-black text-lg font-bold">
|
||||||
|
Unfinished Sessions
|
||||||
|
</span>
|
||||||
|
<BsArrowRepeat
|
||||||
|
className={clsx("text-xl", isLoading && "animate-spin")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
||||||
{sessions
|
{sessions
|
||||||
.sort((a, b) => moment(b.date).diff(moment(a.date)))
|
.sort((a, b) => moment(b.date).diff(moment(a.date)))
|
||||||
.map((session) => (
|
.map((session) => (
|
||||||
<SessionCard session={session} key={session.sessionId} reload={reload} loadSession={loadSession} />
|
<SessionCard
|
||||||
|
session={session}
|
||||||
|
key={session.sessionId}
|
||||||
|
reload={reload}
|
||||||
|
loadSession={loadSession}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
</section>
|
</section>
|
||||||
@@ -145,108 +198,170 @@ export default function Selection({user, page, onStart, disableSelection = false
|
|||||||
|
|
||||||
<section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8">
|
<section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8">
|
||||||
<div
|
<div
|
||||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
|
onClick={
|
||||||
|
!disableSelection && !selectedModules.includes("level")
|
||||||
|
? () => toggleModule("reading")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||||
selectedModules.includes("reading") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
selectedModules.includes("reading") || disableSelection
|
||||||
)}>
|
? "border-mti-purple-light"
|
||||||
|
: "border-mti-gray-platinum",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="bg-ielts-reading absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
<div className="bg-ielts-reading absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||||
<BsBook className="h-7 w-7 text-white" />
|
<BsBook className="h-7 w-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold">Reading:</span>
|
<span className="font-semibold">Reading:</span>
|
||||||
<p className="text-left text-xs">
|
<p className="text-left text-xs">
|
||||||
Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
|
Expand your vocabulary, improve your reading comprehension and
|
||||||
|
improve your ability to interpret texts in English.
|
||||||
</p>
|
</p>
|
||||||
{!selectedModules.includes("reading") && !selectedModules.includes("level") && !disableSelection && (
|
{!selectedModules.includes("reading") &&
|
||||||
|
!selectedModules.includes("level") &&
|
||||||
|
!disableSelection && (
|
||||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||||
)}
|
)}
|
||||||
{(selectedModules.includes("reading") || disableSelection) && (
|
{(selectedModules.includes("reading") || disableSelection) && (
|
||||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||||
)}
|
)}
|
||||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
{selectedModules.includes("level") && (
|
||||||
|
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
|
onClick={
|
||||||
|
!disableSelection && !selectedModules.includes("level")
|
||||||
|
? () => toggleModule("listening")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||||
selectedModules.includes("listening") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
selectedModules.includes("listening") || disableSelection
|
||||||
)}>
|
? "border-mti-purple-light"
|
||||||
|
: "border-mti-gray-platinum",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="bg-ielts-listening absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
<div className="bg-ielts-listening absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||||
<BsHeadphones className="h-7 w-7 text-white" />
|
<BsHeadphones className="h-7 w-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold">Listening:</span>
|
<span className="font-semibold">Listening:</span>
|
||||||
<p className="text-left text-xs">
|
<p className="text-left text-xs">
|
||||||
Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
|
Improve your ability to follow conversations in English and your
|
||||||
|
ability to understand different accents and intonations.
|
||||||
</p>
|
</p>
|
||||||
{!selectedModules.includes("listening") && !selectedModules.includes("level") && !disableSelection && (
|
{!selectedModules.includes("listening") &&
|
||||||
|
!selectedModules.includes("level") &&
|
||||||
|
!disableSelection && (
|
||||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||||
)}
|
)}
|
||||||
{(selectedModules.includes("listening") || disableSelection) && (
|
{(selectedModules.includes("listening") || disableSelection) && (
|
||||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||||
)}
|
)}
|
||||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
{selectedModules.includes("level") && (
|
||||||
|
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
|
onClick={
|
||||||
|
!disableSelection && !selectedModules.includes("level")
|
||||||
|
? () => toggleModule("writing")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||||
selectedModules.includes("writing") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
selectedModules.includes("writing") || disableSelection
|
||||||
)}>
|
? "border-mti-purple-light"
|
||||||
|
: "border-mti-gray-platinum",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="bg-ielts-writing absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
<div className="bg-ielts-writing absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||||
<BsPen className="h-7 w-7 text-white" />
|
<BsPen className="h-7 w-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold">Writing:</span>
|
<span className="font-semibold">Writing:</span>
|
||||||
<p className="text-left text-xs">
|
<p className="text-left text-xs">
|
||||||
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
|
Allow you to practice writing in a variety of formats, from simple
|
||||||
|
paragraphs to complex essays.
|
||||||
</p>
|
</p>
|
||||||
{!selectedModules.includes("writing") && !selectedModules.includes("level") && !disableSelection && (
|
{!selectedModules.includes("writing") &&
|
||||||
|
!selectedModules.includes("level") &&
|
||||||
|
!disableSelection && (
|
||||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||||
)}
|
)}
|
||||||
{(selectedModules.includes("writing") || disableSelection) && (
|
{(selectedModules.includes("writing") || disableSelection) && (
|
||||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||||
)}
|
)}
|
||||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
{selectedModules.includes("level") && (
|
||||||
|
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
|
onClick={
|
||||||
|
!disableSelection && !selectedModules.includes("level")
|
||||||
|
? () => toggleModule("speaking")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||||
selectedModules.includes("speaking") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
selectedModules.includes("speaking") || disableSelection
|
||||||
)}>
|
? "border-mti-purple-light"
|
||||||
|
: "border-mti-gray-platinum",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="bg-ielts-speaking absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
<div className="bg-ielts-speaking absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||||
<BsMegaphone className="h-7 w-7 text-white" />
|
<BsMegaphone className="h-7 w-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold">Speaking:</span>
|
<span className="font-semibold">Speaking:</span>
|
||||||
<p className="text-left text-xs">
|
<p className="text-left text-xs">
|
||||||
You'll have access to interactive dialogs, pronunciation exercises and speech recordings.
|
You'll have access to interactive dialogs, pronunciation
|
||||||
|
exercises and speech recordings.
|
||||||
</p>
|
</p>
|
||||||
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && !disableSelection && (
|
{!selectedModules.includes("speaking") &&
|
||||||
|
!selectedModules.includes("level") &&
|
||||||
|
!disableSelection && (
|
||||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||||
)}
|
)}
|
||||||
{(selectedModules.includes("speaking") || disableSelection) && (
|
{(selectedModules.includes("speaking") || disableSelection) && (
|
||||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||||
)}
|
)}
|
||||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
{selectedModules.includes("level") && (
|
||||||
|
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!disableSelection && (
|
{!disableSelection && (
|
||||||
<div
|
<div
|
||||||
onClick={selectedModules.length === 0 || selectedModules.includes("level") ? () => toggleModule("level") : undefined}
|
onClick={
|
||||||
|
selectedModules.length === 0 ||
|
||||||
|
selectedModules.includes("level")
|
||||||
|
? () => toggleModule("level")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||||
selectedModules.includes("level") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
selectedModules.includes("level") || disableSelection
|
||||||
)}>
|
? "border-mti-purple-light"
|
||||||
|
: "border-mti-gray-platinum",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="bg-ielts-level absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
<div className="bg-ielts-level absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||||
<BsClipboard className="h-7 w-7 text-white" />
|
<BsClipboard className="h-7 w-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold">Level:</span>
|
<span className="font-semibold">Level:</span>
|
||||||
<p className="text-left text-xs">You'll be able to test your english level with multiple choice questions.</p>
|
<p className="text-left text-xs">
|
||||||
{!selectedModules.includes("level") && selectedModules.length === 0 && !disableSelection && (
|
You'll be able to test your english level with multiple
|
||||||
|
choice questions.
|
||||||
|
</p>
|
||||||
|
{!selectedModules.includes("level") &&
|
||||||
|
selectedModules.length === 0 &&
|
||||||
|
!disableSelection && (
|
||||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||||
)}
|
)}
|
||||||
{(selectedModules.includes("level") || disableSelection) && (
|
{(selectedModules.includes("level") || disableSelection) && (
|
||||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||||
)}
|
)}
|
||||||
{!selectedModules.includes("level") && selectedModules.length > 0 && (
|
{!selectedModules.includes("level") &&
|
||||||
|
selectedModules.length > 0 && (
|
||||||
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -256,51 +371,68 @@ export default function Selection({user, page, onStart, disableSelection = false
|
|||||||
<div className="flex w-full flex-col items-center gap-3">
|
<div className="flex w-full flex-col items-center gap-3">
|
||||||
<div
|
<div
|
||||||
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
|
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
|
||||||
onClick={() => setAvoidRepeatedExams((prev) => !prev)}>
|
onClick={() => setAvoidRepeatedExams((prev) => !prev)}
|
||||||
|
>
|
||||||
<input type="checkbox" className="hidden" />
|
<input type="checkbox" className="hidden" />
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
|
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
avoidRepeatedExams && "!bg-mti-purple-light ",
|
avoidRepeatedExams && "!bg-mti-purple-light ",
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
<BsCheck color="white" className="h-full w-full" />
|
<BsCheck color="white" className="h-full w-full" />
|
||||||
</div>
|
</div>
|
||||||
<span className="tooltip" data-tip="If possible, the platform will choose exams not yet done.">
|
<span
|
||||||
|
className="tooltip"
|
||||||
|
data-tip="If possible, the platform will choose exams not yet done."
|
||||||
|
>
|
||||||
Avoid Repeated Questions
|
Avoid Repeated Questions
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
|
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
|
||||||
onClick={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
|
// onClick={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
|
||||||
<input type="checkbox" className="hidden" />
|
>
|
||||||
|
<input type="checkbox" className="hidden" disabled />
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
|
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
variant === "full" && "!bg-mti-purple-light ",
|
variant === "full" && "!bg-mti-purple-light ",
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
<BsCheck color="white" className="h-full w-full" />
|
<BsCheck color="white" className="h-full w-full" />
|
||||||
</div>
|
</div>
|
||||||
<span>Full length exams</span>
|
<span>Full length exams</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="tooltip w-full" data-tip={`Your screen size is too small to do ${page}`}>
|
<div
|
||||||
<Button color="purple" className="w-full max-w-xs px-12 md:hidden" disabled>
|
className="tooltip w-full"
|
||||||
|
data-tip={`Your screen size is too small to do ${page}`}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
className="w-full max-w-xs px-12 md:hidden"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
Start Exam
|
Start Exam
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onStart(
|
onStart(
|
||||||
!disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"],
|
!disableSelection
|
||||||
|
? selectedModules.sort(sortByModuleName)
|
||||||
|
: ["reading", "listening", "writing", "speaking"],
|
||||||
avoidRepeatedExams,
|
avoidRepeatedExams,
|
||||||
variant,
|
variant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
color="purple"
|
color="purple"
|
||||||
className="-md:hidden w-full max-w-xs px-12 md:self-end"
|
className="-md:hidden w-full max-w-xs px-12 md:self-end"
|
||||||
disabled={selectedModules.length === 0 && !disableSelection}>
|
disabled={selectedModules.length === 0 && !disableSelection}
|
||||||
|
>
|
||||||
Start Exam
|
Start Exam
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
24
src/exams/pdf/list.item.tsx
Normal file
24
src/exams/pdf/list.item.tsx
Normal 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;
|
||||||
@@ -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 }) =>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -149,16 +154,47 @@ const TestReport = ({
|
|||||||
.filter(
|
.filter(
|
||||||
({ suggestions, evaluation }) => suggestions || evaluation
|
({ suggestions, evaluation }) => suggestions || evaluation
|
||||||
)
|
)
|
||||||
.map(({ module, suggestions, evaluation }) => (
|
.map(
|
||||||
<View key={module} style={customStyles.testDetails}>
|
({
|
||||||
<Text style={[...defaultSkillsTitleStyle, styles.textBold]}>
|
module,
|
||||||
|
suggestions,
|
||||||
|
evaluation,
|
||||||
|
bullet_points = [],
|
||||||
|
}) => (
|
||||||
|
<View key={module} style={customStyles.testDetailsContainer}>
|
||||||
|
<View style={customStyles.testDetails}>
|
||||||
|
<Text
|
||||||
|
style={[...defaultSkillsTitleStyle, styles.textBold]}
|
||||||
|
>
|
||||||
{module}
|
{module}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={defaultSkillsTextStyle}>{evaluation}</Text>
|
<Text style={defaultSkillsTextStyle}>{evaluation}</Text>
|
||||||
<Text style={defaultSkillsTextStyle}>{suggestions}</Text>
|
<Text style={defaultSkillsTextStyle}>{suggestions}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<View style={customStyles.testDetails}>
|
||||||
|
{bullet_points.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
style={defaultSkillsTitleStyle}
|
||||||
|
>
|
||||||
|
How to Improve:
|
||||||
|
</Text>
|
||||||
|
<View>
|
||||||
|
{bullet_points.map((text: string) => (
|
||||||
|
<ListItem
|
||||||
|
key={text}
|
||||||
|
text={text}
|
||||||
|
textStyle={defaultSkillsTextStyle}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
<View style={styles.alignRightRow}>
|
<View style={styles.alignRightRow}>
|
||||||
<Image src={qrcode} style={styles.qrcode} />
|
<Image src={qrcode} style={styles.qrcode} />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ 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,
|
|
||||||
reload?: Function
|
|
||||||
) => {
|
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
const archive = () => {
|
const archive = () => {
|
||||||
// archive assignment
|
// archive assignment
|
||||||
@@ -26,18 +23,18 @@ export const useAssignmentArchive = (
|
|||||||
|
|
||||||
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 (
|
return (
|
||||||
<BsArchive
|
<div
|
||||||
className={`${downloadClasses} text-2xl cursor-pointer`}
|
className="tooltip flex items-center justify-center w-fit h-fit"
|
||||||
|
data-tip="Archive assignment"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
archive();
|
archive();
|
||||||
}}
|
}}>
|
||||||
/>
|
<BsArchive className={`${downloadClasses} text-2xl cursor-pointer tooltip`} />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
42
src/hooks/useAssignmentUnarchive.tsx
Normal file
42
src/hooks/useAssignmentUnarchive.tsx
Normal 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;
|
||||||
|
};
|
||||||
22
src/hooks/useDiscounts.tsx
Normal file
22
src/hooks/useDiscounts.tsx
Normal 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 };
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import {useState, useMemo} from 'react';
|
import {useState, useMemo} from "react";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
|
|
||||||
/*fields example = [
|
/*fields example = [
|
||||||
@@ -6,43 +6,33 @@ import Input from "@/components/Low/Input";
|
|||||||
['companyInformation', 'companyInformation', 'name']
|
['companyInformation', 'companyInformation', 'name']
|
||||||
]*/
|
]*/
|
||||||
|
|
||||||
|
|
||||||
const getFieldValue = (fields: string[], data: any): string => {
|
const getFieldValue = (fields: string[], data: any): string => {
|
||||||
if (fields.length === 0) return data;
|
if (fields.length === 0) return data;
|
||||||
const [key, ...otherFields] = fields;
|
const [key, ...otherFields] = fields;
|
||||||
|
|
||||||
if (data[key]) return getFieldValue(otherFields, data[key]);
|
if (data[key]) return getFieldValue(otherFields, data[key]);
|
||||||
return data;
|
return data;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const useListSearch = (fields: string[][], rows: any[]) => {
|
export function useListSearch<T>(fields: string[][], rows: T[]) {
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState("");
|
||||||
|
|
||||||
const renderSearch = () => (
|
const renderSearch = () => <Input label="Search" type="text" name="search" onChange={setText} placeholder="Enter search text" value={text} />;
|
||||||
<Input
|
|
||||||
label="Search"
|
|
||||||
type="text"
|
|
||||||
name="search"
|
|
||||||
onChange={setText}
|
|
||||||
placeholder="Enter search text"
|
|
||||||
value={text}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
const updatedRows = useMemo(() => {
|
const updatedRows = useMemo(() => {
|
||||||
const searchText = text.toLowerCase();
|
const searchText = text.toLowerCase();
|
||||||
return rows.filter((row) => {
|
return rows.filter((row) => {
|
||||||
return fields.some((fieldsKeys) => {
|
return fields.some((fieldsKeys) => {
|
||||||
const value = getFieldValue(fieldsKeys, row);
|
const value = getFieldValue(fieldsKeys, row);
|
||||||
if(typeof value === 'string') {
|
if (typeof value === "string") {
|
||||||
return value.toLowerCase().includes(searchText);
|
return value.toLowerCase().includes(searchText);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
}, [fields, rows, text])
|
}, [fields, rows, text]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: updatedRows,
|
rows: updatedRows,
|
||||||
renderSearch,
|
renderSearch,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
@@ -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};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
allowRepetition: boolean;
|
||||||
|
options: MatchSentenceExerciseOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchSentenceExerciseSentence {
|
||||||
id: string;
|
id: string;
|
||||||
sentence: string;
|
sentence: string;
|
||||||
solution: string;
|
solution: string;
|
||||||
color: string;
|
color: string;
|
||||||
}[];
|
}
|
||||||
allowRepetition: boolean;
|
|
||||||
options: {
|
export interface MatchSentenceExerciseOption {
|
||||||
id: string;
|
id: string;
|
||||||
sentence: string;
|
sentence: string;
|
||||||
}[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MultipleChoiceExercise {
|
export interface MultipleChoiceExercise {
|
||||||
|
|||||||
@@ -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
118
src/interfaces/paymob.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -20,6 +20,12 @@ export interface Package {
|
|||||||
price: number;
|
price: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Discount {
|
||||||
|
id: string;
|
||||||
|
percentage: number;
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type DurationUnit = "weeks" | "days" | "months" | "years";
|
export type DurationUnit = "weeks" | "days" | "months" | "years";
|
||||||
|
|
||||||
export interface Payment {
|
export interface Payment {
|
||||||
@@ -36,7 +42,6 @@ export interface Payment {
|
|||||||
commissionTransfer?: string;
|
commissionTransfer?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface PaypalPayment {
|
export interface PaypalPayment {
|
||||||
orderId: string;
|
orderId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import {Module} from ".";
|
||||||
import {Type} from "./user";
|
import {Type} from "./user";
|
||||||
|
|
||||||
export interface Ticket {
|
export interface Ticket {
|
||||||
@@ -10,6 +11,15 @@ export interface Ticket {
|
|||||||
description: string;
|
description: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
assignedTo?: string;
|
assignedTo?: string;
|
||||||
|
examInformation?: {
|
||||||
|
exams: string[];
|
||||||
|
exam: string;
|
||||||
|
selectedModules: Module[];
|
||||||
|
moduleIndex: number;
|
||||||
|
partIndex: number;
|
||||||
|
exerciseIndex: number;
|
||||||
|
questionIndex: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TicketReporter {
|
export interface TicketReporter {
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
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;
|
||||||
@@ -17,7 +24,7 @@ export interface BasicUser {
|
|||||||
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 {
|
||||||
@@ -70,6 +77,7 @@ export interface CorporateInformation {
|
|||||||
export interface AgentInformation {
|
export interface AgentInformation {
|
||||||
companyName: string;
|
companyName: string;
|
||||||
commercialRegistration: string;
|
commercialRegistration: string;
|
||||||
|
companyArabName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompanyInformation {
|
export interface CompanyInformation {
|
||||||
@@ -95,8 +103,15 @@ export interface DemographicCorporateInformation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type Gender = "male" | "female" | "other";
|
export type Gender = "male" | "female" | "other";
|
||||||
export type EmploymentStatus = "employed" | "student" | "self-employed" | "unemployed" | "retired" | "other";
|
export type EmploymentStatus =
|
||||||
export const EMPLOYMENT_STATUS: {status: EmploymentStatus; label: string}[] = [
|
| "employed"
|
||||||
|
| "student"
|
||||||
|
| "self-employed"
|
||||||
|
| "unemployed"
|
||||||
|
| "retired"
|
||||||
|
| "other";
|
||||||
|
export const EMPLOYMENT_STATUS: { status: EmploymentStatus; label: string }[] =
|
||||||
|
[
|
||||||
{ status: "student", label: "Student" },
|
{ status: "student", label: "Student" },
|
||||||
{ status: "employed", label: "Employed" },
|
{ status: "employed", label: "Employed" },
|
||||||
{ status: "unemployed", label: "Unemployed" },
|
{ status: "unemployed", label: "Unemployed" },
|
||||||
@@ -122,6 +137,7 @@ export interface Stat {
|
|||||||
total: number;
|
total: number;
|
||||||
missing: number;
|
missing: number;
|
||||||
};
|
};
|
||||||
|
isDisabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Group {
|
export interface Group {
|
||||||
@@ -137,11 +153,25 @@ export interface Code {
|
|||||||
creator: string;
|
creator: string;
|
||||||
expiryDate: Date;
|
expiryDate: Date;
|
||||||
type: Type;
|
type: Type;
|
||||||
|
creationDate?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
passport_id?: string;
|
passport_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent";
|
export type Type =
|
||||||
export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent"];
|
| "student"
|
||||||
|
| "teacher"
|
||||||
|
| "corporate"
|
||||||
|
| "admin"
|
||||||
|
| "developer"
|
||||||
|
| "agent";
|
||||||
|
export const userTypes: Type[] = [
|
||||||
|
"student",
|
||||||
|
"teacher",
|
||||||
|
"corporate",
|
||||||
|
"admin",
|
||||||
|
"developer",
|
||||||
|
"agent",
|
||||||
|
];
|
||||||
|
|||||||
@@ -17,9 +17,7 @@ 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: [],
|
||||||
@@ -31,11 +29,11 @@ const USER_TYPE_PERMISSIONS: { [key in Type]: Type[] } = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function BatchCodeGenerator({user}: {user: User}) {
|
export default function BatchCodeGenerator({user}: {user: User}) {
|
||||||
const [infos, setInfos] = useState<
|
const [infos, setInfos] = useState<{email: string; name: string; passport_id: string}[]>([]);
|
||||||
{ email: string; name: string; passport_id: string }[]
|
|
||||||
>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
|
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||||
|
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
|
||||||
|
);
|
||||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||||
const [type, setType] = useState<Type>("student");
|
const [type, setType] = useState<Type>("student");
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
@@ -48,12 +46,7 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
|
|||||||
readAs: "ArrayBuffer",
|
readAs: "ArrayBuffer",
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => console.log(expiryDate), [expiryDate]);
|
||||||
if (user && (user.type === "corporate" || user.type === "teacher")) {
|
|
||||||
setExpiryDate(user.subscriptionExpirationDate || null);
|
|
||||||
setIsExpiryDateEnabled(!!user.subscriptionExpirationDate);
|
|
||||||
}
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||||
@@ -67,14 +60,7 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
|
|||||||
const information = uniqBy(
|
const information = uniqBy(
|
||||||
rows
|
rows
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
const [
|
const [firstName, lastName, country, passport_id, email, ...phone] = row as string[];
|
||||||
firstName,
|
|
||||||
lastName,
|
|
||||||
country,
|
|
||||||
passport_id,
|
|
||||||
email,
|
|
||||||
...phone
|
|
||||||
] = row as string[];
|
|
||||||
return EMAIL_REGEX.test(email.toString().trim())
|
return EMAIL_REGEX.test(email.toString().trim())
|
||||||
? {
|
? {
|
||||||
email: email.toString().trim().toLowerCase(),
|
email: email.toString().trim().toLowerCase(),
|
||||||
@@ -107,20 +93,14 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
|
|||||||
}, [filesContent]);
|
}, [filesContent]);
|
||||||
|
|
||||||
const generateAndInvite = async () => {
|
const generateAndInvite = async () => {
|
||||||
const newUsers = infos.filter(
|
const newUsers = infos.filter((x) => !users.map((u) => u.email).includes(x.email));
|
||||||
(x) => !users.map((u) => u.email).includes(x.email),
|
|
||||||
);
|
|
||||||
const existingUsers = infos
|
const existingUsers = infos
|
||||||
.filter((x) => users.map((u) => u.email).includes(x.email))
|
.filter((x) => users.map((u) => u.email).includes(x.email))
|
||||||
.map((i) => users.find((u) => u.email === i.email))
|
.map((i) => users.find((u) => u.email === i.email))
|
||||||
.filter((x) => !!x && x.type === "student") as User[];
|
.filter((x) => !!x && x.type === "student") as User[];
|
||||||
|
|
||||||
const newUsersSentence =
|
const newUsersSentence = newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined;
|
||||||
newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined;
|
const existingUsersSentence = existingUsers.length > 0 ? `invite ${existingUsers.length} registered student(s)` : undefined;
|
||||||
const existingUsersSentence =
|
|
||||||
existingUsers.length > 0
|
|
||||||
? `invite ${existingUsers.length} registered student(s)`
|
|
||||||
: undefined;
|
|
||||||
if (
|
if (
|
||||||
!confirm(
|
!confirm(
|
||||||
`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`,
|
`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`,
|
||||||
@@ -129,17 +109,8 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
Promise.all(
|
Promise.all(existingUsers.map(async (u) => await axios.post(`/api/invites`, {to: u.id, from: user.id})))
|
||||||
existingUsers.map(
|
.then(() => toast.success(`Successfully invited ${existingUsers.length} registered student(s)!`))
|
||||||
async (u) =>
|
|
||||||
await axios.post(`/api/invites`, { to: u.id, from: user.id }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.then(() =>
|
|
||||||
toast.success(
|
|
||||||
`Successfully invited ${existingUsers.length} registered student(s)!`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (newUsers.length === 0) setIsLoading(false);
|
if (newUsers.length === 0) setIsLoading(false);
|
||||||
});
|
});
|
||||||
@@ -193,30 +164,18 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal
|
<Modal isOpen={showHelp} onClose={() => setShowHelp(false)} title="Excel File Format">
|
||||||
isOpen={showHelp}
|
|
||||||
onClose={() => setShowHelp(false)}
|
|
||||||
title="Excel File Format"
|
|
||||||
>
|
|
||||||
<div className="mt-4 flex flex-col gap-2">
|
<div className="mt-4 flex flex-col gap-2">
|
||||||
<span>Please upload an Excel file with the following format:</span>
|
<span>Please upload an Excel file with the following format:</span>
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="border border-neutral-200 px-2 py-1">
|
<th className="border border-neutral-200 px-2 py-1">First Name</th>
|
||||||
First Name
|
<th className="border border-neutral-200 px-2 py-1">Last Name</th>
|
||||||
</th>
|
|
||||||
<th className="border border-neutral-200 px-2 py-1">
|
|
||||||
Last Name
|
|
||||||
</th>
|
|
||||||
<th className="border border-neutral-200 px-2 py-1">Country</th>
|
<th className="border border-neutral-200 px-2 py-1">Country</th>
|
||||||
<th className="border border-neutral-200 px-2 py-1">
|
<th className="border border-neutral-200 px-2 py-1">Passport/National ID</th>
|
||||||
Passport/National ID
|
|
||||||
</th>
|
|
||||||
<th className="border border-neutral-200 px-2 py-1">E-mail</th>
|
<th className="border border-neutral-200 px-2 py-1">E-mail</th>
|
||||||
<th className="border border-neutral-200 px-2 py-1">
|
<th className="border border-neutral-200 px-2 py-1">Phone Number</th>
|
||||||
Phone Number
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
</table>
|
</table>
|
||||||
@@ -225,48 +184,27 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
|
|||||||
<ul>
|
<ul>
|
||||||
<li>- All incorrect e-mails will be ignored;</li>
|
<li>- All incorrect e-mails will be ignored;</li>
|
||||||
<li>- All already registered e-mails will be ignored;</li>
|
<li>- All already registered e-mails will be ignored;</li>
|
||||||
<li>
|
<li>- You may have a header row with the format above, however, it is not necessary;</li>
|
||||||
- You may have a header row with the format above, however, it
|
<li>- All of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below.</li>
|
||||||
is not necessary;
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
- All of the e-mails in the file will receive an e-mail to join
|
|
||||||
EnCoach with the role selected below.
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
|
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
|
||||||
<div className="flex items-end justify-between">
|
<div className="flex items-end justify-between">
|
||||||
<label className="text-mti-gray-dim text-base font-normal">
|
<label className="text-mti-gray-dim text-base font-normal">Choose an Excel file</label>
|
||||||
Choose an Excel file
|
<div className="tooltip cursor-pointer" data-tip="Excel File Format" onClick={() => setShowHelp(true)}>
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
className="tooltip cursor-pointer"
|
|
||||||
data-tip="Excel File Format"
|
|
||||||
onClick={() => setShowHelp(true)}
|
|
||||||
>
|
|
||||||
<BsQuestionCircleFill />
|
<BsQuestionCircleFill />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
|
||||||
onClick={openFilePicker}
|
|
||||||
isLoading={isLoading}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
||||||
</Button>
|
</Button>
|
||||||
{user && (user.type === "developer" || user.type === "admin") && (
|
{user && (user.type === "developer" || user.type === "admin" || user.type === "corporate") && (
|
||||||
<>
|
<>
|
||||||
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
||||||
<label className="text-mti-gray-dim text-base font-normal">
|
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
|
||||||
Expiry Date
|
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}>
|
||||||
</label>
|
|
||||||
<Checkbox
|
|
||||||
isChecked={isExpiryDateEnabled}
|
|
||||||
onChange={setIsExpiryDateEnabled}
|
|
||||||
>
|
|
||||||
Enabled
|
Enabled
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
@@ -277,7 +215,10 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
|
|||||||
"hover:border-mti-purple tooltip",
|
"hover:border-mti-purple tooltip",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
)}
|
)}
|
||||||
filterDate={(date) => moment(date).isAfter(new Date())}
|
filterDate={(date) =>
|
||||||
|
moment(date).isAfter(new Date()) &&
|
||||||
|
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
|
||||||
|
}
|
||||||
dateFormat="dd/MM/yyyy"
|
dateFormat="dd/MM/yyyy"
|
||||||
selected={expiryDate}
|
selected={expiryDate}
|
||||||
onChange={(date) => setExpiryDate(date)}
|
onChange={(date) => setExpiryDate(date)}
|
||||||
@@ -285,19 +226,14 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<label className="text-mti-gray-dim text-base font-normal">
|
<label className="text-mti-gray-dim text-base font-normal">Select the type of user they should be</label>
|
||||||
Select the type of user they should be
|
|
||||||
</label>
|
|
||||||
{user && (
|
{user && (
|
||||||
<select
|
<select
|
||||||
defaultValue="student"
|
defaultValue="student"
|
||||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||||
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none"
|
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none">
|
||||||
>
|
|
||||||
{Object.keys(USER_TYPE_LABELS)
|
{Object.keys(USER_TYPE_LABELS)
|
||||||
.filter((x) =>
|
.filter((x) => USER_TYPE_PERMISSIONS[user.type].includes(x as Type))
|
||||||
USER_TYPE_PERMISSIONS[user.type].includes(x as Type),
|
|
||||||
)
|
|
||||||
.map((type) => (
|
.map((type) => (
|
||||||
<option key={type} value={type}>
|
<option key={type} value={type}>
|
||||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||||
@@ -305,12 +241,7 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button onClick={generateAndInvite} disabled={infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}>
|
||||||
onClick={generateAndInvite}
|
|
||||||
disabled={
|
|
||||||
infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Generate & Send
|
Generate & Send
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
322
src/pages/(admin)/Lists/CodeList.tsx
Normal file
322
src/pages/(admin)/Lists/CodeList.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
342
src/pages/(admin)/Lists/DiscountList.tsx
Normal file
342
src/pages/(admin)/Lists/DiscountList.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,13 +3,8 @@ 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";
|
||||||
@@ -18,11 +13,34 @@ 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;
|
||||||
@@ -32,13 +50,9 @@ interface CreateDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
||||||
const [name, setName] = useState<string | undefined>(
|
const [name, setName] = useState<string | undefined>(group?.name || undefined);
|
||||||
group?.name || undefined,
|
|
||||||
);
|
|
||||||
const [admin, setAdmin] = useState<string>(group?.admin || user.id);
|
const [admin, setAdmin] = useState<string>(group?.admin || user.id);
|
||||||
const [participants, setParticipants] = useState<string[]>(
|
const [participants, setParticipants] = useState<string[]>(group?.participants || []);
|
||||||
group?.participants || [],
|
|
||||||
);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const {openFilePicker, filesContent, clear} = useFilePicker({
|
const {openFilePicker, filesContent, clear} = useFilePicker({
|
||||||
@@ -57,10 +71,7 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
|||||||
rows
|
rows
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
const [email] = row as string[];
|
const [email] = row as string[];
|
||||||
return EMAIL_REGEX.test(email) &&
|
return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email) ? email.toString().trim() : undefined;
|
||||||
!users.map((u) => u.email).includes(email)
|
|
||||||
? email.toString().trim()
|
|
||||||
: undefined;
|
|
||||||
})
|
})
|
||||||
.filter((x) => !!x),
|
.filter((x) => !!x),
|
||||||
);
|
);
|
||||||
@@ -72,14 +83,10 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emailUsers = [...new Set(emails)]
|
const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined);
|
||||||
.map((x) => users.find((y) => y.email.toLowerCase() === x))
|
|
||||||
.filter((x) => x !== undefined);
|
|
||||||
const filteredUsers = emailUsers.filter(
|
const filteredUsers = emailUsers.filter(
|
||||||
(x) =>
|
(x) =>
|
||||||
((user.type === "developer" ||
|
((user.type === "developer" || user.type === "admin" || user.type === "corporate") &&
|
||||||
user.type === "admin" ||
|
|
||||||
user.type === "corporate") &&
|
|
||||||
(x?.type === "student" || x?.type === "teacher")) ||
|
(x?.type === "student" || x?.type === "teacher")) ||
|
||||||
(user.type === "teacher" && x?.type === "student"),
|
(user.type === "teacher" && x?.type === "student"),
|
||||||
);
|
);
|
||||||
@@ -101,21 +108,14 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
if (name !== group?.name && (name === "Students" || name === "Teachers")) {
|
if (name !== group?.name && (name === "Students" || name === "Teachers")) {
|
||||||
toast.error(
|
toast.error("That group name is reserved and cannot be used, please enter another one.");
|
||||||
"That group name is reserved and cannot be used, please enter another one.",
|
|
||||||
);
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
(group ? axios.patch : axios.post)(
|
(group ? axios.patch : axios.post)(group ? `/api/groups/${group.id}` : "/api/groups", {name, admin, participants})
|
||||||
group ? `/api/groups/${group.id}` : "/api/groups",
|
|
||||||
{ name, admin, participants },
|
|
||||||
)
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(
|
toast.success(`Group "${name}" ${group ? "edited" : "created"} successfully`);
|
||||||
`Group "${name}" ${group ? "edited" : "created"} successfully`,
|
|
||||||
);
|
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -131,24 +131,11 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
|||||||
return (
|
return (
|
||||||
<div className="mt-4 flex w-full flex-col gap-12 px-4 py-2">
|
<div className="mt-4 flex w-full flex-col gap-12 px-4 py-2">
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
<Input
|
<Input name="name" type="text" label="Name" defaultValue={name} onChange={setName} required disabled={group?.disableEditing} />
|
||||||
name="name"
|
|
||||||
type="text"
|
|
||||||
label="Name"
|
|
||||||
defaultValue={name}
|
|
||||||
onChange={setName}
|
|
||||||
required
|
|
||||||
disabled={group?.disableEditing}
|
|
||||||
/>
|
|
||||||
<div className="flex w-full flex-col gap-3">
|
<div className="flex w-full flex-col gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-mti-gray-dim text-base font-normal">
|
<label className="text-mti-gray-dim text-base font-normal">Participants</label>
|
||||||
Participants
|
<div className="tooltip" data-tip="The Excel file should only include a column with the desired e-mails.">
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
className="tooltip"
|
|
||||||
data-tip="The Excel file should only include a column with the desired e-mails."
|
|
||||||
>
|
|
||||||
<BsQuestionCircleFill />
|
<BsQuestionCircleFill />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -165,11 +152,7 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
|||||||
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
|
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
|
||||||
}))}
|
}))}
|
||||||
options={users
|
options={users
|
||||||
.filter((x) =>
|
.filter((x) => (user.type === "teacher" ? x.type === "student" : x.type === "student" || x.type === "teacher"))
|
||||||
user.type === "teacher"
|
|
||||||
? x.type === "student"
|
|
||||||
: x.type === "student" || x.type === "teacher",
|
|
||||||
)
|
|
||||||
.map((x) => ({value: x.id, label: `${x.email} - ${x.name}`}))}
|
.map((x) => ({value: x.id, label: `${x.email} - ${x.name}`}))}
|
||||||
onChange={(value) => setParticipants(value.map((x) => x.value))}
|
onChange={(value) => setParticipants(value.map((x) => x.value))}
|
||||||
isMulti
|
isMulti
|
||||||
@@ -187,36 +170,18 @@ const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{user.type !== "teacher" && (
|
{user.type !== "teacher" && (
|
||||||
<Button
|
<Button className="w-full max-w-[300px]" onClick={openFilePicker} isLoading={isLoading} variant="outline">
|
||||||
className="w-full max-w-[300px]"
|
{filesContent.length === 0 ? "Upload participants Excel file" : filesContent[0].name}
|
||||||
onClick={openFilePicker}
|
|
||||||
isLoading={isLoading}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{filesContent.length === 0
|
|
||||||
? "Upload participants Excel file"
|
|
||||||
: filesContent[0].name}
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-8 flex w-full items-center justify-end gap-8">
|
<div className="mt-8 flex w-full items-center justify-end gap-8">
|
||||||
<Button
|
<Button variant="outline" color="red" className="w-full max-w-[200px]" isLoading={isLoading} onClick={onClose}>
|
||||||
variant="outline"
|
|
||||||
color="red"
|
|
||||||
className="w-full max-w-[200px]"
|
|
||||||
isLoading={isLoading}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button className="w-full max-w-[200px]" onClick={submit} isLoading={isLoading} disabled={!name}>
|
||||||
className="w-full max-w-[200px]"
|
|
||||||
onClick={submit}
|
|
||||||
isLoading={isLoading}
|
|
||||||
disabled={!name}
|
|
||||||
>
|
|
||||||
Submit
|
Submit
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -232,9 +197,7 @@ export default function GroupList({ user }: { user: User }) {
|
|||||||
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")) {
|
||||||
@@ -264,16 +227,15 @@ export default function GroupList({ user }: { user: User }) {
|
|||||||
columnHelper.accessor("admin", {
|
columnHelper.accessor("admin", {
|
||||||
header: "Admin",
|
header: "Admin",
|
||||||
cell: (info) => (
|
cell: (info) => (
|
||||||
<div
|
<div className="tooltip" data-tip={capitalize(users.find((x) => x.id === info.getValue())?.type)}>
|
||||||
className="tooltip"
|
|
||||||
data-tip={capitalize(
|
|
||||||
users.find((x) => x.id === info.getValue())?.type,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{users.find((x) => x.id === info.getValue())?.name}
|
{users.find((x) => x.id === info.getValue())?.name}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
columnHelper.accessor("admin", {
|
||||||
|
header: "Linked Corporate",
|
||||||
|
cell: (info) => <LinkedCorporate userId={info.getValue()} users={users} groups={groups} />,
|
||||||
|
}),
|
||||||
columnHelper.accessor("participants", {
|
columnHelper.accessor("participants", {
|
||||||
header: "Participants",
|
header: "Participants",
|
||||||
cell: (info) =>
|
cell: (info) =>
|
||||||
@@ -288,28 +250,15 @@ export default function GroupList({ user }: { user: User }) {
|
|||||||
cell: ({row}: {row: {original: Group}}) => {
|
cell: ({row}: {row: {original: Group}}) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{user &&
|
{user && (user.type === "developer" || user.type === "admin" || user.id === row.original.admin) && (
|
||||||
(user.type === "developer" ||
|
|
||||||
user.type === "admin" ||
|
|
||||||
user.id === row.original.admin) && (
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{(!row.original.disableEditing ||
|
{(!row.original.disableEditing || ["developer", "admin"].includes(user.type)) && (
|
||||||
["developer", "admin"].includes(user.type)) && (
|
<div data-tip="Edit" className="tooltip cursor-pointer" onClick={() => setEditingGroup(row.original)}>
|
||||||
<div
|
|
||||||
data-tip="Edit"
|
|
||||||
className="tooltip cursor-pointer"
|
|
||||||
onClick={() => setEditingGroup(row.original)}
|
|
||||||
>
|
|
||||||
<BsPencil className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
<BsPencil className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(!row.original.disableEditing ||
|
{(!row.original.disableEditing || ["developer", "admin"].includes(user.type)) && (
|
||||||
["developer", "admin"].includes(user.type)) && (
|
<div data-tip="Delete" className="tooltip cursor-pointer" onClick={() => deleteGroup(row.original)}>
|
||||||
<div
|
|
||||||
data-tip="Delete"
|
|
||||||
className="tooltip cursor-pointer"
|
|
||||||
onClick={() => deleteGroup(row.original)}
|
|
||||||
>
|
|
||||||
<BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
<BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -335,11 +284,7 @@ export default function GroupList({ user }: { user: User }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full rounded-xl">
|
<div className="h-full w-full rounded-xl">
|
||||||
<Modal
|
<Modal isOpen={isCreating || !!editingGroup} onClose={closeModal} title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}>
|
||||||
isOpen={isCreating || !!editingGroup}
|
|
||||||
onClose={closeModal}
|
|
||||||
title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}
|
|
||||||
>
|
|
||||||
<CreatePanel
|
<CreatePanel
|
||||||
group={editingGroup}
|
group={editingGroup}
|
||||||
user={user}
|
user={user}
|
||||||
@@ -351,8 +296,7 @@ export default function GroupList({ user }: { user: User }) {
|
|||||||
groups
|
groups
|
||||||
.filter((g) => g.admin === user.id)
|
.filter((g) => g.admin === user.id)
|
||||||
.flatMap((g) => g.participants)
|
.flatMap((g) => g.participants)
|
||||||
.includes(u.id) ||
|
.includes(u.id) || groups.flatMap((g) => g.participants).includes(u.id),
|
||||||
groups.flatMap((g) => g.participants).includes(u.id),
|
|
||||||
)
|
)
|
||||||
: users
|
: users
|
||||||
}
|
}
|
||||||
@@ -364,12 +308,7 @@ export default function GroupList({ user }: { user: User }) {
|
|||||||
<tr key={headerGroup.id}>
|
<tr key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => (
|
{headerGroup.headers.map((header) => (
|
||||||
<th className="py-4" key={header.id}>
|
<th className="py-4" key={header.id}>
|
||||||
{header.isPlaceholder
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
? null
|
|
||||||
: flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext(),
|
|
||||||
)}
|
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -377,10 +316,7 @@ export default function GroupList({ user }: { user: User }) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="px-2">
|
<tbody className="px-2">
|
||||||
{table.getRowModel().rows.map((row) => (
|
{table.getRowModel().rows.map((row) => (
|
||||||
<tr
|
<tr className="even:bg-mti-purple-ultralight/40 rounded-lg py-2 odd:bg-white" key={row.id}>
|
||||||
className="even:bg-mti-purple-ultralight/40 rounded-lg py-2 odd:bg-white"
|
|
||||||
key={row.id}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<td className="px-4 py-2" key={cell.id}>
|
<td className="px-4 py-2" key={cell.id}>
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
@@ -393,8 +329,7 @@ export default function GroupList({ user }: { user: User }) {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsCreating(true)}
|
onClick={() => setIsCreating(true)}
|
||||||
className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out"
|
className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out">
|
||||||
>
|
|
||||||
New Group
|
New Group
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Button from "@/components/Low/Button";
|
|||||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {Type, User, userTypes, CorporateUser} from "@/interfaces/user";
|
import {Type, User, userTypes, CorporateUser, Group} from "@/interfaces/user";
|
||||||
import {Popover, Transition} from "@headlessui/react";
|
import {Popover, Transition} from "@headlessui/react";
|
||||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -16,18 +16,30 @@ import {countries, TCountries} from "countries-list";
|
|||||||
import countryCodes from "country-codes-list";
|
import countryCodes from "country-codes-list";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import UserCard from "@/components/UserCard";
|
import UserCard from "@/components/UserCard";
|
||||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
import {getUserCompanyName, isAgentUser, USER_TYPE_LABELS} from "@/resources/user";
|
||||||
import useFilterStore from "@/stores/listFilterStore";
|
import useFilterStore from "@/stores/listFilterStore";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {isCorporateUser} from '@/resources/user';
|
import {isCorporateUser} from "@/resources/user";
|
||||||
import {useListSearch} from "@/hooks/useListSearch";
|
import {useListSearch} from "@/hooks/useListSearch";
|
||||||
|
import {getUserCorporate} from "@/utils/groups";
|
||||||
|
import {asyncSorter} from "@/utils";
|
||||||
|
import {exportListToExcel, UserListRow} from "@/utils/users";
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<User>();
|
const columnHelper = createColumnHelper<User>();
|
||||||
const searchFields = [
|
const searchFields = [["name"], ["email"], ["corporateInformation", "companyInformation", "name"]];
|
||||||
['name'],
|
|
||||||
['email'],
|
const CompanyNameCell = ({users, user, groups}: {user: User; users: User[]; groups: Group[]}) => {
|
||||||
['corporateInformation', 'companyInformation', 'name'],
|
const [companyName, setCompanyName] = useState("");
|
||||||
];
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const name = getUserCompanyName(user, users, groups);
|
||||||
|
setCompanyName(name);
|
||||||
|
}, [user, users, groups]);
|
||||||
|
|
||||||
|
return isLoading ? <span className="animate-pulse">Loading...</span> : <>{companyName}</>;
|
||||||
|
};
|
||||||
|
|
||||||
export default function UserList({user, filters = []}: {user: User; filters?: ((user: User) => boolean)[]}) {
|
export default function UserList({user, filters = []}: {user: User; filters?: ((user: User) => boolean)[]}) {
|
||||||
const [showDemographicInformation, setShowDemographicInformation] = useState(false);
|
const [showDemographicInformation, setShowDemographicInformation] = useState(false);
|
||||||
const [sorter, setSorter] = useState<string>();
|
const [sorter, setSorter] = useState<string>();
|
||||||
@@ -51,6 +63,7 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
if (user && users) {
|
if (user && users) {
|
||||||
const filterUsers =
|
const filterUsers =
|
||||||
user.type === "corporate" || user.type === "teacher"
|
user.type === "corporate" || user.type === "teacher"
|
||||||
@@ -58,9 +71,12 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
|
|||||||
: users;
|
: users;
|
||||||
|
|
||||||
const filteredUsers = filters.reduce((d, f) => d.filter(f), filterUsers);
|
const filteredUsers = filters.reduce((d, f) => d.filter(f), filterUsers);
|
||||||
|
const sortedUsers = await asyncSorter<User>(filteredUsers, sortFunction);
|
||||||
|
console.log(sortedUsers);
|
||||||
|
|
||||||
setDisplayUsers([...filteredUsers.sort(sortFunction)]);
|
setDisplayUsers([...sortedUsers]);
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [user, users, sorter, groups]);
|
}, [user, users, sorter, groups]);
|
||||||
|
|
||||||
@@ -331,14 +347,14 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
|
|||||||
) as any,
|
) as any,
|
||||||
cell: (info) => USER_TYPE_LABELS[info.getValue()],
|
cell: (info) => USER_TYPE_LABELS[info.getValue()],
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor('corporateInformation.companyInformation.name', {
|
columnHelper.accessor("corporateInformation.companyInformation.name", {
|
||||||
header: (
|
header: (
|
||||||
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "companyName"))}>
|
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "companyName"))}>
|
||||||
<span>Company Name</span>
|
<span>Company Name</span>
|
||||||
<SorterArrow name="companyName" />
|
<SorterArrow name="companyName" />
|
||||||
</button>
|
</button>
|
||||||
) as any,
|
) as any,
|
||||||
cell: (info) => getCorporateName(info.row.original),
|
cell: (info) => <CompanyNameCell user={info.row.original} users={users} groups={groups} />,
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("subscriptionExpirationDate", {
|
columnHelper.accessor("subscriptionExpirationDate", {
|
||||||
header: (
|
header: (
|
||||||
@@ -393,15 +409,7 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCorporateName = (user: User) => {
|
const sortFunction = async (a: User, b: User) => {
|
||||||
if(isCorporateUser(user)) {
|
|
||||||
return user.corporateInformation?.companyInformation?.name
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortFunction = (a: User, b: User) => {
|
|
||||||
if (sorter === "name" || sorter === reverseString("name"))
|
if (sorter === "name" || sorter === reverseString("name"))
|
||||||
return sorter === "name" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name);
|
return sorter === "name" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name);
|
||||||
|
|
||||||
@@ -468,25 +476,20 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
|
|||||||
: b.demographicInformation!.gender.localeCompare(a.demographicInformation!.gender);
|
: b.demographicInformation!.gender.localeCompare(a.demographicInformation!.gender);
|
||||||
}
|
}
|
||||||
|
|
||||||
if(sorter === 'companyName' || sorter === reverseString('companyName')) {
|
if (sorter === "companyName" || sorter === reverseString("companyName")) {
|
||||||
const aCorporateName = getCorporateName(a);
|
const aCorporateName = getUserCompanyName(a, users, groups);
|
||||||
const bCorporateName = getCorporateName(b);
|
const bCorporateName = getUserCompanyName(b, users, groups);
|
||||||
if (!aCorporateName && bCorporateName) return sorter === "companyName" ? -1 : 1;
|
if (!aCorporateName && bCorporateName) return sorter === "companyName" ? -1 : 1;
|
||||||
if (aCorporateName && !bCorporateName) return sorter === "companyName" ? 1 : -1;
|
if (aCorporateName && !bCorporateName) return sorter === "companyName" ? 1 : -1;
|
||||||
if (!aCorporateName && !bCorporateName) return 0;
|
if (!aCorporateName && !bCorporateName) return 0;
|
||||||
|
|
||||||
return sorter === "companyName"
|
return sorter === "companyName" ? aCorporateName.localeCompare(bCorporateName) : bCorporateName.localeCompare(aCorporateName);
|
||||||
? aCorporateName.localeCompare(bCorporateName)
|
|
||||||
: bCorporateName.localeCompare(aCorporateName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return a.id.localeCompare(b.id);
|
return a.id.localeCompare(b.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const { rows: filteredRows, renderSearch } = useListSearch(
|
const {rows: filteredRows, renderSearch} = useListSearch<User>(searchFields, displayUsers);
|
||||||
searchFields,
|
|
||||||
displayUsers,
|
|
||||||
)
|
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: filteredRows,
|
data: filteredRows,
|
||||||
@@ -494,6 +497,18 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
|
|||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const downloadExcel = () => {
|
||||||
|
const csv = exportListToExcel(filteredRows, users, groups);
|
||||||
|
|
||||||
|
const element = document.createElement("a");
|
||||||
|
const file = new Blob([csv], {type: "text/plain"});
|
||||||
|
element.href = URL.createObjectURL(file);
|
||||||
|
element.download = "users.xlsx";
|
||||||
|
document.body.appendChild(element);
|
||||||
|
element.click();
|
||||||
|
document.body.removeChild(element);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Modal isOpen={!!selectedUser} onClose={() => setSelectedUser(undefined)}>
|
<Modal isOpen={!!selectedUser} onClose={() => setSelectedUser(undefined)}>
|
||||||
@@ -573,7 +588,12 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
|
|||||||
</>
|
</>
|
||||||
</Modal>
|
</Modal>
|
||||||
<div className="w-full flex flex-col gap-2">
|
<div className="w-full flex flex-col gap-2">
|
||||||
|
<div className="w-full flex gap-2 items-end">
|
||||||
{renderSearch()}
|
{renderSearch()}
|
||||||
|
<Button className="w-full max-w-[200px] mb-1" variant="outline" onClick={downloadExcel}>
|
||||||
|
Download List
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||||
<thead>
|
<thead>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
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";
|
||||||
@@ -16,9 +18,12 @@ export default function Lists({user}: {user: User}) {
|
|||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
|
selected
|
||||||
|
? "bg-white shadow"
|
||||||
|
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
|
||||||
)
|
)
|
||||||
}>
|
}
|
||||||
|
>
|
||||||
User List
|
User List
|
||||||
</Tab>
|
</Tab>
|
||||||
{user?.type === "developer" && (
|
{user?.type === "developer" && (
|
||||||
@@ -28,9 +33,12 @@ export default function Lists({user}: {user: User}) {
|
|||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
|
selected
|
||||||
|
? "bg-white shadow"
|
||||||
|
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
|
||||||
)
|
)
|
||||||
}>
|
}
|
||||||
|
>
|
||||||
Exam List
|
Exam List
|
||||||
</Tab>
|
</Tab>
|
||||||
)}
|
)}
|
||||||
@@ -40,11 +48,30 @@ export default function Lists({user}: {user: User}) {
|
|||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
|
selected
|
||||||
|
? "bg-white shadow"
|
||||||
|
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
|
||||||
)
|
)
|
||||||
}>
|
}
|
||||||
|
>
|
||||||
Group List
|
Group List
|
||||||
</Tab>
|
</Tab>
|
||||||
|
{user && ["developer", "admin", "corporate"].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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Code List
|
||||||
|
</Tab>
|
||||||
|
)}
|
||||||
{user && ["developer", "admin"].includes(user.type) && (
|
{user && ["developer", "admin"].includes(user.type) && (
|
||||||
<Tab
|
<Tab
|
||||||
className={({ selected }) =>
|
className={({ selected }) =>
|
||||||
@@ -52,12 +79,31 @@ export default function Lists({user}: {user: User}) {
|
|||||||
"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",
|
||||||
)
|
)
|
||||||
}>
|
}
|
||||||
|
>
|
||||||
Package List
|
Package List
|
||||||
</Tab>
|
</Tab>
|
||||||
)}
|
)}
|
||||||
|
{user && ["developer", "admin"].includes(user.type) && (
|
||||||
|
<Tab
|
||||||
|
className={({ selected }) =>
|
||||||
|
clsx(
|
||||||
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
|
||||||
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
selected
|
||||||
|
? "bg-white shadow"
|
||||||
|
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Discount List
|
||||||
|
</Tab>
|
||||||
|
)}
|
||||||
</Tab.List>
|
</Tab.List>
|
||||||
<Tab.Panels className="mt-2">
|
<Tab.Panels className="mt-2">
|
||||||
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
||||||
@@ -71,11 +117,21 @@ export default function Lists({user}: {user: User}) {
|
|||||||
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
||||||
<GroupList user={user} />
|
<GroupList user={user} />
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
|
{user && ["developer", "admin", "corporate"].includes(user.type) && (
|
||||||
|
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
||||||
|
<CodeList user={user} />
|
||||||
|
</Tab.Panel>
|
||||||
|
)}
|
||||||
{user && ["developer", "admin"].includes(user.type) && (
|
{user && ["developer", "admin"].includes(user.type) && (
|
||||||
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
||||||
<PackageList user={user} />
|
<PackageList user={user} />
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
)}
|
)}
|
||||||
|
{user && ["developer", "admin"].includes(user.type) && (
|
||||||
|
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
||||||
|
<DiscountList user={user} />
|
||||||
|
</Tab.Panel>
|
||||||
|
)}
|
||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ 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 {
|
||||||
|
evaluateSpeakingAnswer,
|
||||||
|
evaluateWritingAnswer,
|
||||||
|
} from "@/utils/evaluation";
|
||||||
import { defaultExamUserSolutions, getExam } from "@/utils/exams";
|
import { defaultExamUserSolutions, getExam } from "@/utils/exams";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
@@ -34,13 +37,17 @@ export default function ExamPage({page}: Props) {
|
|||||||
const [hasBeenUploaded, setHasBeenUploaded] = useState(false);
|
const [hasBeenUploaded, setHasBeenUploaded] = useState(false);
|
||||||
const [showAbandonPopup, setShowAbandonPopup] = useState(false);
|
const [showAbandonPopup, setShowAbandonPopup] = useState(false);
|
||||||
const [isEvaluationLoading, setIsEvaluationLoading] = useState(false);
|
const [isEvaluationLoading, setIsEvaluationLoading] = useState(false);
|
||||||
const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<string[]>([]);
|
const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<
|
||||||
|
string[]
|
||||||
|
>([]);
|
||||||
const [timeSpent, setTimeSpent] = useState(0);
|
const [timeSpent, setTimeSpent] = useState(0);
|
||||||
|
|
||||||
const resetStore = useExamStore((state) => state.reset);
|
const resetStore = useExamStore((state) => state.reset);
|
||||||
const assignment = useExamStore((state) => state.assignment);
|
const assignment = useExamStore((state) => state.assignment);
|
||||||
const initialTimeSpent = useExamStore((state) => state.timeSpent);
|
const initialTimeSpent = useExamStore((state) => state.timeSpent);
|
||||||
|
|
||||||
|
const examStore = useExamStore;
|
||||||
|
|
||||||
const { exam, setExam } = useExamStore((state) => state);
|
const { exam, setExam } = useExamStore((state) => state);
|
||||||
const { exams, setExams } = useExamStore((state) => state);
|
const { exams, setExams } = useExamStore((state) => state);
|
||||||
const { sessionId, setSessionId } = useExamStore((state) => state);
|
const { sessionId, setSessionId } = useExamStore((state) => state);
|
||||||
@@ -50,7 +57,9 @@ export default function ExamPage({page}: Props) {
|
|||||||
const { exerciseIndex, setExerciseIndex } = useExamStore((state) => state);
|
const { exerciseIndex, setExerciseIndex } = useExamStore((state) => state);
|
||||||
const { userSolutions, setUserSolutions } = useExamStore((state) => state);
|
const { userSolutions, setUserSolutions } = useExamStore((state) => state);
|
||||||
const { showSolutions, setShowSolutions } = useExamStore((state) => state);
|
const { showSolutions, setShowSolutions } = useExamStore((state) => state);
|
||||||
const {selectedModules, setSelectedModules} = useExamStore((state) => state);
|
const { selectedModules, setSelectedModules } = useExamStore(
|
||||||
|
(state) => state,
|
||||||
|
);
|
||||||
|
|
||||||
const { user } = useUser({ redirectTo: "/login" });
|
const { user } = useUser({ redirectTo: "/login" });
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -88,7 +97,10 @@ export default function ExamPage({page}: Props) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => setTimeSpent((prev) => prev + initialTimeSpent), [initialTimeSpent]);
|
useEffect(
|
||||||
|
() => setTimeSpent((prev) => prev + initialTimeSpent),
|
||||||
|
[initialTimeSpent],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userSolutions.length === 0 && exams.length > 0) {
|
if (userSolutions.length === 0 && exams.length > 0) {
|
||||||
@@ -110,10 +122,28 @@ export default function ExamPage({page}: Props) {
|
|||||||
)
|
)
|
||||||
saveSession();
|
saveSession();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [assignment, exam, exams, moduleIndex, selectedModules, sessionId, userSolutions, user, exerciseIndex, partIndex, questionIndex]);
|
}, [
|
||||||
|
assignment,
|
||||||
|
exam,
|
||||||
|
exams,
|
||||||
|
moduleIndex,
|
||||||
|
selectedModules,
|
||||||
|
sessionId,
|
||||||
|
userSolutions,
|
||||||
|
user,
|
||||||
|
exerciseIndex,
|
||||||
|
partIndex,
|
||||||
|
questionIndex,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (timeSpent % 20 === 0 && timeSpent > 0 && moduleIndex < selectedModules.length && !showSolutions) saveSession();
|
if (
|
||||||
|
timeSpent % 20 === 0 &&
|
||||||
|
timeSpent > 0 &&
|
||||||
|
moduleIndex < selectedModules.length &&
|
||||||
|
!showSolutions
|
||||||
|
)
|
||||||
|
saveSession();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [timeSpent]);
|
}, [timeSpent]);
|
||||||
|
|
||||||
@@ -147,11 +177,20 @@ export default function ExamPage({page}: Props) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) {
|
if (
|
||||||
|
selectedModules.length > 0 &&
|
||||||
|
exams.length > 0 &&
|
||||||
|
moduleIndex < selectedModules.length
|
||||||
|
) {
|
||||||
const nextExam = exams[moduleIndex];
|
const nextExam = exams[moduleIndex];
|
||||||
|
|
||||||
if (partIndex === -1 && nextExam.module !== "listening") setPartIndex(0);
|
if (partIndex === -1 && nextExam.module !== "listening")
|
||||||
if (exerciseIndex === -1 && !["reading", "listening"].includes(nextExam.module)) setExerciseIndex(0);
|
setPartIndex(0);
|
||||||
|
if (
|
||||||
|
exerciseIndex === -1 &&
|
||||||
|
!["reading", "listening"].includes(nextExam?.module)
|
||||||
|
)
|
||||||
|
setExerciseIndex(0);
|
||||||
setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined);
|
setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -166,7 +205,9 @@ export default function ExamPage({page}: Props) {
|
|||||||
module,
|
module,
|
||||||
avoidRepeated,
|
avoidRepeated,
|
||||||
variant,
|
variant,
|
||||||
user?.type === "student" || user?.type === "developer" ? user.preferredGender : undefined,
|
user?.type === "student" || user?.type === "developer"
|
||||||
|
? user.preferredGender
|
||||||
|
: undefined,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
Promise.all(examPromises).then((values) => {
|
Promise.all(examPromises).then((values) => {
|
||||||
@@ -183,16 +224,23 @@ export default function ExamPage({page}: Props) {
|
|||||||
}, [selectedModules, setExams, exams]);
|
}, [selectedModules, setExams, exams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedModules.length > 0 && exams.length !== 0 && moduleIndex >= selectedModules.length && !hasBeenUploaded && !showSolutions) {
|
if (
|
||||||
|
selectedModules.length > 0 &&
|
||||||
|
exams.length !== 0 &&
|
||||||
|
moduleIndex >= selectedModules.length &&
|
||||||
|
!hasBeenUploaded &&
|
||||||
|
!showSolutions
|
||||||
|
) {
|
||||||
const newStats: Stat[] = userSolutions.map((solution) => ({
|
const newStats: Stat[] = userSolutions.map((solution) => ({
|
||||||
...solution,
|
...solution,
|
||||||
id: solution.id || uuidv4(),
|
id: solution.id || uuidv4(),
|
||||||
timeSpent,
|
timeSpent,
|
||||||
session: sessionId,
|
session: sessionId,
|
||||||
exam: exam!.id,
|
exam: solution.exam!,
|
||||||
module: exam!.module,
|
module: solution.module!,
|
||||||
user: user?.id || "",
|
user: user?.id || "",
|
||||||
date: new Date().getTime(),
|
date: new Date().getTime(),
|
||||||
|
isDisabled: solution.isDisabled,
|
||||||
...(assignment ? { assignment: assignment.id } : {}),
|
...(assignment ? { assignment: assignment.id } : {}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -217,10 +265,18 @@ export default function ExamPage({page}: Props) {
|
|||||||
|
|
||||||
const checkIfStatsHaveBeenEvaluated = (ids: string[]) => {
|
const checkIfStatsHaveBeenEvaluated = (ids: string[]) => {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
const awaitedStats = await Promise.all(ids.map(async (id) => (await axios.get<Stat>(`/api/stats/${id}`)).data));
|
try {
|
||||||
const solutionsEvaluated = awaitedStats.every((stat) => stat.solutions.every((x) => x.evaluation !== null));
|
const awaitedStats = await Promise.all(
|
||||||
|
ids.map(
|
||||||
|
async (id) => (await axios.get<Stat>(`/api/stats/${id}`)).data,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const solutionsEvaluated = awaitedStats.every((stat) =>
|
||||||
|
stat.solutions.every((x) => x.evaluation !== null),
|
||||||
|
);
|
||||||
if (solutionsEvaluated) {
|
if (solutionsEvaluated) {
|
||||||
const statsUserSolutions: UserSolution[] = awaitedStats.map((stat) => ({
|
const statsUserSolutions: UserSolution[] = awaitedStats.map(
|
||||||
|
(stat) => ({
|
||||||
id: stat.id,
|
id: stat.id,
|
||||||
exercise: stat.exercise,
|
exercise: stat.exercise,
|
||||||
score: stat.score,
|
score: stat.score,
|
||||||
@@ -228,18 +284,26 @@ export default function ExamPage({page}: Props) {
|
|||||||
type: stat.type,
|
type: stat.type,
|
||||||
exam: stat.exam,
|
exam: stat.exam,
|
||||||
module: stat.module,
|
module: stat.module,
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const updatedUserSolutions = userSolutions.map((x) => {
|
const updatedUserSolutions = userSolutions.map((x) => {
|
||||||
const respectiveSolution = statsUserSolutions.find((y) => y.exercise === x.exercise);
|
const respectiveSolution = statsUserSolutions.find(
|
||||||
|
(y) => y.exercise === x.exercise,
|
||||||
|
);
|
||||||
return respectiveSolution ? respectiveSolution : x;
|
return respectiveSolution ? respectiveSolution : x;
|
||||||
});
|
});
|
||||||
|
|
||||||
setUserSolutions(updatedUserSolutions);
|
setUserSolutions(updatedUserSolutions);
|
||||||
return setStatsAwaitingEvaluation((prev) => prev.filter((x) => !ids.includes(x)));
|
return setStatsAwaitingEvaluation((prev) =>
|
||||||
|
prev.filter((x) => !ids.includes(x)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return checkIfStatsHaveBeenEvaluated(ids);
|
return checkIfStatsHaveBeenEvaluated(ids);
|
||||||
|
} catch {
|
||||||
|
return checkIfStatsHaveBeenEvaluated(ids);
|
||||||
|
}
|
||||||
}, 5 * 1000);
|
}, 5 * 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -249,7 +313,8 @@ export default function ExamPage({page}: Props) {
|
|||||||
Object.assign(p, {
|
Object.assign(p, {
|
||||||
exercises: p.exercises.map((x) =>
|
exercises: p.exercises.map((x) =>
|
||||||
Object.assign(x, {
|
Object.assign(x, {
|
||||||
userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions,
|
userSolutions: userSolutions.find((y) => x.id === y.exercise)
|
||||||
|
?.solutions,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
@@ -259,44 +324,74 @@ export default function ExamPage({page}: Props) {
|
|||||||
|
|
||||||
const exercises = exam.exercises.map((x) =>
|
const exercises = exam.exercises.map((x) =>
|
||||||
Object.assign(x, {
|
Object.assign(x, {
|
||||||
userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions,
|
userSolutions: userSolutions.find((y) => x.id === y.exercise)
|
||||||
|
?.solutions,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return Object.assign(exam, { exercises });
|
return Object.assign(exam, { exercises });
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFinish = (solutions: UserSolution[]) => {
|
const onFinish = async (solutions: UserSolution[]) => {
|
||||||
const solutionIds = solutions.map((x) => x.exercise);
|
const solutionIds = solutions.map((x) => x.exercise);
|
||||||
const solutionExams = solutions.map((x) => x.exam);
|
const solutionExams = solutions.map((x) => x.exam);
|
||||||
|
|
||||||
|
let newSolutions = [...solutions];
|
||||||
|
|
||||||
if (exam && !solutionExams.includes(exam.id)) return;
|
if (exam && !solutionExams.includes(exam.id)) return;
|
||||||
|
|
||||||
if (exam && (exam.module === "writing" || exam.module === "speaking") && solutions.length > 0 && !showSolutions) {
|
if (
|
||||||
|
exam &&
|
||||||
|
(exam.module === "writing" || exam.module === "speaking") &&
|
||||||
|
solutions.length > 0 &&
|
||||||
|
!showSolutions
|
||||||
|
) {
|
||||||
setHasBeenUploaded(true);
|
setHasBeenUploaded(true);
|
||||||
setIsEvaluationLoading(true);
|
setIsEvaluationLoading(true);
|
||||||
|
|
||||||
Promise.all(
|
const responses: UserSolution[] = (
|
||||||
exam.exercises.map(async (exercise) => {
|
await Promise.all(
|
||||||
|
exam.exercises.map(async (exercise, index) => {
|
||||||
const evaluationID = uuidv4();
|
const evaluationID = uuidv4();
|
||||||
if (exercise.type === "writing")
|
if (exercise.type === "writing")
|
||||||
return await evaluateWritingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!, evaluationID);
|
return await evaluateWritingAnswer(
|
||||||
|
exercise,
|
||||||
|
index + 1,
|
||||||
|
solutions.find((x) => x.exercise === exercise.id)!,
|
||||||
|
evaluationID,
|
||||||
|
);
|
||||||
|
|
||||||
if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking")
|
if (
|
||||||
return await evaluateSpeakingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!, evaluationID);
|
exercise.type === "interactiveSpeaking" ||
|
||||||
|
exercise.type === "speaking"
|
||||||
|
)
|
||||||
|
return await evaluateSpeakingAnswer(
|
||||||
|
exercise,
|
||||||
|
solutions.find((x) => x.exercise === exercise.id)!,
|
||||||
|
evaluationID,
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.then((responses) => {
|
).filter((x) => !!x) as UserSolution[];
|
||||||
setStatsAwaitingEvaluation((prev) => [...prev, ...responses.filter((x) => !!x).map((r) => (r as any).id)]);
|
|
||||||
setUserSolutions([...userSolutions, ...responses.filter((x) => !!x)] as any);
|
newSolutions = [
|
||||||
})
|
...newSolutions.filter(
|
||||||
.finally(() => {
|
(x) => !responses.map((y) => y.exercise).includes(x.exercise),
|
||||||
|
),
|
||||||
|
...responses,
|
||||||
|
];
|
||||||
|
setStatsAwaitingEvaluation((prev) => [
|
||||||
|
...prev,
|
||||||
|
...responses.filter((x) => !!x).map((r) => (r as any).id),
|
||||||
|
]);
|
||||||
setHasBeenUploaded(false);
|
setHasBeenUploaded(false);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
axios.get("/api/stats/update");
|
axios.get("/api/stats/update");
|
||||||
|
|
||||||
setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...solutions]);
|
setUserSolutions([
|
||||||
|
...userSolutions.filter((x) => !solutionIds.includes(x.exercise)),
|
||||||
|
...newSolutions,
|
||||||
|
]);
|
||||||
setModuleIndex(moduleIndex + 1);
|
setModuleIndex(moduleIndex + 1);
|
||||||
|
|
||||||
setPartIndex(-1);
|
setPartIndex(-1);
|
||||||
@@ -304,7 +399,12 @@ export default function ExamPage({page}: Props) {
|
|||||||
setQuestionIndex(0);
|
setQuestionIndex(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const aggregateScoresByModule = (answers: UserSolution[]): {module: Module; total: number; missing: number; correct: number}[] => {
|
const aggregateScoresByModule = (): {
|
||||||
|
module: Module;
|
||||||
|
total: number;
|
||||||
|
missing: number;
|
||||||
|
correct: number;
|
||||||
|
}[] => {
|
||||||
const scores: {
|
const scores: {
|
||||||
[key in Module]: { total: number; missing: number; correct: number };
|
[key in Module]: { total: number; missing: number; correct: number };
|
||||||
} = {
|
} = {
|
||||||
@@ -335,9 +435,14 @@ export default function ExamPage({page}: Props) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
answers.forEach((x) => {
|
userSolutions.forEach((x) => {
|
||||||
const examModule =
|
const examModule =
|
||||||
x.module || (x.type === "writing" ? "writing" : x.type === "speaking" || x.type === "interactiveSpeaking" ? "speaking" : undefined);
|
x.module ||
|
||||||
|
(x.type === "writing"
|
||||||
|
? "writing"
|
||||||
|
: x.type === "speaking" || x.type === "interactiveSpeaking"
|
||||||
|
? "speaking"
|
||||||
|
: undefined);
|
||||||
|
|
||||||
scores[examModule!] = {
|
scores[examModule!] = {
|
||||||
total: scores[examModule!].total + x.score.total,
|
total: scores[examModule!].total + x.score.total,
|
||||||
@@ -374,36 +479,64 @@ export default function ExamPage({page}: Props) {
|
|||||||
isLoading={isEvaluationLoading}
|
isLoading={isEvaluationLoading}
|
||||||
user={user!}
|
user={user!}
|
||||||
modules={selectedModules}
|
modules={selectedModules}
|
||||||
onViewResults={() => {
|
onViewResults={(index?: number) => {
|
||||||
setShowSolutions(true);
|
setShowSolutions(true);
|
||||||
setModuleIndex(0);
|
setModuleIndex(index || 0);
|
||||||
setExerciseIndex(["reading", "listening"].includes(exams[0].module) ? -1 : 0);
|
setExerciseIndex(
|
||||||
|
["reading", "listening"].includes(exams[0].module) ? -1 : 0,
|
||||||
|
);
|
||||||
setPartIndex(exams[0].module === "listening" ? -1 : 0);
|
setPartIndex(exams[0].module === "listening" ? -1 : 0);
|
||||||
setExam(exams[0]);
|
setExam(exams[0]);
|
||||||
}}
|
}}
|
||||||
scores={aggregateScoresByModule(userSolutions)}
|
scores={aggregateScoresByModule()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exam && exam.module === "reading") {
|
if (exam && exam.module === "reading") {
|
||||||
return <Reading exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
|
return (
|
||||||
|
<Reading
|
||||||
|
exam={exam}
|
||||||
|
onFinish={onFinish}
|
||||||
|
showSolutions={showSolutions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exam && exam.module === "listening") {
|
if (exam && exam.module === "listening") {
|
||||||
return <Listening exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
|
return (
|
||||||
|
<Listening
|
||||||
|
exam={exam}
|
||||||
|
onFinish={onFinish}
|
||||||
|
showSolutions={showSolutions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exam && exam.module === "writing") {
|
if (exam && exam.module === "writing") {
|
||||||
return <Writing exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
|
return (
|
||||||
|
<Writing
|
||||||
|
exam={exam}
|
||||||
|
onFinish={onFinish}
|
||||||
|
showSolutions={showSolutions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exam && exam.module === "speaking") {
|
if (exam && exam.module === "speaking") {
|
||||||
return <Speaking exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
|
return (
|
||||||
|
<Speaking
|
||||||
|
exam={exam}
|
||||||
|
onFinish={onFinish}
|
||||||
|
showSolutions={showSolutions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exam && exam.module === "level") {
|
if (exam && exam.module === "level") {
|
||||||
return <Level exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
|
return (
|
||||||
|
<Level exam={exam} onFinish={onFinish} showSolutions={showSolutions} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>Loading...</>;
|
return <>Loading...</>;
|
||||||
@@ -416,8 +549,13 @@ export default function ExamPage({page}: Props) {
|
|||||||
<Layout
|
<Layout
|
||||||
user={user}
|
user={user}
|
||||||
className="justify-between"
|
className="justify-between"
|
||||||
focusMode={selectedModules.length !== 0 && !showSolutions && moduleIndex < selectedModules.length}
|
focusMode={
|
||||||
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}>
|
selectedModules.length !== 0 &&
|
||||||
|
!showSolutions &&
|
||||||
|
moduleIndex < selectedModules.length
|
||||||
|
}
|
||||||
|
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}
|
||||||
|
>
|
||||||
<>
|
<>
|
||||||
{renderScreen()}
|
{renderScreen()}
|
||||||
{!showSolutions && moduleIndex < selectedModules.length && (
|
{!showSolutions && moduleIndex < selectedModules.length && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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]));
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,14 +109,6 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
|
|||||||
To add to your use of EnCoach, please purchase one of the time packages available below:
|
To add to your use of EnCoach, please purchase one of the time packages available below:
|
||||||
</span>
|
</span>
|
||||||
<div className="flex w-full flex-wrap justify-center gap-8">
|
<div className="flex w-full flex-wrap justify-center gap-8">
|
||||||
<PayPalScriptProvider
|
|
||||||
options={{
|
|
||||||
clientId: clientID,
|
|
||||||
currency: "USD",
|
|
||||||
intent: "capture",
|
|
||||||
commit: true,
|
|
||||||
vault: true,
|
|
||||||
}}>
|
|
||||||
{packages.map((p) => (
|
{packages.map((p) => (
|
||||||
<div key={p.id} className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}>
|
<div key={p.id} className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}>
|
||||||
<div className="mb-2 flex flex-col items-start">
|
<div className="mb-2 flex flex-col items-start">
|
||||||
@@ -111,19 +121,35 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-col items-start gap-2">
|
<div className="flex w-full flex-col items-start gap-2">
|
||||||
|
{!appliedDiscount && (
|
||||||
<span className="text-2xl">
|
<span className="text-2xl">
|
||||||
{p.price}
|
{p.price}
|
||||||
{getSymbolFromCurrency(p.currency)}
|
{getSymbolFromCurrency(p.currency)}
|
||||||
</span>
|
</span>
|
||||||
<PayPalPayment
|
)}
|
||||||
|
{appliedDiscount && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-2xl line-through">
|
||||||
|
{p.price}
|
||||||
|
{getSymbolFromCurrency(p.currency)}
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl text-mti-red-light">
|
||||||
|
{(p.price - p.price * (appliedDiscount / 100)).toFixed(2)}
|
||||||
|
{getSymbolFromCurrency(p.currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<PaymobPayment
|
||||||
key={clientID}
|
key={clientID}
|
||||||
{...p}
|
user={user}
|
||||||
clientID={clientID}
|
setIsPaymentLoading={setIsLoading}
|
||||||
setIsLoading={setIsLoading}
|
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
setTimeout(reload, 500);
|
setTimeout(reload, 500);
|
||||||
}}
|
}}
|
||||||
trackingId={trackingId}
|
currency={p.currency}
|
||||||
|
duration={p.duration}
|
||||||
|
duration_unit={p.duration_unit}
|
||||||
|
price={+(p.price - p.price * (appliedDiscount / 100)).toFixed(2)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-start gap-1">
|
<div className="flex flex-col items-start gap-1">
|
||||||
@@ -136,7 +162,6 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</PayPalScriptProvider>
|
|
||||||
</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">
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ export function getServerSideProps({
|
|||||||
res: any;
|
res: any;
|
||||||
}) {
|
}) {
|
||||||
if (!query || !query.oobCode || !query.mode) {
|
if (!query || !query.oobCode || !query.mode) {
|
||||||
res.setHeader("location", "/login");
|
|
||||||
res.statusCode = 302;
|
|
||||||
res.end();
|
|
||||||
return {
|
return {
|
||||||
props: {},
|
redirect: {
|
||||||
|
destination: "/login",
|
||||||
|
permanent: false,
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,15 +40,7 @@ export function getServerSideProps({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Reset({
|
export default function Reset({code, mode, continueUrl}: {code: string; mode: string; continueUrl?: string}) {
|
||||||
code,
|
|
||||||
mode,
|
|
||||||
continueUrl,
|
|
||||||
}: {
|
|
||||||
code: string;
|
|
||||||
mode: string;
|
|
||||||
continueUrl?: string;
|
|
||||||
}) {
|
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
@@ -63,7 +55,7 @@ export default function Reset({
|
|||||||
if (mode === "signIn") {
|
if (mode === "signIn") {
|
||||||
axios
|
axios
|
||||||
.post<{ok: boolean}>("/api/reset/verify", {
|
.post<{ok: boolean}>("/api/reset/verify", {
|
||||||
email: continueUrl?.replace("https://platform.encoach.com/", ""),
|
email: continueUrl?.replace("https://platform.encoach.com/", "").replace("https://staging.encoach.com/", ""),
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.data.ok) {
|
if (response.data.ok) {
|
||||||
@@ -76,20 +68,14 @@ export default function Reset({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error(
|
toast.error("Something went wrong! Please make sure to click the link in your e-mail again and input the correct e-mail!", {
|
||||||
"Something went wrong! Please make sure to click the link in your e-mail again and input the correct e-mail!",
|
|
||||||
{
|
|
||||||
toastId: "verify-error",
|
toastId: "verify-error",
|
||||||
},
|
});
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error(
|
toast.error("Something went wrong! Please make sure to click the link in your e-mail again and input the correct e-mail!", {
|
||||||
"Something went wrong! Please make sure to click the link in your e-mail again and input the correct e-mail!",
|
|
||||||
{
|
|
||||||
toastId: "verify-error",
|
toastId: "verify-error",
|
||||||
},
|
});
|
||||||
);
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -112,16 +98,10 @@ export default function Reset({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error(
|
toast.error("Something went wrong! Please make sure to click the link in your e-mail again!", {toastId: "reset-error"});
|
||||||
"Something went wrong! Please make sure to click the link in your e-mail again!",
|
|
||||||
{ toastId: "reset-error" },
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error(
|
toast.error("Something went wrong! Please make sure to click the link in your e-mail again!", {toastId: "reset-error"});
|
||||||
"Something went wrong! Please make sure to click the link in your e-mail again!",
|
|
||||||
{ toastId: "reset-error" },
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
@@ -138,51 +118,24 @@ export default function Reset({
|
|||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
<section className="relative hidden h-full w-fit min-w-fit lg:flex">
|
<section className="relative hidden h-full w-fit min-w-fit lg:flex">
|
||||||
<div className="bg-mti-rose-light absolute z-10 h-full w-full bg-opacity-50" />
|
<div className="bg-mti-rose-light absolute z-10 h-full w-full bg-opacity-50" />
|
||||||
<img
|
<img src="/people-talking-tablet.png" alt="People smiling looking at a tablet" className="aspect-auto h-full" />
|
||||||
src="/people-talking-tablet.png"
|
|
||||||
alt="People smiling looking at a tablet"
|
|
||||||
className="aspect-auto h-full"
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
{mode === "resetPassword" && (
|
{mode === "resetPassword" && (
|
||||||
<section className="flex h-full w-full flex-col items-center justify-center gap-2">
|
<section className="flex h-full w-full flex-col items-center justify-center gap-2">
|
||||||
<div className="relative flex flex-col items-center gap-2">
|
<div className="relative flex flex-col items-center gap-2">
|
||||||
<img
|
<img src="/logo_title.png" alt="EnCoach's Logo" className="absolute -top-36 w-36 lg:-top-64 lg:w-64" />
|
||||||
src="/logo_title.png"
|
<h1 className="text-2xl font-bold lg:text-4xl">Reset your password</h1>
|
||||||
alt="EnCoach's Logo"
|
<p className="text-mti-gray-cool self-start text-sm font-normal lg:text-base">to your registered Email Address</p>
|
||||||
className="absolute -top-36 w-36 lg:-top-64 lg:w-64"
|
|
||||||
/>
|
|
||||||
<h1 className="text-2xl font-bold lg:text-4xl">
|
|
||||||
Reset your password
|
|
||||||
</h1>
|
|
||||||
<p className="text-mti-gray-cool self-start text-sm font-normal lg:text-base">
|
|
||||||
to your registered Email Address
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Divider className="max-w-xs lg:max-w-md" />
|
<Divider className="max-w-xs lg:max-w-md" />
|
||||||
<form
|
<form className="-lg:px-8 flex w-full flex-col items-center gap-6 lg:w-1/2" onSubmit={login}>
|
||||||
className="-lg:px-8 flex w-full flex-col items-center gap-6 lg:w-1/2"
|
<Input type="password" name="password" onChange={(e) => setPassword(e)} placeholder="Password" />
|
||||||
onSubmit={login}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
name="password"
|
|
||||||
onChange={(e) => setPassword(e)}
|
|
||||||
placeholder="Password"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
<Button className="mt-8 w-full" color="purple" disabled={isLoading}>
|
||||||
className="mt-8 w-full"
|
|
||||||
color="purple"
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{!isLoading && "Reset"}
|
{!isLoading && "Reset"}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<BsArrowRepeat
|
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
||||||
className="animate-spin text-white"
|
|
||||||
size={25}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -198,25 +151,15 @@ export default function Reset({
|
|||||||
{mode === "signIn" && (
|
{mode === "signIn" && (
|
||||||
<section className="flex h-full w-full flex-col items-center justify-center gap-2">
|
<section className="flex h-full w-full flex-col items-center justify-center gap-2">
|
||||||
<div className="relative flex flex-col items-center gap-2">
|
<div className="relative flex flex-col items-center gap-2">
|
||||||
<img
|
<img src="/logo_title.png" alt="EnCoach's Logo" className="absolute -top-36 w-36 lg:-top-64 lg:w-64" />
|
||||||
src="/logo_title.png"
|
<h1 className="text-2xl font-bold lg:text-4xl">Confirm your account</h1>
|
||||||
alt="EnCoach's Logo"
|
<p className="text-mti-gray-cool self-start text-sm font-normal lg:text-base">to your registered Email Address</p>
|
||||||
className="absolute -top-36 w-36 lg:-top-64 lg:w-64"
|
|
||||||
/>
|
|
||||||
<h1 className="text-2xl font-bold lg:text-4xl">
|
|
||||||
Confirm your account
|
|
||||||
</h1>
|
|
||||||
<p className="text-mti-gray-cool self-start text-sm font-normal lg:text-base">
|
|
||||||
to your registered Email Address
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Divider className="max-w-xs lg:max-w-md" />
|
<Divider className="max-w-xs lg:max-w-md" />
|
||||||
<div className="-lg:px-8 flex w-full flex-col items-center gap-6 lg:w-1/2">
|
<div className="-lg:px-8 flex w-full flex-col items-center gap-6 lg:w-1/2">
|
||||||
<span className="text-center">
|
<span className="text-center">
|
||||||
Your e-mail is currently being verified, please wait a second.{" "}
|
Your e-mail is currently being verified, please wait a second. <br /> <br />
|
||||||
<br /> <br />
|
Once it has been verified, you will be redirected to the home page.
|
||||||
Once it has been verified, you will be redirected to the home
|
|
||||||
page.
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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 || ""}
|
||||||
/>,
|
/>,
|
||||||
|
|||||||
33
src/pages/api/assignments/[id]/unarchive.tsx
Normal file
33
src/pages/api/assignments/[id]/unarchive.tsx
Normal 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});
|
||||||
|
}
|
||||||
@@ -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!",
|
||||||
|
|||||||
@@ -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});
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import {
|
|||||||
collection,
|
collection,
|
||||||
where,
|
where,
|
||||||
getDocs,
|
getDocs,
|
||||||
|
getDoc,
|
||||||
|
deleteDoc,
|
||||||
} from "firebase/firestore";
|
} from "firebase/firestore";
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import { Type } from "@/interfaces/user";
|
import { Code, Type } from "@/interfaces/user";
|
||||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||||
import { uuidv4 } from "@firebase/util";
|
import { uuidv4 } from "@firebase/util";
|
||||||
import { prepareMailer, prepareMailOptions } from "@/email";
|
import { prepareMailer, prepareMailOptions } from "@/email";
|
||||||
@@ -24,6 +26,7 @@ export default withIronSessionApiRoute(handler, sessionOptions);
|
|||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.method === "GET") return get(req, res);
|
if (req.method === "GET") return get(req, res);
|
||||||
if (req.method === "POST") return post(req, res);
|
if (req.method === "POST") return post(req, res);
|
||||||
|
if (req.method === "DELETE") return del(req, res);
|
||||||
|
|
||||||
return res.status(404).json({ ok: false });
|
return res.status(404).json({ ok: false });
|
||||||
}
|
}
|
||||||
@@ -37,7 +40,10 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { creator } = req.query as { creator?: string };
|
const { creator } = req.query as { creator?: string };
|
||||||
const q = query(collection(db, "codes"), where("creator", "==", creator));
|
const q = query(
|
||||||
|
collection(db, "codes"),
|
||||||
|
where("creator", "==", creator || ""),
|
||||||
|
);
|
||||||
const snapshot = await getDocs(creator ? q : collection(db, "codes"));
|
const snapshot = await getDocs(creator ? q : collection(db, "codes"));
|
||||||
|
|
||||||
res.status(200).json(snapshot.docs.map((doc) => doc.data()));
|
res.status(200).json(snapshot.docs.map((doc) => doc.data()));
|
||||||
@@ -60,9 +66,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const permission = PERMISSIONS.generateCode[type];
|
const permission = PERMISSIONS.generateCode[type];
|
||||||
|
|
||||||
if (!permission.includes(req.session.user.type)) {
|
if (!permission.includes(req.session.user.type)) {
|
||||||
res
|
res.status(403).json({
|
||||||
.status(403)
|
|
||||||
.json({
|
|
||||||
ok: false,
|
ok: false,
|
||||||
reason:
|
reason:
|
||||||
"Your account type does not have permissions to generate a code for that type of user!",
|
"Your account type does not have permissions to generate a code for that type of user!",
|
||||||
@@ -70,13 +74,14 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.session.user.type === "corporate") {
|
|
||||||
const codesGeneratedByUserSnapshot = await getDocs(
|
const codesGeneratedByUserSnapshot = await getDocs(
|
||||||
query(
|
query(collection(db, "codes"), where("creator", "==", req.session.user.id)),
|
||||||
collection(db, "codes"),
|
|
||||||
where("creator", "==", req.session.user.id),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
const userCodes = codesGeneratedByUserSnapshot.docs.map((x) => ({
|
||||||
|
...x.data(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (req.session.user.type === "corporate") {
|
||||||
const totalCodes = codesGeneratedByUserSnapshot.docs.length + codes.length;
|
const totalCodes = codesGeneratedByUserSnapshot.docs.length + codes.length;
|
||||||
const allowedCodes =
|
const allowedCodes =
|
||||||
req.session.user.corporateInformation?.companyInformation.userAmount || 0;
|
req.session.user.corporateInformation?.companyInformation.userAmount || 0;
|
||||||
@@ -94,21 +99,24 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const codePromises = codes.map(async (code, index) => {
|
const codePromises = codes.map(async (code, index) => {
|
||||||
const codeRef = doc(db, "codes", code);
|
const codeRef = doc(db, "codes", code);
|
||||||
const codeInformation = {
|
let codeInformation = {
|
||||||
type,
|
type,
|
||||||
code,
|
code,
|
||||||
creator: req.session.user!.id,
|
creator: req.session.user!.id,
|
||||||
|
creationDate: new Date().toISOString(),
|
||||||
expiryDate,
|
expiryDate,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (infos && infos.length > index) {
|
if (infos && infos.length > index) {
|
||||||
const { email, name, passport_id } = infos[index];
|
const { email, name, passport_id } = infos[index];
|
||||||
|
const previousCode = userCodes.find((x) => x.email === email) as Code;
|
||||||
|
|
||||||
const transport = prepareMailer();
|
const transport = prepareMailer();
|
||||||
const mailOptions = prepareMailOptions(
|
const mailOptions = prepareMailOptions(
|
||||||
{
|
{
|
||||||
type,
|
type,
|
||||||
code,
|
code: previousCode ? previousCode.code : code,
|
||||||
|
environment: process.env.ENVIRONMENT,
|
||||||
},
|
},
|
||||||
[email.toLowerCase().trim()],
|
[email.toLowerCase().trim()],
|
||||||
"EnCoach Registration",
|
"EnCoach Registration",
|
||||||
@@ -117,6 +125,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await transport.sendMail(mailOptions);
|
await transport.sendMail(mailOptions);
|
||||||
|
|
||||||
|
if (!previousCode) {
|
||||||
await setDoc(
|
await setDoc(
|
||||||
codeRef,
|
codeRef,
|
||||||
{
|
{
|
||||||
@@ -127,6 +137,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
},
|
},
|
||||||
{ merge: true },
|
{ merge: true },
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -141,3 +152,23 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
res.status(200).json({ ok: true, valid: results.filter((x) => x).length });
|
res.status(200).json({ ok: true, valid: results.filter((x) => x).length });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function del(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (!req.session.user) {
|
||||||
|
res
|
||||||
|
.status(401)
|
||||||
|
.json({ ok: false, reason: "You must be logged in to generate a code!" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const codes = req.query.code as string[];
|
||||||
|
|
||||||
|
for (const code of codes) {
|
||||||
|
const snapshot = await getDoc(doc(db, "codes", code as string));
|
||||||
|
if (!snapshot.exists()) continue;
|
||||||
|
|
||||||
|
await deleteDoc(snapshot.ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({ codes });
|
||||||
|
}
|
||||||
|
|||||||
94
src/pages/api/discounts/[id].ts
Normal file
94
src/pages/api/discounts/[id].ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/pages/api/discounts/index.ts
Normal file
81
src/pages/api/discounts/index.ts
Normal 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 });
|
||||||
|
}
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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}`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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()}` : ""}`, {
|
||||||
|
|||||||
@@ -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!`,
|
||||||
|
|||||||
@@ -1,17 +1,7 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
import {app} from "@/firebase";
|
||||||
import {
|
import {getFirestore, getDoc, doc, deleteDoc, setDoc, getDocs, collection, where, query} from "firebase/firestore";
|
||||||
getFirestore,
|
|
||||||
getDoc,
|
|
||||||
doc,
|
|
||||||
deleteDoc,
|
|
||||||
setDoc,
|
|
||||||
getDocs,
|
|
||||||
collection,
|
|
||||||
where,
|
|
||||||
query,
|
|
||||||
} 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 {Ticket} from "@/interfaces/ticket";
|
import {Ticket} from "@/interfaces/ticket";
|
||||||
@@ -41,8 +31,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
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));
|
||||||
@@ -57,6 +46,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: "decline",
|
decision: "decline",
|
||||||
|
environment: process.env.ENVIRONMENT,
|
||||||
},
|
},
|
||||||
[invitedBy.email],
|
[invitedBy.email],
|
||||||
`${req.session.user.name} has declined your invite!`,
|
`${req.session.user.name} has declined your invite!`,
|
||||||
|
|||||||
@@ -5,14 +5,7 @@ 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,
|
|
||||||
doc,
|
|
||||||
getDoc,
|
|
||||||
getDocs,
|
|
||||||
getFirestore,
|
|
||||||
setDoc,
|
|
||||||
} from "firebase/firestore";
|
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import ShortUniqueId from "short-unique-id";
|
import ShortUniqueId from "short-unique-id";
|
||||||
@@ -45,9 +38,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const body = req.body as 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});
|
||||||
@@ -64,10 +55,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
{
|
{
|
||||||
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],
|
[invited.email],
|
||||||
"You have been invited to a group!",
|
"You have been invited to a group!",
|
||||||
@@ -76,10 +65,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
console.log(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)
|
|
||||||
.length == 0
|
|
||||||
) {
|
|
||||||
const shortUID = new ShortUniqueId();
|
const shortUID = new ShortUniqueId();
|
||||||
await setDoc(doc(db, "invites", body.id || shortUID.randomUUID(8)), body);
|
await setDoc(doc(db, "invites", body.id || shortUID.randomUUID(8)), body);
|
||||||
}
|
}
|
||||||
|
|||||||
52
src/pages/api/paymob/index.ts
Normal file
52
src/pages/api/paymob/index.ts
Normal 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}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
95
src/pages/api/paymob/webhook.ts
Normal file
95
src/pages/api/paymob/webhook.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -42,21 +42,20 @@ 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: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
"PayPal-Client-Metadata-Id": trackingId,
|
"PayPal-Client-Metadata-Id": trackingId,
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
);
|
axios
|
||||||
|
.post(url, {}, headers)
|
||||||
|
.then(async (request) => {
|
||||||
if (request.data.status === "COMPLETED") {
|
if (request.data.status === "COMPLETED") {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
const subscriptionExpirationDate =
|
const subscriptionExpirationDate =
|
||||||
req.session.user.subscriptionExpirationDate;
|
user!.subscriptionExpirationDate;
|
||||||
const today = moment(new Date());
|
const today = moment(new Date());
|
||||||
const dateToBeAddedTo = !subscriptionExpirationDate
|
const dateToBeAddedTo = !subscriptionExpirationDate
|
||||||
? today
|
? today
|
||||||
@@ -64,9 +63,12 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
? moment(subscriptionExpirationDate)
|
? moment(subscriptionExpirationDate)
|
||||||
: today;
|
: today;
|
||||||
|
|
||||||
const updatedExpirationDate = dateToBeAddedTo.add(duration, duration_unit);
|
const updatedExpirationDate = dateToBeAddedTo.add(
|
||||||
|
duration,
|
||||||
|
duration_unit
|
||||||
|
);
|
||||||
await setDoc(
|
await setDoc(
|
||||||
doc(db, "users", req.session.user.id),
|
doc(db, "users", req.session.user!.id),
|
||||||
{
|
{
|
||||||
subscriptionExpirationDate: updatedExpirationDate.toISOString(),
|
subscriptionExpirationDate: updatedExpirationDate.toISOString(),
|
||||||
status: "active",
|
status: "active",
|
||||||
@@ -75,32 +77,32 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await setDoc(
|
await setDoc(doc(db, "paypalpayments", v4()), {
|
||||||
doc(db, 'paypalpayments', v4()),
|
|
||||||
{
|
|
||||||
orderId: id,
|
orderId: id,
|
||||||
userId: req.session.user.id,
|
userId: req.session.user!.id,
|
||||||
status: request.data.status,
|
status: request.data.status,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
value: request.data.purchase_units[0].payments.captures[0].amount.value,
|
value:
|
||||||
currency: request.data.purchase_units[0].payments.captures[0].amount.currency_code,
|
request.data.purchase_units[0].payments.captures[0].amount.value,
|
||||||
|
currency:
|
||||||
|
request.data.purchase_units[0].payments.captures[0].amount
|
||||||
|
.currency_code,
|
||||||
subscriptionDuration: duration,
|
subscriptionDuration: duration,
|
||||||
subscriptionDurationUnit: duration_unit,
|
subscriptionDurationUnit: duration_unit,
|
||||||
subscriptionExpirationDate: updatedExpirationDate.toISOString(),
|
subscriptionExpirationDate: updatedExpirationDate.toISOString(),
|
||||||
}
|
});
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to insert paypal payment!', err);
|
console.error("Failed to insert paypal payment!", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.type === "corporate") {
|
if (user!.type === "corporate") {
|
||||||
const snapshot = await getDocs(collection(db, "groups"));
|
const snapshot = await getDocs(collection(db, "groups"));
|
||||||
const groups: Group[] = (
|
const groups: Group[] = (
|
||||||
snapshot.docs.map((doc) => ({
|
snapshot.docs.map((doc) => ({
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
...doc.data(),
|
...doc.data(),
|
||||||
})) as Group[]
|
})) as Group[]
|
||||||
).filter((x) => x.admin === user.id);
|
).filter((x) => x.admin === user!.id);
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
groups
|
groups
|
||||||
@@ -123,10 +125,13 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return res.status(200).json({ ok: true });
|
return res.status(200).json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
res
|
res.status(404).json({
|
||||||
.status(404)
|
|
||||||
.json({
|
|
||||||
ok: false,
|
ok: false,
|
||||||
reason: "Order ID not found or purchase was not approved!",
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,49 +14,97 @@ 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")
|
||||||
|
return res.status(404).json({ ok: false, reason: "Method not supported!" });
|
||||||
if (!req.session.user) return res.status(401).json({ ok: false });
|
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 = {
|
||||||
{
|
|
||||||
purchase_units: [
|
|
||||||
{
|
|
||||||
amount: {
|
|
||||||
currency_code: currencyCode,
|
currency_code: currencyCode,
|
||||||
value: price.toString(),
|
value: price.toString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
purchase_units: [
|
||||||
|
{
|
||||||
|
invoice_id: `INV-${v4()}`,
|
||||||
|
amount: {
|
||||||
|
...amount,
|
||||||
|
breakdown: {
|
||||||
|
item_total: amount,
|
||||||
},
|
},
|
||||||
reference_id: v4(),
|
},
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: "Encoach Subscription",
|
||||||
|
quantity: "1",
|
||||||
|
category: "DIGITAL_GOODS",
|
||||||
|
unit_amount: amount,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
payment_source: {
|
payment_source: {
|
||||||
paypal: {
|
paypal: {
|
||||||
email_address: req.session.user.email || "",
|
email_address: req.session.user.email || "",
|
||||||
|
address: {
|
||||||
|
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: {
|
experience_context: {
|
||||||
payment_method_preference: "IMMEDIATE_PAYMENT_REQUIRED",
|
payment_method_preference: "IMMEDIATE_PAYMENT_REQUIRED",
|
||||||
locale: "en-US",
|
locale: "en-US",
|
||||||
landing_page: "LOGIN",
|
landing_page: "LOGIN",
|
||||||
shipping_preference: "NO_SHIPPING",
|
shipping_preference: "NO_SHIPPING",
|
||||||
user_action: "PAY_NOW",
|
user_action: "PAY_NOW",
|
||||||
|
brand_name: "Encoach",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
intent: "CAPTURE",
|
intent: "CAPTURE",
|
||||||
},
|
};
|
||||||
{
|
|
||||||
|
const headers = {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
'PayPal-Client-Metadata-Id': trackingId,
|
"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);
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
key: "user_id",
|
||||||
value: req.session.user.id,
|
value: req.session.user.id,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
};
|
||||||
{
|
|
||||||
|
const headers = {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
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" });
|
||||||
|
|||||||
@@ -3,22 +3,11 @@ 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,
|
|
||||||
setDoc,
|
|
||||||
query,
|
|
||||||
collection,
|
|
||||||
where,
|
|
||||||
getDocs,
|
|
||||||
} from "firebase/firestore";
|
|
||||||
import {
|
|
||||||
CorporateInformation,
|
|
||||||
DemographicInformation,
|
|
||||||
Type,
|
|
||||||
} from "@/interfaces/user";
|
|
||||||
import {addUserToGroupOnCreation} from "@/utils/registration";
|
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);
|
||||||
@@ -57,9 +46,7 @@ async function registerIndividual(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const codeQuery = query(collection(db, "codes"), where("code", "==", code));
|
const codeQuery = query(collection(db, "codes"), where("code", "==", code));
|
||||||
const codeDocs = (await getDocs(codeQuery)).docs.filter(
|
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!"});
|
||||||
@@ -89,14 +76,8 @@ async function registerIndividual(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
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
|
|
||||||
? codeData.type
|
|
||||||
: "student",
|
|
||||||
subscriptionExpirationDate: codeData
|
|
||||||
? codeData.expiryDate
|
|
||||||
: moment().subtract(1, "days").toISOString(),
|
|
||||||
...(passport_id ? {demographicInformation: {passport_id}} : {}),
|
...(passport_id ? {demographicInformation: {passport_id}} : {}),
|
||||||
registrationDate: new Date().toISOString(),
|
registrationDate: new Date().toISOString(),
|
||||||
status: code ? "active" : "paymentDue",
|
status: code ? "active" : "paymentDue",
|
||||||
@@ -106,12 +87,7 @@ async function registerIndividual(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
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};
|
||||||
@@ -151,7 +127,25 @@ async function registerCorporate(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
registrationDate: new Date().toISOString(),
|
registrationDate: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const defaultTeachersGroup: Group = {
|
||||||
|
admin: userId,
|
||||||
|
id: v4(),
|
||||||
|
name: "Teachers",
|
||||||
|
participants: [],
|
||||||
|
disableEditing: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultStudentsGroup: Group = {
|
||||||
|
admin: userId,
|
||||||
|
id: v4(),
|
||||||
|
name: "Students",
|
||||||
|
participants: [],
|
||||||
|
disableEditing: true,
|
||||||
|
};
|
||||||
|
|
||||||
await setDoc(doc(db, "users", userId), user);
|
await setDoc(doc(db, "users", userId), user);
|
||||||
|
await setDoc(doc(db, "groups", defaultTeachersGroup.id), defaultTeachersGroup);
|
||||||
|
await setDoc(doc(db, "groups", defaultStudentsGroup.id), defaultStudentsGroup);
|
||||||
|
|
||||||
req.session.user = {...user, id: userId};
|
req.session.user = {...user, id: userId};
|
||||||
await req.session.save();
|
await req.session.save();
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 || ""}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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});
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
import {app} from "@/firebase";
|
||||||
import {
|
import {getFirestore, getDoc, doc, deleteDoc, setDoc} from "firebase/firestore";
|
||||||
getFirestore,
|
|
||||||
getDoc,
|
|
||||||
doc,
|
|
||||||
deleteDoc,
|
|
||||||
setDoc,
|
|
||||||
} 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 {Ticket, TicketTypeLabel, TicketStatusLabel} from "@/interfaces/ticket";
|
import {Ticket, TicketTypeLabel, TicketStatusLabel} from "@/interfaces/ticket";
|
||||||
@@ -81,7 +75,7 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
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",
|
||||||
{
|
{
|
||||||
@@ -92,6 +86,7 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
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],
|
[data.reporter.email],
|
||||||
`Ticket ${id}: ${data.subject}`,
|
`Ticket ${id}: ${data.subject}`,
|
||||||
|
|||||||
@@ -3,15 +3,7 @@ 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,
|
|
||||||
doc,
|
|
||||||
getDocs,
|
|
||||||
getFirestore,
|
|
||||||
setDoc,
|
|
||||||
where,
|
|
||||||
query,
|
|
||||||
} from "firebase/firestore";
|
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
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";
|
||||||
@@ -100,9 +92,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
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],
|
[body.reporter.email],
|
||||||
`Ticket ${id}: ${body.subject}`
|
`Ticket ${id}: ${body.subject}`,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.log(e);
|
||||||
|
|||||||
29
src/pages/api/users/[id].ts
Normal file
29
src/pages/api/users/[id].ts
Normal 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 });
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user