Compare commits
198 Commits
feature/ex
...
ENCOA-38/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85b94512e9 | ||
|
|
906646ebce | ||
|
|
96108a4958 | ||
|
|
fb449f2054 | ||
|
|
d5ee3d9519 | ||
|
|
4e20ec6575 | ||
|
|
836b674076 | ||
|
|
5086c6fb09 | ||
|
|
489c9c3b7e | ||
|
|
e3ded29e77 | ||
|
|
16419a5584 | ||
|
|
3e3b24cc30 | ||
|
|
841698ba10 | ||
|
|
d50904611c | ||
|
|
e77fd16d26 | ||
|
|
649f24e4ae | ||
|
|
2f0cbfe74e | ||
|
|
d022bd078a | ||
|
|
c18afee9ad | ||
|
|
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 | ||
|
|
2a27fbd02f | ||
|
|
a86ed9f76c | ||
|
|
20b52d049d | ||
|
|
165e33b188 | ||
|
|
04cbcbc4cb | ||
|
|
2feb9223c1 | ||
|
|
02d2d07f6c | ||
|
|
ecd66d61f2 | ||
|
|
424b72efaf | ||
|
|
79e51d6294 | ||
|
|
773480875f | ||
|
|
96d1b85f56 | ||
|
|
cf20920fd8 | ||
|
|
4114971244 | ||
|
|
bee20388d9 | ||
|
|
bd97529658 | ||
|
|
d3c24d738c | ||
|
|
eac43a160d | ||
|
|
24c3f506c6 | ||
|
|
3e13ed5830 | ||
|
|
9b5ff70037 | ||
|
|
d7f1a4f6b2 | ||
|
|
b663e5c706 | ||
|
|
efb99b31f2 | ||
|
|
03882d2a7e | ||
|
|
a3087567ea | ||
|
|
e37afd5bbc | ||
|
|
cf5e827ca7 | ||
|
|
bfa9d039e2 | ||
|
|
62b915fbc1 | ||
|
|
cdfafb3eea | ||
|
|
29cae5c3d2 | ||
|
|
04f97b62c3 | ||
|
|
52d309e7f4 | ||
|
|
dbf5b17f64 | ||
|
|
703fb0df5f | ||
|
|
be4d2de76f | ||
|
|
44c61c2e5d | ||
|
|
764064bc28 | ||
|
|
d87de9fea9 | ||
|
|
b63ba3f316 | ||
|
|
64b1d9266e | ||
|
|
b7cd1fb141 | ||
|
|
30cb2f460c | ||
|
|
6a38b7a32e | ||
|
|
2a1b5236ee | ||
|
|
a99f6fd20e | ||
|
|
c0c9d22864 | ||
|
|
718782cfd5 | ||
|
|
f643430068 | ||
|
|
2823af7ef8 | ||
|
|
57116f50e8 | ||
|
|
e382a09ae8 | ||
|
|
b4c7c9a911 | ||
|
|
86e920f102 | ||
|
|
6f12a4a1db | ||
|
|
a27a3c1fb0 | ||
|
|
63618405bc | ||
|
|
7ab67fdf15 | ||
|
|
17ec004a59 | ||
|
|
417bd7fecb | ||
|
|
e82895351d | ||
|
|
4802310474 | ||
|
|
dc3373be6a | ||
|
|
2e894622d0 | ||
|
|
1895b9e183 | ||
|
|
03f78ceb46 | ||
|
|
872cc62fe4 | ||
|
|
ce7032c8a7 | ||
|
|
71f07af2eb |
@@ -1,4 +1,5 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const websiteUrl = process.env.NODE_ENV === 'production' ? "https://encoach.com" : "http://localhost:3000";
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
output: "standalone",
|
||||
@@ -8,7 +9,7 @@ const nextConfig = {
|
||||
source: "/api/packages",
|
||||
headers: [
|
||||
{key: "Access-Control-Allow-Credentials", value: "false"},
|
||||
{key: "Access-Control-Allow-Origin", value: "https://encoach.com"},
|
||||
{key: "Access-Control-Allow-Origin", value: websiteUrl},
|
||||
{
|
||||
key: "Access-Control-Allow-Methods",
|
||||
value: "GET",
|
||||
@@ -19,6 +20,36 @@ const nextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/api/tickets",
|
||||
headers: [
|
||||
{key: "Access-Control-Allow-Credentials", value: "false"},
|
||||
{key: "Access-Control-Allow-Origin", value: websiteUrl},
|
||||
{
|
||||
key: "Access-Control-Allow-Methods",
|
||||
value: "POST,OPTIONS",
|
||||
},
|
||||
{
|
||||
key: "Access-Control-Allow-Headers",
|
||||
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/api/users/agents",
|
||||
headers: [
|
||||
{key: "Access-Control-Allow-Credentials", value: "false"},
|
||||
{key: "Access-Control-Allow-Origin", value: websiteUrl},
|
||||
{
|
||||
key: "Access-Control-Allow-Methods",
|
||||
value: "POST,OPTIONS",
|
||||
},
|
||||
{
|
||||
key: "Access-Control-Allow-Headers",
|
||||
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@beam-australia/react-env": "^3.1.1",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@headlessui/react": "^1.7.13",
|
||||
"@mdi/js": "^7.1.96",
|
||||
"@mdi/react": "^1.6.1",
|
||||
|
||||
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 (
|
||||
<>
|
||||
<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) && (
|
||||
<WordsDrawer
|
||||
key={currentBlankId}
|
||||
|
||||
@@ -76,7 +76,7 @@ export default function InteractiveSpeaking({
|
||||
onBack({
|
||||
exercise: id,
|
||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
score: {correct: 100, total: 100, missing: 0},
|
||||
type,
|
||||
});
|
||||
};
|
||||
@@ -96,14 +96,13 @@ export default function InteractiveSpeaking({
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
score: {correct: 100, total: 100, missing: 0},
|
||||
type,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (userSolutions.length > 0 && answers.length === 0) {
|
||||
console.log(userSolutions);
|
||||
const solutions = userSolutions as unknown as typeof answers;
|
||||
setAnswers(solutions);
|
||||
|
||||
@@ -112,10 +111,6 @@ export default function InteractiveSpeaking({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userSolutions, mediaBlob, answers]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log({answers});
|
||||
}, [answers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (updateIndex) updateIndex(questionIndex);
|
||||
}, [questionIndex, updateIndex]);
|
||||
@@ -131,7 +126,7 @@ export default function InteractiveSpeaking({
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
score: {correct: 100, total: 100, missing: 0},
|
||||
type,
|
||||
});
|
||||
}
|
||||
@@ -176,7 +171,7 @@ export default function InteractiveSpeaking({
|
||||
{
|
||||
exercise: id,
|
||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
score: {correct: 100, total: 100, missing: 0},
|
||||
module: "speaking",
|
||||
exam: examID,
|
||||
type,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import {MatchSentencesExercise} from "@/interfaces/exam";
|
||||
import {MatchSentenceExerciseOption, MatchSentenceExerciseSentence, MatchSentencesExercise} from "@/interfaces/exam";
|
||||
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import clsx from "clsx";
|
||||
@@ -9,13 +9,74 @@ import {CommonProps} from ".";
|
||||
import Button from "../Low/Button";
|
||||
import Xarrow from "react-xarrows";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {DndContext, DragEndEvent, useDraggable, useDroppable} from "@dnd-kit/core";
|
||||
|
||||
function DroppableQuestionArea({question, answer}: {question: MatchSentenceExerciseSentence; answer?: string}) {
|
||||
const {isOver, setNodeRef} = useDroppable({id: `droppable_sentence_${question.id}`});
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-4" ref={setNodeRef}>
|
||||
<div className="flex items-center gap-3 cursor-pointer col-span-2">
|
||||
<button
|
||||
className={clsx(
|
||||
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
||||
"transition duration-300 ease-in-out",
|
||||
)}>
|
||||
{question.id}
|
||||
</button>
|
||||
<span>{question.sentence}</span>
|
||||
</div>
|
||||
<div
|
||||
key={`answer_${question.id}_${answer}`}
|
||||
className={clsx("w-48 h-10 border rounded-xl flex items-center justify-center", isOver && "border-mti-purple-light")}>
|
||||
{answer && `Paragraph ${answer}`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DraggableOptionArea({option}: {option: MatchSentenceExerciseOption}) {
|
||||
const {attributes, listeners, setNodeRef, transform} = useDraggable({
|
||||
id: `draggable_option_${option.id}`,
|
||||
});
|
||||
|
||||
const style = transform
|
||||
? {
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
zIndex: 99,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className={clsx("flex items-center justify-start gap-6 cursor-pointer")} ref={setNodeRef} style={style} {...listeners} {...attributes}>
|
||||
<button
|
||||
id={`option_${option.id}`}
|
||||
// onClick={() => selectOption(id)}
|
||||
className={clsx(
|
||||
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple px-3 py-2 rounded-full z-10",
|
||||
"transition duration-300 ease-in-out",
|
||||
option.id,
|
||||
)}>
|
||||
Paragraph {option.id}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MatchSentences({id, options, type, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) {
|
||||
const [selectedQuestion, setSelectedQuestion] = useState<string>();
|
||||
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
||||
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
if (event.over && event.over.id.toString().startsWith("droppable")) {
|
||||
const optionID = event.active.id.toString().replace("draggable_option_", "");
|
||||
const sentenceID = event.over.id.toString().replace("droppable_sentence_", "");
|
||||
|
||||
setAnswers((prev) => [...prev.filter((x) => x.question.toString() !== sentenceID), {question: sentenceID, option: optionID}]);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateScore = () => {
|
||||
const total = sentences.length;
|
||||
const correct = answers.filter(
|
||||
@@ -26,11 +87,9 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
||||
return {total, correct, missing};
|
||||
};
|
||||
|
||||
const selectOption = (option: string) => {
|
||||
if (!selectedQuestion) return;
|
||||
setAnswers((prev) => [...prev.filter((x) => x.question !== selectedQuestion), {question: selectedQuestion, option}]);
|
||||
setSelectedQuestion(undefined);
|
||||
};
|
||||
useEffect(() => {
|
||||
console.log(answers);
|
||||
}, [answers]);
|
||||
|
||||
useEffect(() => {
|
||||
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 (
|
||||
<>
|
||||
<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">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
@@ -48,46 +107,28 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
<div className="flex gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
{sentences.map(({sentence, id}) => (
|
||||
<div key={`question_${id}`} className="flex items-center justify-end gap-2 cursor-pointer">
|
||||
<span>{sentence} </span>
|
||||
<button
|
||||
id={id}
|
||||
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>
|
||||
|
||||
<DndContext onDragEnd={handleDragEnd}>
|
||||
<div className="flex flex-col gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
{sentences.map((question) => (
|
||||
<DroppableQuestionArea
|
||||
key={`question_${question.id}`}
|
||||
question={question}
|
||||
answer={answers.find((x) => x.question.toString() === question.id.toString())?.option}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<span>Drag one of these paragraphs into the slots above:</span>
|
||||
<div className="flex gap-4 flex-wrap justify-center items-center max-w-lg">
|
||||
{options.map((option) => (
|
||||
<DraggableOptionArea key={`answer_${option.id}`} option={option} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<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}
|
||||
onClick={() => selectOption(id)}
|
||||
className={clsx(
|
||||
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
||||
"transition duration-300 ease-in-out",
|
||||
id,
|
||||
)}>
|
||||
{id}
|
||||
</button>
|
||||
<span>{sentence}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{answers.map((solution, index) => (
|
||||
<Xarrow key={index} start={solution.question} end={solution.option} lineColor="#7872BF" showHead={false} />
|
||||
))}
|
||||
</div>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||
|
||||
@@ -7,6 +7,7 @@ import {CommonProps} from ".";
|
||||
import Button from "../Low/Button";
|
||||
|
||||
function Question({
|
||||
id,
|
||||
variant,
|
||||
prompt,
|
||||
options,
|
||||
@@ -15,7 +16,13 @@ function Question({
|
||||
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<span className="">{prompt}</span>
|
||||
{isNaN(Number(id)) ? (
|
||||
<span className="">{prompt}</span>
|
||||
) : (
|
||||
<span className="">
|
||||
{id} - {prompt}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-4 justify-between">
|
||||
{variant === "image" &&
|
||||
options.map((option) => (
|
||||
@@ -117,7 +124,7 @@ export default function MultipleChoice({
|
||||
|
||||
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>
|
||||
{questionIndex < questions.length && (
|
||||
<Question
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: storagePath ? [{id, solution: storagePath}] : [],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
score: {correct: 0, total: 100, missing: 0},
|
||||
type,
|
||||
});
|
||||
};
|
||||
@@ -94,7 +94,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
|
||||
onBack({
|
||||
exercise: id,
|
||||
solutions: storagePath ? [{id, solution: storagePath}] : [],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
score: {correct: 0, total: 100, missing: 0},
|
||||
type,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
||||
|
||||
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">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
|
||||
|
||||
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">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<span key={index}>
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function Writing({
|
||||
if (inputText.length > 0 && saveTimer % 10 === 0) {
|
||||
setUserSolutions([
|
||||
...storeUserSolutions.filter((x) => x.exercise !== id),
|
||||
{exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type},
|
||||
{exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type, module: "writing"},
|
||||
]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -64,7 +64,8 @@ export default function Writing({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded) onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type});
|
||||
if (hasExamEnded)
|
||||
onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type, module: "writing"});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasExamEnded]);
|
||||
|
||||
@@ -147,7 +148,9 @@ export default function Writing({
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={() => onBack({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type})}
|
||||
onClick={() =>
|
||||
onBack({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type})
|
||||
}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
Back
|
||||
</Button>
|
||||
@@ -158,8 +161,9 @@ export default function Writing({
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: [{id, solution: inputText.replaceAll(/\s{2,}/g, " ")}],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
score: {correct: 100, total: 100, missing: 0},
|
||||
type,
|
||||
module: "writing",
|
||||
})
|
||||
}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
|
||||
@@ -34,6 +34,7 @@ export default function Layout({user, children, className, navDisabled = false,
|
||||
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
|
||||
className="-md:hidden"
|
||||
userType={user.type}
|
||||
userId={user.id}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
|
||||
@@ -137,7 +137,7 @@ export default function TicketDisplay({ user, ticket, onClose }: Props) {
|
||||
options={[
|
||||
{ value: "me", label: "Assign to me" },
|
||||
...users
|
||||
.filter((x) => ["admin", "developer"].includes(x.type))
|
||||
.filter((x) => ["admin", "developer", "agent"].includes(x.type))
|
||||
.map((u) => ({
|
||||
value: u.id,
|
||||
label: `${u.name} - ${u.email}`,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {Ticket, TicketType, TicketTypeLabel} from "@/interfaces/ticket";
|
||||
import {User} from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import axios from "axios";
|
||||
import {useState} from "react";
|
||||
import {toast} from "react-toastify";
|
||||
@@ -20,6 +21,8 @@ export default function TicketSubmission({user, page, onClose}: Props) {
|
||||
const [description, setDescription] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const examState = useExamStore((state) => state);
|
||||
|
||||
const submit = () => {
|
||||
if (!type) return toast.error("Please choose a type!", {toastId: "missing-type"});
|
||||
if (subject.trim() === "")
|
||||
@@ -48,6 +51,18 @@ export default function TicketSubmission({user, page, onClose}: Props) {
|
||||
type,
|
||||
reportedFrom: page,
|
||||
description,
|
||||
examInformation:
|
||||
page.includes("exam") || page.includes("exercises")
|
||||
? {
|
||||
exam: examState.exam?.id || "",
|
||||
exams: examState.exams.map((x) => x.id),
|
||||
exerciseIndex: examState.exerciseIndex,
|
||||
moduleIndex: examState.moduleIndex,
|
||||
partIndex: examState.partIndex,
|
||||
questionIndex: examState.questionIndex,
|
||||
selectedModules: examState.selectedModules,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
axios
|
||||
|
||||
@@ -62,8 +62,8 @@ export default function Button({
|
||||
onClick={onClick}
|
||||
className={clsx(
|
||||
"py-4 px-6 rounded-full transition ease-in-out duration-300 disabled:cursor-not-allowed cursor-pointer",
|
||||
className,
|
||||
colorClassNames[color][variant],
|
||||
className,
|
||||
)}
|
||||
disabled={disabled || isLoading}>
|
||||
{!isLoading && children}
|
||||
|
||||
@@ -1,68 +1,70 @@
|
||||
import clsx from "clsx";
|
||||
import { ComponentProps } from "react";
|
||||
import ReactSelect from "react-select";
|
||||
import {ComponentProps, useEffect, useState} from "react";
|
||||
import ReactSelect, {GroupBase, StylesConfig} from "react-select";
|
||||
|
||||
interface Option {
|
||||
[key: string]: any;
|
||||
value: string;
|
||||
label: string;
|
||||
[key: string]: any;
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
defaultValue?: Option;
|
||||
value?: Option | null;
|
||||
options: Option[];
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
onChange: (value: Option | null) => void;
|
||||
isClearable?: boolean;
|
||||
defaultValue?: Option;
|
||||
value?: Option | null;
|
||||
options: Option[];
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
onChange: (value: Option | null) => void;
|
||||
isClearable?: boolean;
|
||||
styles?: StylesConfig<Option, boolean, GroupBase<Option>>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Select({
|
||||
value,
|
||||
defaultValue,
|
||||
options,
|
||||
placeholder,
|
||||
disabled,
|
||||
onChange,
|
||||
isClearable,
|
||||
}: Props) {
|
||||
return (
|
||||
<ReactSelect
|
||||
className={clsx(
|
||||
"placeholder:text-mti-gray-cool border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none",
|
||||
disabled &&
|
||||
"!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
|
||||
)}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
menuPortalTarget={document?.body}
|
||||
defaultValue={defaultValue}
|
||||
styles={{
|
||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused
|
||||
? "#D5D9F0"
|
||||
: state.isSelected
|
||||
? "#7872BF"
|
||||
: "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
isDisabled={disabled}
|
||||
isClearable={isClearable}
|
||||
/>
|
||||
);
|
||||
export default function Select({value, defaultValue, options, placeholder, disabled, onChange, styles, isClearable, className}: Props) {
|
||||
const [target, setTarget] = useState<HTMLElement>();
|
||||
|
||||
useEffect(() => {
|
||||
if (document) setTarget(document.body);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ReactSelect
|
||||
className={
|
||||
styles
|
||||
? undefined
|
||||
: clsx(
|
||||
"placeholder:text-mti-gray-cool border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none",
|
||||
disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
|
||||
className,
|
||||
)
|
||||
}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange as any}
|
||||
placeholder={placeholder}
|
||||
menuPortalTarget={target}
|
||||
defaultValue={defaultValue}
|
||||
styles={
|
||||
styles || {
|
||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}
|
||||
}
|
||||
isDisabled={disabled}
|
||||
isClearable={isClearable}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,11 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
|
||||
const [timer, setTimer] = useState(minTimer * 60);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [warningMode, setWarningMode] = useState(false);
|
||||
|
||||
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
|
||||
const {timeSpent} = useExamStore((state) => state);
|
||||
|
||||
useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!disableTimer) {
|
||||
|
||||
70
src/components/Medium/TopicModal.tsx
Normal file
70
src/components/Medium/TopicModal.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import topics from "@/resources/topics";
|
||||
import {useState} from "react";
|
||||
import {BsArrowLeft, BsArrowRight} from "react-icons/bs";
|
||||
import Button from "../Low/Button";
|
||||
import Modal from "../Modal";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
initialTopics: string[];
|
||||
onClose: VoidFunction;
|
||||
selectTopics: (topics: string[]) => void;
|
||||
}
|
||||
|
||||
export default function TopicModal({isOpen, initialTopics, onClose, selectTopics}: Props) {
|
||||
const [selectedTopics, setSelectedTopics] = useState([...initialTopics]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Preferred Topics">
|
||||
<div className="flex flex-col w-full h-full gap-4 mt-4">
|
||||
<div className="w-full h-full grid grid-cols-2 -md:gap-1 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="border-b border-b-neutral-400/30">Available Topics</span>
|
||||
<div className=" max-h-[500px] overflow-y-scroll scrollbar-hide">
|
||||
{topics
|
||||
.filter((x) => !selectedTopics.includes(x))
|
||||
.map((x) => (
|
||||
<div key={x} className="odd:bg-mti-purple-ultralight/40 p-2 flex justify-between items-center">
|
||||
<span>{x}</span>
|
||||
<button
|
||||
onClick={() => setSelectedTopics((prev) => [...prev, x])}
|
||||
className="border border-mti-purple-light cursor-pointer p-2 rounded-lg bg-white drop-shadow transition ease-in-out duration-300 hover:bg-mti-purple hover:text-white">
|
||||
<BsArrowRight />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="border-b border-b-neutral-400/30">Preferred Topics ({selectedTopics.length || "All"})</span>
|
||||
<div className=" max-h-[500px] overflow-y-scroll scrollbar-hide">
|
||||
{selectedTopics.map((x) => (
|
||||
<div key={x} className="odd:bg-mti-purple-ultralight/40 p-2 flex justify-between items-center text-right">
|
||||
<button
|
||||
onClick={() => setSelectedTopics((prev) => [...prev.filter((y) => y !== x)])}
|
||||
className="border border-mti-purple-light cursor-pointer p-2 rounded-lg bg-white drop-shadow transition ease-in-out duration-300 hover:bg-mti-purple hover:text-white">
|
||||
<BsArrowLeft />
|
||||
</button>
|
||||
<span>{x}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex gap-4 items-center justify-end">
|
||||
<Button variant="outline" color="rose" className="w-full max-w-[200px]" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full max-w-[200px]"
|
||||
onClick={() => {
|
||||
selectTopics(selectedTopics);
|
||||
onClose();
|
||||
}}>
|
||||
Select
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,201 +1,160 @@
|
||||
import { User } from "@/interfaces/user";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import {User} from "@/interfaces/user";
|
||||
import {Dialog, Transition} from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { Fragment } from "react";
|
||||
import { BsXLg } from "react-icons/bs";
|
||||
import {useRouter} from "next/router";
|
||||
import {Fragment} from "react";
|
||||
import {BsXLg} from "react-icons/bs";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
path: string;
|
||||
user: User;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
path: string;
|
||||
user: User;
|
||||
disableNavigation?: boolean;
|
||||
}
|
||||
|
||||
export default function MobileMenu({ isOpen, onClose, path, user }: Props) {
|
||||
const router = useRouter();
|
||||
export default function MobileMenu({isOpen, onClose, path, user, disableNavigation}: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const logout = async () => {
|
||||
axios.post("/api/logout").finally(() => {
|
||||
setTimeout(() => router.reload(), 500);
|
||||
});
|
||||
};
|
||||
const logout = async () => {
|
||||
axios.post("/api/logout").finally(() => {
|
||||
setTimeout(() => router.reload(), 500);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||
</Transition.Child>
|
||||
return (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center text-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="flex h-screen w-full transform flex-col gap-8 overflow-hidden bg-white text-left align-middle text-black shadow-xl transition-all">
|
||||
<Dialog.Title
|
||||
as="header"
|
||||
className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden"
|
||||
>
|
||||
<Link href="/">
|
||||
<Image
|
||||
src="/logo_title.png"
|
||||
alt="EnCoach logo"
|
||||
width={69}
|
||||
height={69}
|
||||
/>
|
||||
</Link>
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={onClose}
|
||||
tabIndex={0}
|
||||
>
|
||||
<BsXLg
|
||||
className="text-mti-purple-light text-2xl"
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
</Dialog.Title>
|
||||
<div className="flex h-full flex-col gap-6 px-8 text-lg">
|
||||
<Link
|
||||
href="/"
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
{(user.type === "student" ||
|
||||
user.type === "teacher" ||
|
||||
user.type === "developer") && (
|
||||
<>
|
||||
<Link
|
||||
href="/exam"
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/exam" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}
|
||||
>
|
||||
Exams
|
||||
</Link>
|
||||
<Link
|
||||
href="/exercises"
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/exercises" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}
|
||||
>
|
||||
Exercises
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<Link
|
||||
href="/stats"
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/stats" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}
|
||||
>
|
||||
Stats
|
||||
</Link>
|
||||
<Link
|
||||
href="/record"
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/record" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}
|
||||
>
|
||||
Record
|
||||
</Link>
|
||||
{["admin", "developer", "agent", "corporate"].includes(
|
||||
user.type,
|
||||
) && (
|
||||
<Link
|
||||
href="/payment-record"
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/payment-record" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}
|
||||
>
|
||||
Payment Record
|
||||
</Link>
|
||||
)}
|
||||
{["admin", "developer", "corporate", "teacher"].includes(
|
||||
user.type,
|
||||
) && (
|
||||
<Link
|
||||
href="/settings"
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/settings" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
)}
|
||||
{["admin", "developer", "agent"].includes(user.type) && (
|
||||
<Link
|
||||
href="/tickets"
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/tickets" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}
|
||||
>
|
||||
Tickets
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href="/profile"
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/profile" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}
|
||||
>
|
||||
Profile
|
||||
</Link>
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center text-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95">
|
||||
<Dialog.Panel className="flex h-screen w-full transform flex-col gap-8 overflow-hidden bg-white text-left align-middle text-black shadow-xl transition-all">
|
||||
<Dialog.Title as="header" className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden">
|
||||
<Link href={disableNavigation ? "" : "/"}>
|
||||
<Image src="/logo_title.png" alt="EnCoach logo" width={69} height={69} />
|
||||
</Link>
|
||||
<div className="cursor-pointer" onClick={onClose} tabIndex={0}>
|
||||
<BsXLg className="text-mti-purple-light text-2xl" onClick={onClose} />
|
||||
</div>
|
||||
</Dialog.Title>
|
||||
<div className="flex h-full flex-col gap-6 px-8 text-lg">
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Dashboard
|
||||
</Link>
|
||||
{(user.type === "student" || user.type === "teacher" || user.type === "developer") && (
|
||||
<>
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/exam"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/exam" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Exams
|
||||
</Link>
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/exercises"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/exercises" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Exercises
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/stats"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/stats" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Stats
|
||||
</Link>
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/record"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/record" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Record
|
||||
</Link>
|
||||
{["admin", "developer", "agent", "corporate"].includes(user.type) && (
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/payment-record"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/payment-record" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Payment Record
|
||||
</Link>
|
||||
)}
|
||||
{["admin", "developer", "corporate", "teacher"].includes(user.type) && (
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/settings"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/settings" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Settings
|
||||
</Link>
|
||||
)}
|
||||
{["admin", "developer", "agent"].includes(user.type) && (
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/tickets"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/tickets" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Tickets
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/profile"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/profile" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Profile
|
||||
</Link>
|
||||
|
||||
<span
|
||||
className={clsx(
|
||||
"w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out",
|
||||
)}
|
||||
onClick={logout}
|
||||
>
|
||||
Logout
|
||||
</span>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
<span
|
||||
className={clsx("w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out")}
|
||||
onClick={logout}>
|
||||
Logout
|
||||
</span>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
|
||||
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
|
||||
const [isTicketOpen, setIsTicketOpen] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||
|
||||
const expirationDateColor = (date: Date) => {
|
||||
@@ -59,10 +61,12 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
|
||||
{user && <MobileMenu path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />}
|
||||
{user && (
|
||||
<MobileMenu disableNavigation={disableNavigation} path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />
|
||||
)}
|
||||
<header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4">
|
||||
<Link href={disableNavigation ? "" : "/"} className=" flex items-center gap-8 md:px-8">
|
||||
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" />
|
||||
@@ -73,7 +77,7 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
|
||||
<button
|
||||
className={clsx(
|
||||
"border-mti-purple-light tooltip tooltip-bottom flex h-8 w-8 flex-col items-center justify-center rounded-full border p-1",
|
||||
"hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white",
|
||||
"hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white z-20",
|
||||
)}
|
||||
data-tip="Submit a help/feedback ticket"
|
||||
onClick={() => setIsTicketOpen(true)}>
|
||||
@@ -82,7 +86,7 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
|
||||
|
||||
{showExpirationDate() && (
|
||||
<Link
|
||||
href={disablePaymentPage ? "/payment" : ""}
|
||||
href={!!user.subscriptionExpirationDate && !disablePaymentPage ? "/payment" : ""}
|
||||
data-tip="Expiry date"
|
||||
className={clsx(
|
||||
"flex w-fit cursor-pointer justify-center rounded-full border px-6 py-2 text-sm font-normal focus:outline-none",
|
||||
|
||||
@@ -1,77 +1,138 @@
|
||||
import {DurationUnit} from "@/interfaces/paypal";
|
||||
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OnCancelledActions, OrderResponseBody} from "@paypal/paypal-js";
|
||||
import {PayPalButtons, PayPalScriptProvider, usePayPalScriptReducer} from "@paypal/react-paypal-js";
|
||||
import { DurationUnit } from "@/interfaces/paypal";
|
||||
import {
|
||||
CreateOrderActions,
|
||||
CreateOrderData,
|
||||
OnApproveActions,
|
||||
OnApproveData,
|
||||
OnCancelledActions,
|
||||
OrderResponseBody,
|
||||
} from "@paypal/paypal-js";
|
||||
import {
|
||||
PayPalButtons,
|
||||
PayPalScriptProvider,
|
||||
usePayPalScriptReducer,
|
||||
} from "@paypal/react-paypal-js";
|
||||
import axios from "axios";
|
||||
import {useEffect, useState} from "react";
|
||||
import {toast} from "react-toastify";
|
||||
import { useState, useEffect } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
interface Props {
|
||||
clientID: string;
|
||||
currency: string;
|
||||
price: number;
|
||||
duration: number;
|
||||
duration_unit: DurationUnit;
|
||||
loadScript?: boolean;
|
||||
setIsLoading: (isLoading: boolean) => void;
|
||||
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
|
||||
clientID: string;
|
||||
currency: string;
|
||||
price: number;
|
||||
duration: number;
|
||||
duration_unit: DurationUnit;
|
||||
loadScript?: boolean;
|
||||
setIsLoading: (isLoading: boolean) => void;
|
||||
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
|
||||
trackingId?: string;
|
||||
}
|
||||
|
||||
export default function PayPalPayment({clientID, price, currency, duration, duration_unit, loadScript, setIsLoading, onSuccess}: Props) {
|
||||
const createOrder = async (data: CreateOrderData, actions: CreateOrderActions): Promise<string> => {
|
||||
setIsLoading(true);
|
||||
export default function PayPalPayment({
|
||||
clientID,
|
||||
price,
|
||||
currency,
|
||||
duration,
|
||||
duration_unit,
|
||||
loadScript,
|
||||
setIsLoading,
|
||||
onSuccess,
|
||||
trackingId,
|
||||
}: Props) {
|
||||
const createOrder = async (
|
||||
data: CreateOrderData,
|
||||
actions: CreateOrderActions
|
||||
): Promise<string> => {
|
||||
if (!trackingId) {
|
||||
throw new Error("trackingId is not set");
|
||||
}
|
||||
setIsLoading(true);
|
||||
|
||||
return axios
|
||||
.post<OrderResponseBody>("/api/paypal", {currencyCode: currency, price})
|
||||
.then((response) => response.data)
|
||||
.then((data) => data.id);
|
||||
};
|
||||
return axios
|
||||
.post<OrderResponseBody>("/api/paypal", {
|
||||
currencyCode: currency,
|
||||
price,
|
||||
trackingId,
|
||||
})
|
||||
.then((response) => response.data)
|
||||
.then((data) => {
|
||||
setIsLoading(false);
|
||||
return data.id;
|
||||
})
|
||||
.catch((err) => {
|
||||
setIsLoading(false);
|
||||
return err;
|
||||
});
|
||||
};
|
||||
|
||||
const onApprove = async (data: OnApproveData, actions: OnApproveActions) => {
|
||||
const request = await axios.post<{ok: boolean; reason?: string}>("/api/paypal/approve", {id: data.orderID, duration, duration_unit});
|
||||
const onApprove = async (data: OnApproveData, actions: OnApproveActions) => {
|
||||
if (!trackingId) {
|
||||
throw new Error("trackingId is not set");
|
||||
}
|
||||
|
||||
if (request.status !== 200) {
|
||||
toast.error("Something went wrong, please try again later");
|
||||
return;
|
||||
}
|
||||
axios
|
||||
.post<{ ok: boolean; reason?: string }>("/api/paypal/approve", {
|
||||
id: data.orderID,
|
||||
duration,
|
||||
duration_unit,
|
||||
trackingId,
|
||||
})
|
||||
.then((request) => {
|
||||
if (request.status !== 200) {
|
||||
toast.error("Something went wrong, please try again later");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Your account has been credited more time!");
|
||||
return onSuccess(duration, duration_unit);
|
||||
};
|
||||
toast.success("Your account has been credited more time!");
|
||||
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>) => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
const onError = async (data: Record<string, unknown>) => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const onCancel = async (data: Record<string, unknown>, actions: OnCancelledActions) => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
const onCancel = async (
|
||||
data: Record<string, unknown>,
|
||||
actions: OnCancelledActions
|
||||
) => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return loadScript ? (
|
||||
<PayPalScriptProvider
|
||||
options={{
|
||||
clientId: clientID,
|
||||
currency,
|
||||
intent: "capture",
|
||||
commit: true,
|
||||
vault: true,
|
||||
}}>
|
||||
<PayPalButtons
|
||||
className="w-full"
|
||||
style={{layout: "vertical"}}
|
||||
createOrder={createOrder}
|
||||
onApprove={onApprove}
|
||||
onCancel={onCancel}
|
||||
onError={onError}
|
||||
/>
|
||||
</PayPalScriptProvider>
|
||||
) : (
|
||||
<PayPalButtons
|
||||
className="w-full"
|
||||
style={{layout: "vertical"}}
|
||||
createOrder={createOrder}
|
||||
onApprove={onApprove}
|
||||
onCancel={onCancel}
|
||||
onError={onError}
|
||||
/>
|
||||
);
|
||||
if (trackingId) {
|
||||
return loadScript ? (
|
||||
<PayPalScriptProvider
|
||||
options={{
|
||||
clientId: clientID,
|
||||
currency,
|
||||
intent: "capture",
|
||||
commit: true,
|
||||
}}
|
||||
>
|
||||
<PayPalButtons
|
||||
className="w-full"
|
||||
style={{ layout: "vertical" }}
|
||||
createOrder={createOrder}
|
||||
onApprove={onApprove}
|
||||
onCancel={onCancel}
|
||||
onError={onError}
|
||||
/>
|
||||
</PayPalScriptProvider>
|
||||
) : (
|
||||
<PayPalButtons
|
||||
className="w-full"
|
||||
style={{ layout: "vertical" }}
|
||||
createOrder={createOrder}
|
||||
onApprove={onApprove}
|
||||
onCancel={onCancel}
|
||||
onError={onError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
77
src/components/PaymobPayment.tsx
Normal file
77
src/components/PaymobPayment.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import {PaymentIntention} from "@/interfaces/paymob";
|
||||
import {DurationUnit} from "@/interfaces/paypal";
|
||||
import {User} from "@/interfaces/user";
|
||||
import axios from "axios";
|
||||
import {useRouter} from "next/router";
|
||||
import {useState} from "react";
|
||||
import Button from "./Low/Button";
|
||||
import Input from "./Low/Input";
|
||||
import Modal from "./Modal";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
currency: string;
|
||||
price: number;
|
||||
setIsPaymentLoading: (v: boolean) => void;
|
||||
duration: number;
|
||||
duration_unit: DurationUnit;
|
||||
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
|
||||
}
|
||||
|
||||
export default function PaymobPayment({user, price, setIsPaymentLoading, currency, duration, duration_unit, onSuccess}: Props) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const handleCardPayment = async () => {
|
||||
try {
|
||||
setIsPaymentLoading(true);
|
||||
|
||||
const paymentIntention: PaymentIntention = {
|
||||
amount: price * 1000,
|
||||
currency: "OMR",
|
||||
items: [],
|
||||
payment_methods: [],
|
||||
customer: {
|
||||
email: user.email,
|
||||
first_name: user.name.split(" ")[0],
|
||||
last_name: [...user.name.split(" ")].pop() || "N/A",
|
||||
extras: {
|
||||
re: user.id,
|
||||
},
|
||||
},
|
||||
billing_data: {
|
||||
apartment: "N/A",
|
||||
building: "N/A",
|
||||
country: user.demographicInformation?.country || "N/A",
|
||||
email: user.email,
|
||||
first_name: user.name.split(" ")[0],
|
||||
last_name: [...user.name.split(" ")].pop() || "N/A",
|
||||
floor: "N/A",
|
||||
phone_number: user.demographicInformation?.phone || "N/A",
|
||||
state: "N/A",
|
||||
street: "N/A",
|
||||
},
|
||||
extras: {
|
||||
userID: user.id,
|
||||
duration,
|
||||
duration_unit,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await axios.post<{iframeURL: string}>(`/api/paymob`, paymentIntention);
|
||||
|
||||
router.push(response.data.iframeURL);
|
||||
} catch (error) {
|
||||
console.error("Error starting card payment process:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button isLoading={isLoading} onClick={handleCardPayment}>
|
||||
Select
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,295 +1,212 @@
|
||||
import clsx from "clsx";
|
||||
import { IconType } from "react-icons";
|
||||
import { MdSpaceDashboard } from "react-icons/md";
|
||||
import {IconType} from "react-icons";
|
||||
import {MdSpaceDashboard} from "react-icons/md";
|
||||
import {
|
||||
BsFileEarmarkText,
|
||||
BsClockHistory,
|
||||
BsPencil,
|
||||
BsGraphUp,
|
||||
BsChevronBarRight,
|
||||
BsChevronBarLeft,
|
||||
BsShieldFill,
|
||||
BsCloudFill,
|
||||
BsCurrencyDollar,
|
||||
BsClipboardData,
|
||||
BsFileEarmarkText,
|
||||
BsClockHistory,
|
||||
BsPencil,
|
||||
BsGraphUp,
|
||||
BsChevronBarRight,
|
||||
BsChevronBarLeft,
|
||||
BsShieldFill,
|
||||
BsCloudFill,
|
||||
BsCurrencyDollar,
|
||||
BsClipboardData,
|
||||
} from "react-icons/bs";
|
||||
import { RiLogoutBoxFill } from "react-icons/ri";
|
||||
import { SlPencil } from "react-icons/sl";
|
||||
import { FaAward } from "react-icons/fa";
|
||||
import {RiLogoutBoxFill} from "react-icons/ri";
|
||||
import {SlPencil} from "react-icons/sl";
|
||||
import {FaAward} from "react-icons/fa";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import {useRouter} from "next/router";
|
||||
import axios from "axios";
|
||||
import FocusLayer from "@/components/FocusLayer";
|
||||
import { preventNavigation } from "@/utils/navigation.disabled";
|
||||
import { useState } from "react";
|
||||
import {preventNavigation} from "@/utils/navigation.disabled";
|
||||
import {useEffect, useState} from "react";
|
||||
import usePreferencesStore from "@/stores/preferencesStore";
|
||||
import { Type } from "@/interfaces/user";
|
||||
import {Type} from "@/interfaces/user";
|
||||
import useTicketsListener from "@/hooks/useTicketsListener";
|
||||
interface Props {
|
||||
path: string;
|
||||
navDisabled?: boolean;
|
||||
focusMode?: boolean;
|
||||
onFocusLayerMouseEnter?: () => void;
|
||||
className?: string;
|
||||
userType?: Type;
|
||||
path: string;
|
||||
navDisabled?: boolean;
|
||||
focusMode?: boolean;
|
||||
onFocusLayerMouseEnter?: () => void;
|
||||
className?: string;
|
||||
userType?: Type;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
interface NavProps {
|
||||
Icon: IconType;
|
||||
label: string;
|
||||
path: string;
|
||||
keyPath: string;
|
||||
disabled?: boolean;
|
||||
isMinimized?: boolean;
|
||||
Icon: IconType;
|
||||
label: string;
|
||||
path: string;
|
||||
keyPath: string;
|
||||
disabled?: boolean;
|
||||
isMinimized?: boolean;
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
const Nav = ({
|
||||
Icon,
|
||||
label,
|
||||
path,
|
||||
keyPath,
|
||||
disabled = false,
|
||||
isMinimized = false,
|
||||
}: NavProps) => (
|
||||
<Link
|
||||
href={!disabled ? keyPath : ""}
|
||||
className={clsx(
|
||||
"flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white",
|
||||
"transition-all duration-300 ease-in-out",
|
||||
disabled
|
||||
? "hover:bg-mti-gray-dim cursor-not-allowed"
|
||||
: "hover:bg-mti-purple-light cursor-pointer",
|
||||
path === keyPath && "bg-mti-purple-light text-white",
|
||||
isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]",
|
||||
)}
|
||||
>
|
||||
<Icon size={24} />
|
||||
{!isMinimized && <span className="text-lg font-semibold">{label}</span>}
|
||||
</Link>
|
||||
);
|
||||
const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false, badge}: NavProps) => {
|
||||
return (
|
||||
<Link
|
||||
href={!disabled ? keyPath : ""}
|
||||
className={clsx(
|
||||
"flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white",
|
||||
"transition-all duration-300 ease-in-out relative",
|
||||
disabled ? "hover:bg-mti-gray-dim cursor-not-allowed" : "hover:bg-mti-purple-light cursor-pointer",
|
||||
path === keyPath && "bg-mti-purple-light text-white",
|
||||
isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]",
|
||||
)}>
|
||||
<Icon size={24} />
|
||||
{!isMinimized && <span className="text-lg font-semibold">{label}</span>}
|
||||
{!!badge && badge > 0 && (
|
||||
<div
|
||||
className={clsx(
|
||||
"bg-mti-purple-light h-5 w-5 text-xs rounded-full flex items-center justify-center text-white",
|
||||
"transition ease-in-out duration-300",
|
||||
isMinimized && "absolute right-0 top-0",
|
||||
)}>
|
||||
{badge}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Sidebar({
|
||||
path,
|
||||
navDisabled = false,
|
||||
focusMode = false,
|
||||
userType,
|
||||
onFocusLayerMouseEnter,
|
||||
className,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
export default function Sidebar({path, navDisabled = false, focusMode = false, userType, onFocusLayerMouseEnter, className, userId}: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [
|
||||
state.isSidebarMinimized,
|
||||
state.toggleSidebarMinimized,
|
||||
]);
|
||||
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
|
||||
|
||||
const logout = async () => {
|
||||
axios.post("/api/logout").finally(() => {
|
||||
setTimeout(() => router.reload(), 500);
|
||||
});
|
||||
};
|
||||
const {totalAssignedTickets} = useTicketsListener(userId);
|
||||
|
||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||
const logout = async () => {
|
||||
axios.post("/api/logout").finally(() => {
|
||||
setTimeout(() => router.reload(), 500);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className={clsx(
|
||||
"relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8",
|
||||
isMinimized ? "w-fit" : "-xl:w-fit w-1/6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="-xl:hidden flex-col gap-3 xl:flex">
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={MdSpaceDashboard}
|
||||
label="Dashboard"
|
||||
path={path}
|
||||
keyPath="/"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
{(userType === "student" ||
|
||||
userType === "teacher" ||
|
||||
userType === "developer") && (
|
||||
<>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsFileEarmarkText}
|
||||
label="Exams"
|
||||
path={path}
|
||||
keyPath="/exam"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsPencil}
|
||||
label="Exercises"
|
||||
path={path}
|
||||
keyPath="/exercises"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsGraphUp}
|
||||
label="Stats"
|
||||
path={path}
|
||||
keyPath="/stats"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsClockHistory}
|
||||
label="Record"
|
||||
path={path}
|
||||
keyPath="/record"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
{["admin", "developer", "agent", "corporate"].includes(
|
||||
userType || "",
|
||||
) && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCurrencyDollar}
|
||||
label="Payment Record"
|
||||
path={path}
|
||||
keyPath="/payment-record"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{["admin", "developer", "corporate", "teacher"].includes(
|
||||
userType || "",
|
||||
) && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsShieldFill}
|
||||
label="Settings"
|
||||
path={path}
|
||||
keyPath="/settings"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{["admin", "developer", "agent"].includes(userType || "") && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsClipboardData}
|
||||
label="Tickets"
|
||||
path={path}
|
||||
keyPath="/tickets"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{userType === "developer" && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCloudFill}
|
||||
label="Generation"
|
||||
path={path}
|
||||
keyPath="/generation"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="-xl:flex flex-col gap-3 xl:hidden">
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={MdSpaceDashboard}
|
||||
label="Dashboard"
|
||||
path={path}
|
||||
keyPath="/"
|
||||
isMinimized={true}
|
||||
/>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsFileEarmarkText}
|
||||
label="Exams"
|
||||
path={path}
|
||||
keyPath="/exam"
|
||||
isMinimized={true}
|
||||
/>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsPencil}
|
||||
label="Exercises"
|
||||
path={path}
|
||||
keyPath="/exercises"
|
||||
isMinimized={true}
|
||||
/>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsGraphUp}
|
||||
label="Stats"
|
||||
path={path}
|
||||
keyPath="/stats"
|
||||
isMinimized={true}
|
||||
/>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsClockHistory}
|
||||
label="Record"
|
||||
path={path}
|
||||
keyPath="/record"
|
||||
isMinimized={true}
|
||||
/>
|
||||
{userType !== "student" && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsShieldFill}
|
||||
label="Settings"
|
||||
path={path}
|
||||
keyPath="/settings"
|
||||
isMinimized={true}
|
||||
/>
|
||||
)}
|
||||
{userType === "developer" && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCloudFill}
|
||||
label="Generation"
|
||||
path={path}
|
||||
keyPath="/generation"
|
||||
isMinimized={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||
|
||||
<div className="fixed bottom-12 flex flex-col gap-0">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={1}
|
||||
onClick={toggleMinimize}
|
||||
className={clsx(
|
||||
"hover:text-mti-rose -xl:hidden flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
|
||||
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
||||
)}
|
||||
>
|
||||
{isMinimized ? (
|
||||
<BsChevronBarRight size={24} />
|
||||
) : (
|
||||
<BsChevronBarLeft size={24} />
|
||||
)}
|
||||
{!isMinimized && (
|
||||
<span className="text-lg font-medium">Minimize</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={1}
|
||||
onClick={focusMode ? () => {} : logout}
|
||||
className={clsx(
|
||||
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
|
||||
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
||||
)}
|
||||
>
|
||||
<RiLogoutBoxFill size={24} />
|
||||
{!isMinimized && (
|
||||
<span className="-xl:hidden text-lg font-medium">Log Out</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{focusMode && (
|
||||
<FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
return (
|
||||
<section
|
||||
className={clsx(
|
||||
"relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8",
|
||||
isMinimized ? "w-fit" : "-xl:w-fit w-1/6",
|
||||
className,
|
||||
)}>
|
||||
<div className="-xl:hidden flex-col gap-3 xl:flex">
|
||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
|
||||
{(userType === "student" || userType === "teacher" || userType === "developer") && (
|
||||
<>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsFileEarmarkText}
|
||||
label="Exams"
|
||||
path={path}
|
||||
keyPath="/exam"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsPencil}
|
||||
label="Exercises"
|
||||
path={path}
|
||||
keyPath="/exercises"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{(userType || "") !== 'agent' && (
|
||||
<>
|
||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
|
||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
|
||||
</>
|
||||
)}
|
||||
{["admin", "developer", "agent", "corporate"].includes(userType || "") && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCurrencyDollar}
|
||||
label="Payment Record"
|
||||
path={path}
|
||||
keyPath="/payment-record"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{["admin", "developer", "corporate", "teacher"].includes(userType || "") && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsShieldFill}
|
||||
label="Settings"
|
||||
path={path}
|
||||
keyPath="/settings"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{["admin", "developer", "agent"].includes(userType || "") && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsClipboardData}
|
||||
label="Tickets"
|
||||
path={path}
|
||||
keyPath="/tickets"
|
||||
isMinimized={isMinimized}
|
||||
badge={totalAssignedTickets}
|
||||
/>
|
||||
)}
|
||||
{userType === "developer" && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCloudFill}
|
||||
label="Generation"
|
||||
path={path}
|
||||
keyPath="/generation"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="-xl:flex flex-col gap-3 xl:hidden">
|
||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} />
|
||||
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} />
|
||||
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
|
||||
{(userType || "") !== 'agent' && (
|
||||
<>
|
||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
|
||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
|
||||
</>
|
||||
)}
|
||||
{userType !== "student" && (
|
||||
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized={true} />
|
||||
)}
|
||||
{userType === "developer" && (
|
||||
<Nav disabled={disableNavigation} Icon={BsCloudFill} label="Generation" path={path} keyPath="/generation" isMinimized={true} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="fixed bottom-12 flex flex-col gap-0">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={1}
|
||||
onClick={toggleMinimize}
|
||||
className={clsx(
|
||||
"hover:text-mti-rose -xl:hidden flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
|
||||
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
||||
)}>
|
||||
{isMinimized ? <BsChevronBarRight size={24} /> : <BsChevronBarLeft size={24} />}
|
||||
{!isMinimized && <span className="text-lg font-medium">Minimize</span>}
|
||||
</div>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={1}
|
||||
onClick={focusMode ? () => {} : logout}
|
||||
className={clsx(
|
||||
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
|
||||
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
||||
)}>
|
||||
<RiLogoutBoxFill size={24} />
|
||||
{!isMinimized && <span className="-xl:hidden text-lg font-medium">Log Out</span>}
|
||||
</div>
|
||||
</div>
|
||||
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
|
||||
|
||||
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">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {MatchSentencesExercise} from "@/interfaces/exam";
|
||||
import {MatchSentenceExerciseSentence, MatchSentencesExercise} from "@/interfaces/exam";
|
||||
import clsx from "clsx";
|
||||
import LineTo from "react-lineto";
|
||||
import {CommonProps} from ".";
|
||||
@@ -9,6 +9,48 @@ import {Fragment} from "react";
|
||||
import Button from "../Low/Button";
|
||||
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({
|
||||
id,
|
||||
type,
|
||||
@@ -31,7 +73,7 @@ export default function MatchSentencesSolutions({
|
||||
|
||||
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">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
@@ -40,57 +82,18 @@ export default function MatchSentencesSolutions({
|
||||
</Fragment>
|
||||
))}
|
||||
</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">
|
||||
{sentences.map(({sentence, id, solution}) => (
|
||||
<div key={`question_${id}`} className="flex items-center justify-end gap-2 cursor-pointer">
|
||||
<span>{sentence} </span>
|
||||
<button
|
||||
id={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}
|
||||
{sentences.map((question) => (
|
||||
<QuestionSolutionArea
|
||||
question={question}
|
||||
userSolution={userSolutions.find((x) => x.question.toString() === question.id.toString())}
|
||||
key={`question_${question.id}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-4 h-4 rounded-full bg-mti-purple" /> Correct
|
||||
|
||||
@@ -6,6 +6,7 @@ import {CommonProps} from ".";
|
||||
import Button from "../Low/Button";
|
||||
|
||||
function Question({
|
||||
id,
|
||||
variant,
|
||||
prompt,
|
||||
solution,
|
||||
@@ -26,7 +27,13 @@ function Question({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<span>{prompt}</span>
|
||||
{isNaN(Number(id)) ? (
|
||||
<span className="">{prompt}</span>
|
||||
) : (
|
||||
<span className="">
|
||||
{id} - {prompt}
|
||||
</span>
|
||||
)}
|
||||
<div className="grid grid-cols-4 gap-4 place-items-center">
|
||||
{variant === "image" &&
|
||||
options.map((option) => (
|
||||
|
||||
@@ -20,6 +20,9 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
||||
|
||||
useEffect(() => {
|
||||
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}) => {
|
||||
const blob = new Blob([data], {type: "audio/wav"});
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
||||
|
||||
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">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
|
||||
@@ -107,7 +107,7 @@ export default function WriteBlanksSolutions({
|
||||
|
||||
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">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {RadioGroup} from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
@@ -8,7 +8,7 @@ import moment from "moment";
|
||||
import {Divider} from "primereact/divider";
|
||||
import {useEffect, useState} from "react";
|
||||
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 Button from "./Low/Button";
|
||||
import Checkbox from "./Low/Checkbox";
|
||||
@@ -19,6 +19,7 @@ import Select from "react-select";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||
import {CURRENCIES} from "@/resources/paypal";
|
||||
import useCodes from "@/hooks/useCodes";
|
||||
|
||||
const expirationDateColor = (date: Date) => {
|
||||
const momentDate = moment(date);
|
||||
@@ -37,6 +38,9 @@ interface Props {
|
||||
onViewTeachers?: () => void;
|
||||
onViewCorporate?: () => void;
|
||||
disabled?: boolean;
|
||||
disabledFields?: {
|
||||
countryManager?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
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],
|
||||
}));
|
||||
|
||||
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 [type, setType] = useState(user.type);
|
||||
const [status, setStatus] = useState(user.status);
|
||||
@@ -77,6 +84,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
? user.agentInformation?.companyName
|
||||
: undefined,
|
||||
);
|
||||
const [arabName, setArabName] = useState(user.type === "agent" ? user.agentInformation?.companyArabName : undefined);
|
||||
const [commercialRegistration, setCommercialRegistration] = useState(
|
||||
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 {stats} = useStats(user.id);
|
||||
const {users} = useUsers();
|
||||
const {codes} = useCodes(user.id);
|
||||
|
||||
useEffect(() => {
|
||||
if (users && users.length > 0) {
|
||||
@@ -114,8 +123,9 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
agentInformation:
|
||||
type === "agent"
|
||||
? {
|
||||
name: companyName,
|
||||
companyName,
|
||||
commercialRegistration,
|
||||
arabName,
|
||||
}
|
||||
: undefined,
|
||||
corporateInformation:
|
||||
@@ -144,38 +154,63 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
});
|
||||
};
|
||||
|
||||
const generalProfileItems = [
|
||||
{
|
||||
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||
value: Object.keys(groupBySession(stats)).length,
|
||||
label: "Exams",
|
||||
},
|
||||
{
|
||||
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||
value: stats.length,
|
||||
label: "Modules",
|
||||
},
|
||||
{
|
||||
icon: <BsStar className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
|
||||
label: "Average Score",
|
||||
},
|
||||
];
|
||||
|
||||
const corporateProfileItems =
|
||||
user.type === "corporate"
|
||||
? [
|
||||
{
|
||||
icon: <BsPerson className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||
value: codes.length,
|
||||
label: "Users Used",
|
||||
},
|
||||
{
|
||||
icon: <BsPersonAdd className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||
value: user.corporateInformation.companyInformation.userAmount,
|
||||
label: "Number of Users",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProfileSummary
|
||||
user={user}
|
||||
items={[
|
||||
{
|
||||
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||
value: Object.keys(groupBySession(stats)).length,
|
||||
label: "Exams",
|
||||
},
|
||||
{
|
||||
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||
value: stats.length,
|
||||
label: "Exercises",
|
||||
},
|
||||
{
|
||||
icon: <BsStar className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
|
||||
label: "Average Score",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ProfileSummary user={user} items={user.type === "corporate" ? corporateProfileItems : generalProfileItems} />
|
||||
|
||||
{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
|
||||
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"
|
||||
name="companyName"
|
||||
onChange={setCompanyName}
|
||||
placeholder="Enter corporate name"
|
||||
placeholder="Enter their company's name in english"
|
||||
defaultValue={companyName}
|
||||
required
|
||||
disabled={disabled}
|
||||
@@ -224,9 +259,9 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
defaultValue={monthlyDuration}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="flex flex-col gap-3 w-full lg:col-span-2">
|
||||
<div className="flex flex-col gap-3 w-full lg:col-span-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Pricing</label>
|
||||
<div className="w-full grid grid-cols-5 gap-2">
|
||||
<div className="w-full grid grid-cols-6 gap-2">
|
||||
<Input
|
||||
name="paymentValue"
|
||||
onChange={(e) => setPaymentValue(e ? parseInt(e) : undefined)}
|
||||
@@ -237,7 +272,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
/>
|
||||
<Select
|
||||
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 col-span-3 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
|
||||
disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
|
||||
)}
|
||||
options={CURRENCIES_OPTIONS}
|
||||
@@ -273,12 +308,17 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
<Select
|
||||
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",
|
||||
!["developer", "admin"].includes(loggedInUser.type) &&
|
||||
(!["developer", "admin"].includes(loggedInUser.type) || disabledFields.countryManager) &&
|
||||
"!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
|
||||
)}
|
||||
options={[
|
||||
{value: "", label: "No referral"},
|
||||
...users.filter((u) => u.type === "agent").map((x) => ({value: x.id, label: `${x.name} - ${x.email}`})),
|
||||
...users
|
||||
.filter((u) => u.type === "agent")
|
||||
.map((x) => ({
|
||||
value: x.id,
|
||||
label: `${x.name} - ${x.email}`,
|
||||
})),
|
||||
]}
|
||||
defaultValue={{
|
||||
value: referralAgent,
|
||||
@@ -304,7 +344,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
}),
|
||||
}}
|
||||
// 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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type Error = "E001" | "E002";
|
||||
export type Error = "E001" | "E002" | "E003";
|
||||
export interface ErrorMessage {
|
||||
error: Error;
|
||||
message: string;
|
||||
@@ -7,4 +7,5 @@ export interface ErrorMessage {
|
||||
export const errorMessages: {[key in Error]: string} = {
|
||||
E001: "Wrong password!",
|
||||
E002: "Invalid e-mail",
|
||||
E003: "E-mail already in use!",
|
||||
};
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
BsPencilSquare,
|
||||
BsBank,
|
||||
BsCurrencyDollar,
|
||||
BsLayoutWtf,
|
||||
BsLayoutSidebar,
|
||||
} from "react-icons/bs";
|
||||
import UserCard from "@/components/UserCard";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
@@ -309,6 +311,12 @@ export default function AdminDashboard({user}: Props) {
|
||||
value={pending.length}
|
||||
color="rose"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => router.push("https://cms.encoach.com/admin")}
|
||||
Icon={BsLayoutSidebar}
|
||||
label="Content Management System (CMS)"
|
||||
color="green"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<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 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">
|
||||
<span className="p-4">Latest corporate</span>
|
||||
<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 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">
|
||||
{users
|
||||
.filter(
|
||||
@@ -378,6 +399,22 @@ export default function AdminDashboard({user}: Props) {
|
||||
))}
|
||||
</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">
|
||||
<span className="p-4">Corporate expiring in 1 month</span>
|
||||
<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 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">
|
||||
{users
|
||||
.filter(
|
||||
@@ -418,6 +455,18 @@ export default function AdminDashboard({user}: Props) {
|
||||
))}
|
||||
</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">
|
||||
<span className="p-4">Expired Corporate</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
|
||||
@@ -1,115 +1,105 @@
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import { Module } from "@/interfaces";
|
||||
import { Assignment } from "@/interfaces/results";
|
||||
import { calculateBandScore } from "@/utils/score";
|
||||
import {Module} from "@/interfaces";
|
||||
import {Assignment} from "@/interfaces/results";
|
||||
import {calculateBandScore} from "@/utils/score";
|
||||
import clsx from "clsx";
|
||||
import moment from "moment";
|
||||
import {
|
||||
BsBook,
|
||||
BsClipboard,
|
||||
BsHeadphones,
|
||||
BsMegaphone,
|
||||
BsPen,
|
||||
} from "react-icons/bs";
|
||||
import { usePDFDownload } from "@/hooks/usePDFDownload";
|
||||
import { uniqBy } from "lodash";
|
||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||
import {usePDFDownload} from "@/hooks/usePDFDownload";
|
||||
import {useAssignmentArchive} from "@/hooks/useAssignmentArchive";
|
||||
import {uniqBy} from "lodash";
|
||||
import {useAssignmentUnarchive} from "@/hooks/useAssignmentUnarchive";
|
||||
|
||||
interface Props {
|
||||
onClick?: () => void;
|
||||
allowDownload?: boolean;
|
||||
onClick?: () => void;
|
||||
allowDownload?: boolean;
|
||||
reload?: Function;
|
||||
allowArchive?: boolean;
|
||||
allowUnarchive?: boolean;
|
||||
}
|
||||
|
||||
export default function AssignmentCard({
|
||||
id,
|
||||
name,
|
||||
assigner,
|
||||
startDate,
|
||||
endDate,
|
||||
assignees,
|
||||
results,
|
||||
exams,
|
||||
onClick,
|
||||
allowDownload,
|
||||
id,
|
||||
name,
|
||||
assigner,
|
||||
startDate,
|
||||
endDate,
|
||||
assignees,
|
||||
results,
|
||||
exams,
|
||||
archived,
|
||||
onClick,
|
||||
allowDownload,
|
||||
reload,
|
||||
allowArchive,
|
||||
allowUnarchive,
|
||||
}: Assignment & Props) {
|
||||
const { users } = useUsers();
|
||||
const renderPdfIcon = usePDFDownload("assignments");
|
||||
const renderPdfIcon = usePDFDownload("assignments");
|
||||
const renderArchiveIcon = useAssignmentArchive(id, reload);
|
||||
const renderUnarchiveIcon = useAssignmentUnarchive(id, reload);
|
||||
|
||||
const calculateAverageModuleScore = (module: Module) => {
|
||||
const resultModuleBandScores = results.map((r) => {
|
||||
const moduleStats = r.stats.filter((s) => s.module === module);
|
||||
const calculateAverageModuleScore = (module: Module) => {
|
||||
const resultModuleBandScores = results.map((r) => {
|
||||
const moduleStats = r.stats.filter((s) => s.module === module);
|
||||
|
||||
const correct = moduleStats.reduce(
|
||||
(acc, curr) => acc + curr.score.correct,
|
||||
0,
|
||||
);
|
||||
const total = moduleStats.reduce(
|
||||
(acc, curr) => acc + curr.score.total,
|
||||
0,
|
||||
);
|
||||
return calculateBandScore(correct, total, module, r.type);
|
||||
});
|
||||
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
|
||||
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
|
||||
return calculateBandScore(correct, total, module, r.type);
|
||||
});
|
||||
|
||||
return resultModuleBandScores.length === 0
|
||||
? -1
|
||||
: resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
|
||||
results.length;
|
||||
};
|
||||
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / results.length;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="border-mti-gray-platinum flex h-fit w-[350px] cursor-pointer flex-col gap-6 rounded-xl border bg-white p-4 transition duration-300 ease-in-out hover:drop-shadow"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-row justify-between">
|
||||
<h3 className="text-xl font-semibold">{name}</h3>
|
||||
{allowDownload &&
|
||||
renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
|
||||
</div>
|
||||
<ProgressBar
|
||||
color={results.length / assignees.length < 0.5 ? "red" : "purple"}
|
||||
percentage={(results.length / assignees.length) * 100}
|
||||
label={`${results.length}/${assignees.length}`}
|
||||
className="h-5"
|
||||
textClassName={
|
||||
results.length / assignees.length < 0.5
|
||||
? "!text-mti-gray-dim font-light"
|
||||
: "text-white"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<span className="flex justify-between gap-1">
|
||||
<span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
<span>-</span>
|
||||
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
</span>
|
||||
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
||||
{uniqBy(exams, (x) => x.module).map(({ module }) => (
|
||||
<div
|
||||
key={module}
|
||||
className={clsx(
|
||||
"-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||
module === "reading" && "bg-ielts-reading",
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}
|
||||
>
|
||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||
{calculateAverageModuleScore(module) > -1 && (
|
||||
<span className="text-sm">
|
||||
{calculateAverageModuleScore(module).toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="border-mti-gray-platinum flex h-fit w-[350px] cursor-pointer flex-col gap-6 rounded-xl border bg-white p-4 transition duration-300 ease-in-out hover:drop-shadow">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-row justify-between">
|
||||
<h3 className="text-xl font-semibold">{name}</h3>
|
||||
<div className="flex gap-2">
|
||||
{allowDownload && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
|
||||
{allowArchive && !archived && renderArchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
|
||||
{allowUnarchive && archived && renderUnarchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
|
||||
</div>
|
||||
</div>
|
||||
<ProgressBar
|
||||
color={results.length / assignees.length < 0.5 ? "red" : "purple"}
|
||||
percentage={(results.length / assignees.length) * 100}
|
||||
label={`${results.length}/${assignees.length}`}
|
||||
className="h-5"
|
||||
textClassName={results.length / assignees.length < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"}
|
||||
/>
|
||||
</div>
|
||||
<span className="flex justify-between gap-1">
|
||||
<span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
<span>-</span>
|
||||
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
</span>
|
||||
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
||||
{uniqBy(exams, (x) => x.module).map(({module}) => (
|
||||
<div
|
||||
key={module}
|
||||
className={clsx(
|
||||
"-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||
module === "reading" && "bg-ielts-reading",
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}>
|
||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||
{calculateAverageModuleScore(module) > -1 && (
|
||||
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ import {toast} from "react-toastify";
|
||||
import {uuidv4} from "@firebase/util";
|
||||
import {Assignment} from "@/interfaces/results";
|
||||
import Checkbox from "@/components/Low/Checkbox";
|
||||
import {Variant} from "@/interfaces/exam";
|
||||
import {InstructorGender, Variant} from "@/interfaces/exam";
|
||||
import Select from "@/components/Low/Select";
|
||||
|
||||
interface Props {
|
||||
isCreating: boolean;
|
||||
@@ -40,6 +41,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(),
|
||||
);
|
||||
const [variant, setVariant] = useState<Variant>("full");
|
||||
const [instructorGender, setInstructorGender] = useState<InstructorGender>(assignment?.instructorGender || "varied");
|
||||
// creates a new exam for each assignee or just one exam for all assignees
|
||||
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
|
||||
|
||||
@@ -63,6 +65,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
selectedModules,
|
||||
generateMultiple,
|
||||
variant,
|
||||
instructorGender,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`);
|
||||
@@ -226,6 +229,20 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor's Gender</label>
|
||||
<Select
|
||||
value={{value: instructorGender, label: capitalize(instructorGender)}}
|
||||
onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)}
|
||||
disabled={!selectedModules.includes("speaking") || !!assignment}
|
||||
options={[
|
||||
{value: "male", label: "Male"},
|
||||
{value: "female", label: "Female"},
|
||||
{value: "varied", label: "Varied"},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="w-full flex flex-col gap-3">
|
||||
<span className="font-semibold">Assignees ({assignees.length} selected)</span>
|
||||
<div className="flex gap-4 overflow-x-scroll scrollbar-hide">
|
||||
|
||||
@@ -1,354 +1,307 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import Modal from "@/components/Modal";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import { Module } from "@/interfaces";
|
||||
import { Assignment } from "@/interfaces/results";
|
||||
import { Stat, User } from "@/interfaces/user";
|
||||
import {Module} from "@/interfaces";
|
||||
import {Assignment} from "@/interfaces/results";
|
||||
import {Stat, User} from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import { getExamById } from "@/utils/exams";
|
||||
import { sortByModule } from "@/utils/moduleUtils";
|
||||
import { calculateBandScore } from "@/utils/score";
|
||||
import { convertToUserSolutions } from "@/utils/stats";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {sortByModule} from "@/utils/moduleUtils";
|
||||
import {calculateBandScore} from "@/utils/score";
|
||||
import {convertToUserSolutions} from "@/utils/stats";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import { capitalize, uniqBy } from "lodash";
|
||||
import {capitalize, uniqBy} from "lodash";
|
||||
import moment from "moment";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
BsBook,
|
||||
BsClipboard,
|
||||
BsHeadphones,
|
||||
BsMegaphone,
|
||||
BsPen,
|
||||
} from "react-icons/bs";
|
||||
import {useRouter} from "next/router";
|
||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
assignment?: Assignment;
|
||||
onClose: () => void;
|
||||
isOpen: boolean;
|
||||
assignment?: Assignment;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
||||
const { users } = useUsers();
|
||||
const router = useRouter();
|
||||
export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
||||
const {users} = useUsers();
|
||||
const router = useRouter();
|
||||
|
||||
const setExams = useExamStore((state) => state.setExams);
|
||||
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
|
||||
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||
const setExams = useExamStore((state) => state.setExams);
|
||||
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
|
||||
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
const date = moment(parseInt(timestamp));
|
||||
const formatter = "YYYY/MM/DD - HH:mm";
|
||||
const deleteAssignment = async () => {
|
||||
if (!confirm("Are you sure you want to delete this assignment?")) return;
|
||||
|
||||
return date.format(formatter);
|
||||
};
|
||||
axios
|
||||
.delete(`/api/assignments/${assignment?.id}`)
|
||||
.then(() => toast.success(`Successfully deleted the assignment "${assignment?.name}".`))
|
||||
.catch(() => toast.error("Something went wrong, please try again later."))
|
||||
.finally(onClose);
|
||||
};
|
||||
|
||||
const calculateAverageModuleScore = (module: Module) => {
|
||||
if (!assignment) return -1;
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
const date = moment(parseInt(timestamp));
|
||||
const formatter = "YYYY/MM/DD - HH:mm";
|
||||
|
||||
const resultModuleBandScores = assignment.results.map((r) => {
|
||||
const moduleStats = r.stats.filter((s) => s.module === module);
|
||||
return date.format(formatter);
|
||||
};
|
||||
|
||||
const correct = moduleStats.reduce(
|
||||
(acc, curr) => acc + curr.score.correct,
|
||||
0,
|
||||
);
|
||||
const total = moduleStats.reduce(
|
||||
(acc, curr) => acc + curr.score.total,
|
||||
0,
|
||||
);
|
||||
return calculateBandScore(correct, total, module, r.type);
|
||||
});
|
||||
const calculateAverageModuleScore = (module: Module) => {
|
||||
if (!assignment) return -1;
|
||||
|
||||
return resultModuleBandScores.length === 0
|
||||
? -1
|
||||
: resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
|
||||
assignment.results.length;
|
||||
};
|
||||
const resultModuleBandScores = assignment.results.map((r) => {
|
||||
const moduleStats = r.stats.filter((s) => s.module === module);
|
||||
|
||||
const aggregateScoresByModule = (
|
||||
stats: Stat[],
|
||||
): { module: Module; total: number; missing: number; correct: number }[] => {
|
||||
const scores: {
|
||||
[key in Module]: { total: number; missing: number; correct: number };
|
||||
} = {
|
||||
reading: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
listening: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
writing: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
speaking: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
level: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
};
|
||||
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
|
||||
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
|
||||
return calculateBandScore(correct, total, module, r.type);
|
||||
});
|
||||
|
||||
stats.forEach((x) => {
|
||||
scores[x.module!] = {
|
||||
total: scores[x.module!].total + x.score.total,
|
||||
correct: scores[x.module!].correct + x.score.correct,
|
||||
missing: scores[x.module!].missing + x.score.missing,
|
||||
};
|
||||
});
|
||||
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length;
|
||||
};
|
||||
|
||||
return Object.keys(scores)
|
||||
.filter((x) => scores[x as Module].total > 0)
|
||||
.map((x) => ({ module: x as Module, ...scores[x as Module] }));
|
||||
};
|
||||
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => {
|
||||
const scores: {
|
||||
[key in Module]: {total: number; missing: number; correct: number};
|
||||
} = {
|
||||
reading: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
listening: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
writing: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
speaking: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
level: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const customContent = (
|
||||
stats: Stat[],
|
||||
user: string,
|
||||
focus: "academic" | "general",
|
||||
) => {
|
||||
const correct = stats.reduce(
|
||||
(accumulator, current) => accumulator + current.score.correct,
|
||||
0,
|
||||
);
|
||||
const total = stats.reduce(
|
||||
(accumulator, current) => accumulator + current.score.total,
|
||||
0,
|
||||
);
|
||||
const aggregatedScores = aggregateScoresByModule(stats).filter(
|
||||
(x) => x.total > 0,
|
||||
);
|
||||
stats.forEach((x) => {
|
||||
scores[x.module!] = {
|
||||
total: scores[x.module!].total + x.score.total,
|
||||
correct: scores[x.module!].correct + x.score.correct,
|
||||
missing: scores[x.module!].missing + x.score.missing,
|
||||
};
|
||||
});
|
||||
|
||||
const aggregatedLevels = aggregatedScores.map((x) => ({
|
||||
module: x.module,
|
||||
level: calculateBandScore(x.correct, x.total, x.module, focus),
|
||||
}));
|
||||
return Object.keys(scores)
|
||||
.filter((x) => scores[x as Module].total > 0)
|
||||
.map((x) => ({module: x as Module, ...scores[x as Module]}));
|
||||
};
|
||||
|
||||
const timeSpent = stats[0].timeSpent;
|
||||
const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => {
|
||||
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
|
||||
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
|
||||
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
|
||||
|
||||
const selectExam = () => {
|
||||
const examPromises = uniqBy(stats, "exam").map((stat) =>
|
||||
getExamById(stat.module, stat.exam),
|
||||
);
|
||||
const aggregatedLevels = aggregatedScores.map((x) => ({
|
||||
module: x.module,
|
||||
level: calculateBandScore(x.correct, x.total, x.module, focus),
|
||||
}));
|
||||
|
||||
Promise.all(examPromises).then((exams) => {
|
||||
if (exams.every((x) => !!x)) {
|
||||
setUserSolutions(convertToUserSolutions(stats));
|
||||
setShowSolutions(true);
|
||||
setExams(exams.map((x) => x!).sort(sortByModule));
|
||||
setSelectedModules(
|
||||
exams
|
||||
.map((x) => x!)
|
||||
.sort(sortByModule)
|
||||
.map((x) => x!.module),
|
||||
);
|
||||
router.push("/exercises");
|
||||
}
|
||||
});
|
||||
};
|
||||
const timeSpent = stats[0].timeSpent;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<div className="-md:items-center flex w-full justify-between 2xl:items-center">
|
||||
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
|
||||
<span className="font-medium">
|
||||
{formatTimestamp(stats[0].date.toString())}
|
||||
</span>
|
||||
{timeSpent && (
|
||||
<>
|
||||
<span className="md:hidden 2xl:flex">• </span>
|
||||
<span className="text-sm">
|
||||
{Math.floor(timeSpent / 60)} minutes
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={clsx(
|
||||
correct / total >= 0.7 && "text-mti-purple",
|
||||
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
||||
correct / total < 0.3 && "text-mti-rose",
|
||||
)}
|
||||
>
|
||||
Level{" "}
|
||||
{(
|
||||
aggregatedLevels.reduce(
|
||||
(accumulator, current) => accumulator + current.level,
|
||||
0,
|
||||
) / aggregatedLevels.length
|
||||
).toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
const selectExam = () => {
|
||||
const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam));
|
||||
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
||||
{aggregatedLevels.map(({ module, level }) => (
|
||||
<div
|
||||
key={module}
|
||||
className={clsx(
|
||||
"-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||
module === "reading" && "bg-ielts-reading",
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}
|
||||
>
|
||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||
<span className="text-sm">{level.toFixed(1)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
Promise.all(examPromises).then((exams) => {
|
||||
if (exams.every((x) => !!x)) {
|
||||
setUserSolutions(convertToUserSolutions(stats));
|
||||
setShowSolutions(true);
|
||||
setExams(exams.map((x) => x!).sort(sortByModule));
|
||||
setSelectedModules(
|
||||
exams
|
||||
.map((x) => x!)
|
||||
.sort(sortByModule)
|
||||
.map((x) => x!.module),
|
||||
);
|
||||
router.push("/exercises");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span>
|
||||
{(() => {
|
||||
const student = users.find((u) => u.id === user);
|
||||
return `${student?.name} (${student?.email})`;
|
||||
})()}
|
||||
</span>
|
||||
<div
|
||||
key={user}
|
||||
className={clsx(
|
||||
"border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
|
||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||
correct / total >= 0.3 &&
|
||||
correct / total < 0.7 &&
|
||||
"hover:border-mti-red",
|
||||
correct / total < 0.3 && "hover:border-mti-rose",
|
||||
)}
|
||||
onClick={selectExam}
|
||||
role="button"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
<div
|
||||
key={user}
|
||||
className={clsx(
|
||||
"border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden",
|
||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||
correct / total >= 0.3 &&
|
||||
correct / total < 0.7 &&
|
||||
"hover:border-mti-red",
|
||||
correct / total < 0.3 && "hover:border-mti-rose",
|
||||
)}
|
||||
data-tip="Your screen size is too small to view previous exams."
|
||||
role="button"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const content = (
|
||||
<>
|
||||
<div className="-md:items-center flex w-full justify-between 2xl:items-center">
|
||||
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
|
||||
<span className="font-medium">{formatTimestamp(stats[0].date.toString())}</span>
|
||||
{timeSpent && (
|
||||
<>
|
||||
<span className="md:hidden 2xl:flex">• </span>
|
||||
<span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={clsx(
|
||||
correct / total >= 0.7 && "text-mti-purple",
|
||||
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
||||
correct / total < 0.3 && "text-mti-rose",
|
||||
)}>
|
||||
Level{" "}
|
||||
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}>
|
||||
<div className="mt-4 flex w-full flex-col gap-4">
|
||||
<ProgressBar
|
||||
color="purple"
|
||||
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
|
||||
className="h-6"
|
||||
textClassName={
|
||||
(assignment?.results.length || 0) /
|
||||
(assignment?.assignees.length || 1) <
|
||||
0.5
|
||||
? "!text-mti-gray-dim font-light"
|
||||
: "text-white"
|
||||
}
|
||||
percentage={
|
||||
((assignment?.results.length || 0) /
|
||||
(assignment?.assignees.length || 1)) *
|
||||
100
|
||||
}
|
||||
/>
|
||||
<div className="flex items-start gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span>
|
||||
Start Date:{" "}
|
||||
{moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}
|
||||
</span>
|
||||
<span>
|
||||
End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}
|
||||
</span>
|
||||
</div>
|
||||
<span>
|
||||
Assignees:{" "}
|
||||
{users
|
||||
.filter((u) => assignment?.assignees.includes(u.id))
|
||||
.map((u) => `${u.name} (${u.email})`)
|
||||
.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xl font-bold">Average Scores</span>
|
||||
<div className="-md:mt-2 flex w-full items-center gap-4">
|
||||
{assignment &&
|
||||
uniqBy(assignment.exams, (x) => x.module).map(({ module }) => (
|
||||
<div
|
||||
data-tip={capitalize(module)}
|
||||
key={module}
|
||||
className={clsx(
|
||||
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||
module === "reading" && "bg-ielts-reading",
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}
|
||||
>
|
||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||
{module === "listening" && (
|
||||
<BsHeadphones className="h-4 w-4" />
|
||||
)}
|
||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||
{calculateAverageModuleScore(module) > -1 && (
|
||||
<span className="text-sm">
|
||||
{calculateAverageModuleScore(module).toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xl font-bold">
|
||||
Results ({assignment?.results.length}/{assignment?.assignees.length}
|
||||
)
|
||||
</span>
|
||||
<div>
|
||||
{assignment && assignment?.results.length > 0 && (
|
||||
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
|
||||
{assignment.results.map((r) =>
|
||||
customContent(r.stats, r.user, r.type),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{assignment && assignment?.results.length === 0 && (
|
||||
<span className="ml-1 font-semibold">No results yet...</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
||||
{aggregatedLevels.map(({module, level}) => (
|
||||
<div
|
||||
key={module}
|
||||
className={clsx(
|
||||
"-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||
module === "reading" && "bg-ielts-reading",
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}>
|
||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||
<span className="text-sm">{level.toFixed(1)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span>
|
||||
{(() => {
|
||||
const student = users.find((u) => u.id === user);
|
||||
return `${student?.name} (${student?.email})`;
|
||||
})()}
|
||||
</span>
|
||||
<div
|
||||
key={user}
|
||||
className={clsx(
|
||||
"border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
|
||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
||||
correct / total < 0.3 && "hover:border-mti-rose",
|
||||
)}
|
||||
onClick={selectExam}
|
||||
role="button">
|
||||
{content}
|
||||
</div>
|
||||
<div
|
||||
key={user}
|
||||
className={clsx(
|
||||
"border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden",
|
||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
||||
correct / total < 0.3 && "hover:border-mti-rose",
|
||||
)}
|
||||
data-tip="Your screen size is too small to view previous exams."
|
||||
role="button">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}>
|
||||
<div className="mt-4 flex w-full flex-col gap-4">
|
||||
<ProgressBar
|
||||
color="purple"
|
||||
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
|
||||
className="h-6"
|
||||
textClassName={
|
||||
(assignment?.results.length || 0) / (assignment?.assignees.length || 1) < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"
|
||||
}
|
||||
percentage={((assignment?.results.length || 0) / (assignment?.assignees.length || 1)) * 100}
|
||||
/>
|
||||
<div className="flex items-start gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
<span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
</div>
|
||||
<span>
|
||||
Assignees:{" "}
|
||||
{users
|
||||
.filter((u) => assignment?.assignees.includes(u.id))
|
||||
.map((u) => `${u.name} (${u.email})`)
|
||||
.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xl font-bold">Average Scores</span>
|
||||
<div className="-md:mt-2 flex w-full items-center gap-4">
|
||||
{assignment &&
|
||||
uniqBy(assignment.exams, (x) => x.module).map(({module}) => (
|
||||
<div
|
||||
data-tip={capitalize(module)}
|
||||
key={module}
|
||||
className={clsx(
|
||||
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||
module === "reading" && "bg-ielts-reading",
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}>
|
||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||
{calculateAverageModuleScore(module) > -1 && (
|
||||
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xl font-bold">
|
||||
Results ({assignment?.results.length}/{assignment?.assignees.length})
|
||||
</span>
|
||||
<div>
|
||||
{assignment && assignment?.results.length > 0 && (
|
||||
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
|
||||
{assignment.results.map((r) => customContent(r.stats, r.user, r.type))}
|
||||
</div>
|
||||
)}
|
||||
{assignment && assignment?.results.length === 0 && <span className="ml-1 font-semibold">No results yet...</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 w-full items-center justify-end">
|
||||
{assignment && (assignment.results.length === assignment.assignees.length || moment().isAfter(moment(assignment.endDate))) && (
|
||||
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={deleteAssignment}>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onClose} className="w-full max-w-[200px]">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import {IconType} from "react-icons";
|
||||
interface Props {
|
||||
Icon: IconType;
|
||||
label: string;
|
||||
value: string | number;
|
||||
color: "purple" | "rose" | "red";
|
||||
value?: string | number;
|
||||
color: "purple" | "rose" | "red" | "green";
|
||||
tooltip?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export default function IconCard({Icon, label, value, color, tooltip, onClick}:
|
||||
purple: "text-mti-purple-light",
|
||||
red: "text-mti-red-light",
|
||||
rose: "text-mti-rose-light",
|
||||
green: "text-mti-green-light",
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -13,7 +13,8 @@ import {CorporateUser, User} from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
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 {averageScore, groupBySession} from "@/utils/stats";
|
||||
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js";
|
||||
import {PayPalButtons} from "@paypal/react-paypal-js";
|
||||
@@ -34,7 +35,7 @@ interface Props {
|
||||
export default function StudentDashboard({user}: Props) {
|
||||
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
|
||||
|
||||
const {stats} = useStats(user.id);
|
||||
const {stats} = useStats(user.id, !user?.id);
|
||||
const {users} = useUsers();
|
||||
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id});
|
||||
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user.id});
|
||||
@@ -83,19 +84,22 @@ export default function StudentDashboard({user}: Props) {
|
||||
user={user}
|
||||
items={[
|
||||
{
|
||||
icon: <BsFileEarmarkText className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
|
||||
value: Object.keys(groupBySession(stats)).length,
|
||||
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||
value: countFullExams(stats),
|
||||
label: "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" />,
|
||||
value: stats.length,
|
||||
label: "Exercises",
|
||||
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||
value: countExamModules(stats),
|
||||
label: "Modules",
|
||||
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" />,
|
||||
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
|
||||
label: "Average Score",
|
||||
tooltip: "Average success rate for questions responded",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -224,35 +228,40 @@ export default function StudentDashboard({user}: Props) {
|
||||
<section className="flex flex-col gap-3">
|
||||
<span className="text-lg font-bold">Score History</span>
|
||||
<div className="-md:grid-rows-4 grid gap-6 md:grid-cols-2">
|
||||
{MODULE_ARRAY.map((module) => (
|
||||
<div className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4" key={module}>
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
<div className="bg-mti-gray-smoke flex h-8 w-8 items-center justify-center rounded-lg md:h-12 md:w-12 md:rounded-xl">
|
||||
{module === "reading" && <BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" />}
|
||||
{module === "listening" && <BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />}
|
||||
{module === "writing" && <BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" />}
|
||||
{module === "speaking" && <BsMegaphone className="text-ielts-speaking h-4 w-4 md:h-5 md:w-5" />}
|
||||
{module === "level" && <BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" />}
|
||||
{MODULE_ARRAY.map((module) => {
|
||||
const desiredLevel = user.desiredLevels[module] || 9;
|
||||
const level = user.levels[module] || 0;
|
||||
return (
|
||||
<div className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4" key={module}>
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
<div className="bg-mti-gray-smoke flex h-8 w-8 items-center justify-center rounded-lg md:h-12 md:w-12 md:rounded-xl">
|
||||
{module === "reading" && <BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" />}
|
||||
{module === "listening" && <BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />}
|
||||
{module === "writing" && <BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" />}
|
||||
{module === "speaking" && <BsMegaphone className="text-ielts-speaking h-4 w-4 md:h-5 md:w-5" />}
|
||||
{module === "level" && <BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" />}
|
||||
</div>
|
||||
<div className="flex w-full justify-between">
|
||||
<span className="text-sm font-bold md:font-extrabold">{capitalize(module)}</span>
|
||||
<span className="text-mti-gray-dim text-sm font-normal">
|
||||
{module === "level" && `English Level: ${getLevelLabel(level).join(" / ")}`}
|
||||
{module !== "level" && `Level ${level} / Level 9 (Desired Level: ${desiredLevel})`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full justify-between">
|
||||
<span className="text-sm font-bold md:font-extrabold">{capitalize(module)}</span>
|
||||
<span className="text-mti-gray-dim text-sm font-normal">
|
||||
Level {user.levels[module] || 0} / Level 9 (Desired Level: {user.desiredLevels[module] || 9})
|
||||
</span>
|
||||
<div className="md:pl-14">
|
||||
<ProgressBar
|
||||
color={module}
|
||||
label=""
|
||||
mark={Math.round((desiredLevel * 100) / 9)}
|
||||
markLabel={`Desired Level: ${desiredLevel}`}
|
||||
percentage={Math.round((level * 100) / 9)}
|
||||
className="h-2 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:pl-14">
|
||||
<ProgressBar
|
||||
color={module}
|
||||
label=""
|
||||
mark={Math.round((user.desiredLevels[module] * 100) / 9)}
|
||||
markLabel={`Desired Level: ${user.desiredLevels[module]}`}
|
||||
percentage={Math.round((user.levels[module] * 100) / 9)}
|
||||
className="h-2 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
|
||||
@@ -153,7 +153,8 @@ export default function TeacherDashboard({user}: Props) {
|
||||
const AssignmentsPage = () => {
|
||||
const activeFilter = (a: Assignment) =>
|
||||
moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()) && a.assignees.length > a.results.length;
|
||||
const pastFilter = (a: Assignment) => moment(a.endDate).isBefore(moment()) || a.assignees.length === a.results.length;
|
||||
const 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());
|
||||
|
||||
return (
|
||||
@@ -163,6 +164,7 @@ export default function TeacherDashboard({user}: Props) {
|
||||
onClose={() => {
|
||||
setSelectedAssignment(undefined);
|
||||
setIsCreatingAssignment(false);
|
||||
reloadAssignments();
|
||||
}}
|
||||
assignment={selectedAssignment}
|
||||
/>
|
||||
@@ -234,7 +236,29 @@ export default function TeacherDashboard({user}: Props) {
|
||||
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{assignments.filter(pastFilter).map((a) => (
|
||||
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} allowDownload />
|
||||
<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>
|
||||
</section>
|
||||
@@ -280,7 +304,7 @@ export default function TeacherDashboard({user}: Props) {
|
||||
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
||||
<span className="flex flex-col gap-1 items-center text-xl">
|
||||
<span className="text-lg">Assignments</span>
|
||||
<span className="font-semibold text-mti-purple-light">{assignments.length}</span>
|
||||
<span className="font-semibold text-mti-purple-light">{assignments.filter((a) => !a.archived).length}</span>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</p>
|
||||
<br />
|
||||
<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 />
|
||||
<p>Thanks,</p>
|
||||
<p>Your EnCoach team</p>
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
<img src="/logo_title.png" class="w-48 h-48 self-center" />
|
||||
<div>
|
||||
<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
|
||||
become a
|
||||
{{type}}!</span><br />
|
||||
@@ -19,7 +20,7 @@
|
||||
</div>
|
||||
<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">
|
||||
<b>{{code}}</b>
|
||||
</span>
|
||||
|
||||
35
src/email/templates/ticketStatusCompleted.handlebars
Normal file
35
src/email/templates/ticketStatusCompleted.handlebars
Normal file
@@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<div style="background-color: #ffffff; color: #353338;"
|
||||
class="h-full min-h-screen w-full flex flex-col p-8 gap-16 text-base">
|
||||
<img src="/logo_title.png" class="w-48 h-48 self-center" />
|
||||
<div>
|
||||
<span>Your ticket has been completed!</span>
|
||||
<br/>
|
||||
<span>Here is the ticket's information:</span>
|
||||
<br/>
|
||||
<br/>
|
||||
<span><b>ID:</b> {{id}}</span><br/>
|
||||
<span><b>Subject:</b> {{subject}}</span><br/>
|
||||
<span><b>Reporter:</b> {{reporter.name}} - {{reporter.email}}</span><br/>
|
||||
<span><b>Date:</b> {{date}}</span><br/>
|
||||
<span><b>Type:</b> {{type}}</span><br/>
|
||||
<span><b>Page:</b> {{reportedFrom}}</span>
|
||||
<br/>
|
||||
<br/>
|
||||
<span><b>Description:</b> {{description}}</span><br/>
|
||||
</div>
|
||||
<br />
|
||||
<br />
|
||||
<div>
|
||||
<span>Thanks, <br /> Your EnCoach team</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</html>
|
||||
@@ -10,7 +10,8 @@
|
||||
<p>Hello {{name}},</p>
|
||||
<br />
|
||||
<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 />
|
||||
<p>If you didn’t ask to verify this address, you can ignore this email.</p>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "Tiago Ribeiro",
|
||||
"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 {LevelScore} from "@/constants/ielts";
|
||||
import {getLevelScore} from "@/utils/score";
|
||||
import {capitalize} from "lodash";
|
||||
|
||||
interface Score {
|
||||
module: Module;
|
||||
@@ -25,7 +26,7 @@ interface Props {
|
||||
modules: Module[];
|
||||
scores: Score[];
|
||||
isLoading: boolean;
|
||||
onViewResults: () => void;
|
||||
onViewResults: (moduleIndex?: number) => void;
|
||||
}
|
||||
|
||||
export default function Finish({user, scores, modules, isLoading, onViewResults}: Props) {
|
||||
@@ -182,33 +183,37 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
{showLevel(bandScore)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex gap-2">
|
||||
<div className="bg-mti-red-light mt-1 h-3 w-3 rounded-full" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-mti-red-light">
|
||||
{(((selectedScore.total - selectedScore.missing) / selectedScore.total) * 100).toFixed(0)}%
|
||||
</span>
|
||||
<span className="text-lg">Completion</span>
|
||||
{!["writing", "speaking"].includes(selectedModule) ? (
|
||||
<div className="flex flex-col gap-5 w-28">
|
||||
<div className="flex gap-2">
|
||||
<div className="bg-mti-red-light mt-1 h-3 w-3 rounded-full" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-mti-red-light">
|
||||
{(((selectedScore.total - selectedScore.missing) / selectedScore.total) * 100).toFixed(0)}%
|
||||
</span>
|
||||
<span className="text-lg">Completion</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="bg-mti-purple-light mt-1 h-3 w-3 rounded-full" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-mti-purple-light">{selectedScore.correct.toString().padStart(2, "0")}</span>
|
||||
<span className="text-lg">Correct</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="bg-mti-rose-light mt-1 h-3 w-3 rounded-full" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-mti-rose-light">
|
||||
{(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")}
|
||||
</span>
|
||||
<span className="text-lg">Wrong</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="bg-mti-purple-light mt-1 h-3 w-3 rounded-full" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-mti-purple-light">{selectedScore.correct.toString().padStart(2, "0")}</span>
|
||||
<span className="text-lg">Correct</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="bg-mti-rose-light mt-1 h-3 w-3 rounded-full" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-mti-rose-light">
|
||||
{(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")}
|
||||
</span>
|
||||
<span className="text-lg">Wrong</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-28 h-full" />
|
||||
)}
|
||||
</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">
|
||||
<button
|
||||
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">
|
||||
<BsArrowCounterclockwise className="h-7 w-7 text-white" />
|
||||
</button>
|
||||
@@ -227,11 +233,19 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
</div>
|
||||
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
||||
<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">
|
||||
<BsEyeFill className="h-7 w-7 text-white" />
|
||||
</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>
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
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);
|
||||
|
||||
@@ -52,17 +52,15 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
|
||||
setHasExamEnded(false);
|
||||
|
||||
if (solution) {
|
||||
onFinish(
|
||||
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "level", exam: exam.id})),
|
||||
);
|
||||
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]);
|
||||
} else {
|
||||
onFinish(userSolutions.map((x) => ({...x, module: "level", exam: exam.id})));
|
||||
onFinish(userSolutions);
|
||||
}
|
||||
};
|
||||
|
||||
const previousExercise = (solution?: UserSolution) => {
|
||||
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) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ListeningExam, UserSolution} from "@/interfaces/exam";
|
||||
import {ListeningExam, MultipleChoiceExercise, UserSolution} from "@/interfaces/exam";
|
||||
import {useEffect, useState} from "react";
|
||||
import {renderExercise} from "@/components/Exercises";
|
||||
import {renderSolution} from "@/components/Solutions";
|
||||
@@ -16,18 +16,20 @@ interface Props {
|
||||
}
|
||||
|
||||
const INSTRUCTIONS_AUDIO_SRC =
|
||||
"https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_recordings%2Fgeneric_intro.mp3?alt=media&token=9b9cfdb8-e90d-40d1-854b-51c4378a5c4b";
|
||||
"https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/generic_listening_intro_v2.mp3?alt=media&token=16769f5f-1e9b-4a72-86a9-45a6f0fa9f82";
|
||||
|
||||
export default function Listening({exam, showSolutions = false, onFinish}: Props) {
|
||||
const [questionIndex, setQuestionIndex] = useState(0);
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [timesListened, setTimesListened] = useState(0);
|
||||
const [showBlankModal, setShowBlankModal] = useState(false);
|
||||
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]);
|
||||
|
||||
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
||||
const {partIndex, setPartIndex} = useExamStore((state) => state);
|
||||
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
||||
const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]);
|
||||
|
||||
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);
|
||||
}, [setExerciseIndex, showSolutions]);
|
||||
|
||||
// useEffect(() => {
|
||||
// if (exam.variant !== "partial") setPartIndex(-1);
|
||||
// }, [exam.variant, setPartIndex]);
|
||||
useEffect(() => {
|
||||
if (partIndex === -1 && exam.variant === "partial") {
|
||||
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(() => {
|
||||
if (hasExamEnded && exerciseIndex === -1) {
|
||||
@@ -55,15 +74,19 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
return;
|
||||
}
|
||||
|
||||
onFinish(userSolutions.map((x) => ({...x, module: "listening", exam: exam.id})));
|
||||
onFinish(userSolutions);
|
||||
};
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
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) {
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
@@ -72,6 +95,7 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
|
||||
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
|
||||
setPartIndex(partIndex + 1);
|
||||
setTimesListened(0);
|
||||
setExerciseIndex(showSolutions ? 0 : -1);
|
||||
return;
|
||||
}
|
||||
@@ -91,19 +115,18 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
setHasExamEnded(false);
|
||||
|
||||
if (solution) {
|
||||
onFinish(
|
||||
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "listening", exam: exam.id})),
|
||||
);
|
||||
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "listening", exam: exam.id}]);
|
||||
} else {
|
||||
onFinish(userSolutions.map((x) => ({...x, module: "listening", exam: exam.id})));
|
||||
onFinish(userSolutions);
|
||||
}
|
||||
};
|
||||
|
||||
const previousExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
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);
|
||||
};
|
||||
@@ -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 = () => (
|
||||
<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">
|
||||
@@ -155,18 +203,7 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
||||
<div className="flex flex-col h-full w-full gap-8 justify-between">
|
||||
<ModuleTitle
|
||||
exerciseIndex={
|
||||
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
|
||||
}
|
||||
exerciseIndex={calculateExerciseIndex()}
|
||||
minTimer={exam.minTimer}
|
||||
module="listening"
|
||||
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 Icon from "@mdi/react";
|
||||
import {mdiArrowRight, mdiNotebook} from "@mdi/js";
|
||||
@@ -10,7 +10,7 @@ import {renderExercise} from "@/components/Exercises";
|
||||
import {renderSolution} from "@/components/Solutions";
|
||||
import {Panel} from "primereact/panel";
|
||||
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 ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||
import {Divider} from "primereact/divider";
|
||||
@@ -26,6 +26,8 @@ interface Props {
|
||||
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}) {
|
||||
return (
|
||||
<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) {
|
||||
const [questionIndex, setQuestionIndex] = useState(0);
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [showTextModal, setShowTextModal] = 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 {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
||||
const {partIndex, setPartIndex} = 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));
|
||||
|
||||
@@ -98,6 +126,21 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
||||
if (showSolutions) setExerciseIndex(-1);
|
||||
}, [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(() => {
|
||||
const listener = (e: KeyboardEvent) => {
|
||||
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;
|
||||
}
|
||||
|
||||
onFinish(userSolutions.map((x) => ({...x, module: "reading", exam: exam.id})));
|
||||
onFinish(userSolutions);
|
||||
};
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
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);
|
||||
|
||||
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
||||
@@ -165,18 +212,16 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
||||
setHasExamEnded(false);
|
||||
|
||||
if (solution) {
|
||||
onFinish(
|
||||
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "reading", exam: exam.id})),
|
||||
);
|
||||
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "reading", exam: exam.id}]);
|
||||
} else {
|
||||
onFinish(userSolutions.map((x) => ({...x, module: "reading", exam: exam.id})));
|
||||
onFinish(userSolutions);
|
||||
}
|
||||
};
|
||||
|
||||
const previousExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
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);
|
||||
|
||||
@@ -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 = () => (
|
||||
<div className="flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16 mt-4">
|
||||
<div className="flex flex-col w-full gap-2">
|
||||
<h4 className="text-xl font-semibold">
|
||||
Please read the following excerpt attentively, you will then be asked questions about the text you've read.
|
||||
</h4>
|
||||
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<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">
|
||||
{exam.parts[partIndex].text.content.split("\\n").map((line, index) => (
|
||||
<p key={index}>{line}</p>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<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">
|
||||
<h4 className="text-xl font-semibold">
|
||||
Please read the following excerpt attentively, you will then be asked questions about the text you've read.
|
||||
</h4>
|
||||
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
|
||||
</div>
|
||||
<TextComponent part={exam.parts[partIndex]} exerciseType={exerciseType} />
|
||||
</>
|
||||
)}
|
||||
{isTextMinimized && <span className="font-semibold">Reading Passage</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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)} />}
|
||||
<ModuleTitle
|
||||
minTimer={exam.minTimer}
|
||||
exerciseIndex={
|
||||
(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
|
||||
}
|
||||
exerciseIndex={calculateExerciseIndex()}
|
||||
module="reading"
|
||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||
disableTimer={showSolutions}
|
||||
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()}
|
||||
|
||||
{exerciseIndex > -1 &&
|
||||
|
||||
@@ -1,305 +1,442 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {useState} from "react";
|
||||
import {Module} from "@/interfaces";
|
||||
import { useState } from "react";
|
||||
import { Module } from "@/interfaces";
|
||||
import clsx from "clsx";
|
||||
import {User} from "@/interfaces/user";
|
||||
import { User } from "@/interfaces/user";
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import {BsArrowRepeat, BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
||||
import {totalExamsByModule} from "@/utils/stats";
|
||||
import {
|
||||
BsArrowRepeat,
|
||||
BsBook,
|
||||
BsCheck,
|
||||
BsCheckCircle,
|
||||
BsClipboard,
|
||||
BsHeadphones,
|
||||
BsMegaphone,
|
||||
BsPen,
|
||||
BsXCircle,
|
||||
} from "react-icons/bs";
|
||||
import { totalExamsByModule } from "@/utils/stats";
|
||||
import useStats from "@/hooks/useStats";
|
||||
import Button from "@/components/Low/Button";
|
||||
import {calculateAverageLevel} from "@/utils/score";
|
||||
import {sortByModuleName} from "@/utils/moduleUtils";
|
||||
import {capitalize} from "lodash";
|
||||
import { calculateAverageLevel } from "@/utils/score";
|
||||
import { sortByModuleName } from "@/utils/moduleUtils";
|
||||
import { capitalize } from "lodash";
|
||||
import ProfileSummary from "@/components/ProfileSummary";
|
||||
import {Variant} from "@/interfaces/exam";
|
||||
import useSessions, {Session} from "@/hooks/useSessions";
|
||||
import { Variant } from "@/interfaces/exam";
|
||||
import useSessions, { Session } from "@/hooks/useSessions";
|
||||
import SessionCard from "@/components/Medium/SessionCard";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import moment from "moment";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
page: "exercises" | "exams";
|
||||
onStart: (modules: Module[], avoidRepeated: boolean, variant: Variant) => void;
|
||||
disableSelection?: boolean;
|
||||
user: User;
|
||||
page: "exercises" | "exams";
|
||||
onStart: (
|
||||
modules: Module[],
|
||||
avoidRepeated: boolean,
|
||||
variant: Variant,
|
||||
) => void;
|
||||
disableSelection?: boolean;
|
||||
}
|
||||
|
||||
export default function Selection({user, page, onStart, disableSelection = false}: Props) {
|
||||
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
|
||||
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
|
||||
const [variant, setVariant] = useState<Variant>("full");
|
||||
export default function Selection({
|
||||
user,
|
||||
page,
|
||||
onStart,
|
||||
disableSelection = false,
|
||||
}: Props) {
|
||||
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
|
||||
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
|
||||
const [variant, setVariant] = useState<Variant>("full");
|
||||
|
||||
const {stats} = useStats(user?.id);
|
||||
const {sessions, isLoading, reload} = useSessions(user.id);
|
||||
const { stats } = useStats(user?.id);
|
||||
const { sessions, isLoading, reload } = useSessions(user.id);
|
||||
|
||||
const state = useExamStore((state) => state);
|
||||
const state = useExamStore((state) => state);
|
||||
|
||||
const toggleModule = (module: Module) => {
|
||||
const modules = selectedModules.filter((x) => x !== module);
|
||||
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module]));
|
||||
};
|
||||
const toggleModule = (module: Module) => {
|
||||
const modules = selectedModules.filter((x) => x !== module);
|
||||
setSelectedModules((prev) =>
|
||||
prev.includes(module) ? modules : [...modules, module],
|
||||
);
|
||||
};
|
||||
|
||||
const loadSession = async (session: Session) => {
|
||||
state.setSelectedModules(session.selectedModules);
|
||||
state.setExam(session.exam);
|
||||
state.setExams(session.exams);
|
||||
state.setSessionId(session.sessionId);
|
||||
state.setAssignment(session.assignment);
|
||||
state.setExerciseIndex(session.exerciseIndex);
|
||||
state.setPartIndex(session.partIndex);
|
||||
state.setModuleIndex(session.moduleIndex);
|
||||
state.setTimeSpent(session.timeSpent);
|
||||
state.setUserSolutions(session.userSolutions);
|
||||
state.setShowSolutions(false);
|
||||
state.setQuestionIndex(session.questionIndex);
|
||||
};
|
||||
const loadSession = async (session: Session) => {
|
||||
state.setSelectedModules(session.selectedModules);
|
||||
state.setExam(session.exam);
|
||||
state.setExams(session.exams);
|
||||
state.setSessionId(session.sessionId);
|
||||
state.setAssignment(session.assignment);
|
||||
state.setExerciseIndex(session.exerciseIndex);
|
||||
state.setPartIndex(session.partIndex);
|
||||
state.setModuleIndex(session.moduleIndex);
|
||||
state.setTimeSpent(session.timeSpent);
|
||||
state.setUserSolutions(session.userSolutions);
|
||||
state.setShowSolutions(false);
|
||||
state.setQuestionIndex(session.questionIndex);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex h-full w-full flex-col gap-8 md:gap-16">
|
||||
{user && (
|
||||
<ProfileSummary
|
||||
user={user}
|
||||
items={[
|
||||
{
|
||||
icon: <BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />,
|
||||
label: "Reading",
|
||||
value: totalExamsByModule(stats, "reading"),
|
||||
},
|
||||
{
|
||||
icon: <BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />,
|
||||
label: "Listening",
|
||||
value: totalExamsByModule(stats, "listening"),
|
||||
},
|
||||
{
|
||||
icon: <BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />,
|
||||
label: "Writing",
|
||||
value: totalExamsByModule(stats, "writing"),
|
||||
},
|
||||
{
|
||||
icon: <BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />,
|
||||
label: "Speaking",
|
||||
value: totalExamsByModule(stats, "speaking"),
|
||||
},
|
||||
{
|
||||
icon: <BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />,
|
||||
label: "Level",
|
||||
value: totalExamsByModule(stats, "level"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex h-full w-full flex-col gap-8 md:gap-16">
|
||||
{user && (
|
||||
<ProfileSummary
|
||||
user={user}
|
||||
items={[
|
||||
{
|
||||
icon: (
|
||||
<BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />
|
||||
),
|
||||
label: "Reading",
|
||||
value: totalExamsByModule(stats, "reading"),
|
||||
tooltip: "The amount of reading exams performed.",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />
|
||||
),
|
||||
label: "Listening",
|
||||
value: totalExamsByModule(stats, "listening"),
|
||||
tooltip: "The amount of listening exams performed.",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />
|
||||
),
|
||||
label: "Writing",
|
||||
value: totalExamsByModule(stats, "writing"),
|
||||
tooltip: "The amount of writing exams performed.",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />
|
||||
),
|
||||
label: "Speaking",
|
||||
value: totalExamsByModule(stats, "speaking"),
|
||||
tooltip: "The amount of speaking exams performed.",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />
|
||||
),
|
||||
label: "Level",
|
||||
value: totalExamsByModule(stats, "level"),
|
||||
tooltip: "The amount of level exams performed.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<span className="text-lg font-bold">About {capitalize(page)}</span>
|
||||
<span className="text-mti-gray-taupe">
|
||||
{page === "exercises" && (
|
||||
<>
|
||||
In the realm of language acquisition, practice makes perfect, and our exercises are the key to unlocking your full
|
||||
potential. Dive into a world of interactive and engaging exercises that cater to diverse learning styles. From grammar
|
||||
drills that build a strong foundation to vocabulary challenges that broaden your lexicon, our exercises are carefully
|
||||
designed to make learning 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" && (
|
||||
<>
|
||||
Welcome to the heart of success on your English language journey! Our exams are crafted with precision to assess and
|
||||
enhance your language skills. Each test is a passport to your linguistic prowess, designed to challenge and elevate
|
||||
your abilities. Whether you're a beginner or a seasoned learner, our exams cater to all levels, providing a
|
||||
comprehensive evaluation of your reading, writing, speaking, and listening skills. Prepare to embark on a journey of
|
||||
self-discovery and language mastery as you navigate through our thoughtfully curated exams. Your success is not just a
|
||||
destination; it's a testament to your dedication and our commitment to empowering you with the English language.
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</section>
|
||||
<section className="flex flex-col gap-3">
|
||||
<span className="text-lg font-bold">About {capitalize(page)}</span>
|
||||
<span className="text-mti-gray-taupe">
|
||||
{page === "exercises" && (
|
||||
<>
|
||||
In the realm of language acquisition, practice makes perfect,
|
||||
and our exercises are the key to unlocking your full potential.
|
||||
Dive into a world of interactive and engaging exercises that
|
||||
cater to diverse learning styles. From grammar drills that build
|
||||
a strong foundation to vocabulary challenges that broaden your
|
||||
lexicon, our exercises are carefully designed to make learning
|
||||
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" && (
|
||||
<>
|
||||
Welcome to the heart of success on your English language
|
||||
journey! Our exams are crafted with precision to assess and
|
||||
enhance your language skills. Each test is a passport to your
|
||||
linguistic prowess, designed to challenge and elevate your
|
||||
abilities. Whether you're a beginner or a seasoned learner,
|
||||
our exams cater to all levels, providing a comprehensive
|
||||
evaluation of your reading, writing, speaking, and listening
|
||||
skills. Prepare to embark on a journey of self-discovery and
|
||||
language mastery as you navigate through our thoughtfully
|
||||
curated exams. Your success is not just a destination; it's
|
||||
a testament to your dedication and our commitment to empowering
|
||||
you with the English language.
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</section>
|
||||
|
||||
{sessions.length > 0 && (
|
||||
<section className="flex flex-col gap-3 md:gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
onClick={reload}
|
||||
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out">
|
||||
<span className="text-mti-black text-lg font-bold">Unfinished Sessions</span>
|
||||
<BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
||||
{sessions
|
||||
.sort((a, b) => moment(b.date).diff(moment(a.date)))
|
||||
.map((session) => (
|
||||
<SessionCard session={session} key={session.sessionId} reload={reload} loadSession={loadSession} />
|
||||
))}
|
||||
</span>
|
||||
</section>
|
||||
)}
|
||||
{sessions.length > 0 && (
|
||||
<section className="flex flex-col gap-3 md:gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
onClick={reload}
|
||||
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out"
|
||||
>
|
||||
<span className="text-mti-black text-lg font-bold">
|
||||
Unfinished Sessions
|
||||
</span>
|
||||
<BsArrowRepeat
|
||||
className={clsx("text-xl", isLoading && "animate-spin")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
||||
{sessions
|
||||
.sort((a, b) => moment(b.date).diff(moment(a.date)))
|
||||
.map((session) => (
|
||||
<SessionCard
|
||||
session={session}
|
||||
key={session.sessionId}
|
||||
reload={reload}
|
||||
loadSession={loadSession}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8">
|
||||
<div
|
||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
|
||||
className={clsx(
|
||||
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||
selectedModules.includes("reading") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||
)}>
|
||||
<div className="bg-ielts-reading absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||
<BsBook className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Reading:</span>
|
||||
<p className="text-left text-xs">
|
||||
Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
|
||||
</p>
|
||||
{!selectedModules.includes("reading") && !selectedModules.includes("level") && !disableSelection && (
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("reading") || disableSelection) && (
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
||||
</div>
|
||||
<div
|
||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
|
||||
className={clsx(
|
||||
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||
selectedModules.includes("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">
|
||||
<BsHeadphones className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Listening:</span>
|
||||
<p className="text-left text-xs">
|
||||
Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
|
||||
</p>
|
||||
{!selectedModules.includes("listening") && !selectedModules.includes("level") && !disableSelection && (
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("listening") || disableSelection) && (
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
||||
</div>
|
||||
<div
|
||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
|
||||
className={clsx(
|
||||
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||
selectedModules.includes("writing") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||
)}>
|
||||
<div className="bg-ielts-writing absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||
<BsPen className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Writing:</span>
|
||||
<p className="text-left text-xs">
|
||||
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
|
||||
</p>
|
||||
{!selectedModules.includes("writing") && !selectedModules.includes("level") && !disableSelection && (
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("writing") || disableSelection) && (
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
||||
</div>
|
||||
<div
|
||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
|
||||
className={clsx(
|
||||
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||
selectedModules.includes("speaking") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||
)}>
|
||||
<div className="bg-ielts-speaking absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||
<BsMegaphone className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Speaking:</span>
|
||||
<p className="text-left text-xs">
|
||||
You'll have access to interactive dialogs, pronunciation exercises and speech recordings.
|
||||
</p>
|
||||
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && !disableSelection && (
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("speaking") || disableSelection) && (
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
||||
</div>
|
||||
{!disableSelection && (
|
||||
<div
|
||||
onClick={selectedModules.length === 0 || selectedModules.includes("level") ? () => toggleModule("level") : undefined}
|
||||
className={clsx(
|
||||
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||
selectedModules.includes("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">
|
||||
<BsClipboard className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<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>
|
||||
{!selectedModules.includes("level") && selectedModules.length === 0 && !disableSelection && (
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("level") || disableSelection) && (
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{!selectedModules.includes("level") && selectedModules.length > 0 && (
|
||||
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<div className="-md:flex-col -md:gap-4 -md:justify-center flex w-full items-center md:justify-between">
|
||||
<div className="flex w-full flex-col items-center gap-3">
|
||||
<div
|
||||
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
|
||||
onClick={() => setAvoidRepeatedExams((prev) => !prev)}>
|
||||
<input type="checkbox" className="hidden" />
|
||||
<div
|
||||
className={clsx(
|
||||
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
|
||||
"transition duration-300 ease-in-out",
|
||||
avoidRepeatedExams && "!bg-mti-purple-light ",
|
||||
)}>
|
||||
<BsCheck color="white" className="h-full w-full" />
|
||||
</div>
|
||||
<span className="tooltip" data-tip="If possible, the platform will choose exams not yet done.">
|
||||
Avoid Repeated Questions
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
|
||||
onClick={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
|
||||
<input type="checkbox" className="hidden" />
|
||||
<div
|
||||
className={clsx(
|
||||
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
|
||||
"transition duration-300 ease-in-out",
|
||||
variant === "full" && "!bg-mti-purple-light ",
|
||||
)}>
|
||||
<BsCheck color="white" className="h-full w-full" />
|
||||
</div>
|
||||
<span>Full length exams</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="tooltip w-full" data-tip={`Your screen size is too small to do ${page}`}>
|
||||
<Button color="purple" className="w-full max-w-xs px-12 md:hidden" disabled>
|
||||
Start Exam
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() =>
|
||||
onStart(
|
||||
!disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"],
|
||||
avoidRepeatedExams,
|
||||
variant,
|
||||
)
|
||||
}
|
||||
color="purple"
|
||||
className="-md:hidden w-full max-w-xs px-12 md:self-end"
|
||||
disabled={selectedModules.length === 0 && !disableSelection}>
|
||||
Start Exam
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
<section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8">
|
||||
<div
|
||||
onClick={
|
||||
!disableSelection && !selectedModules.includes("level")
|
||||
? () => toggleModule("reading")
|
||||
: undefined
|
||||
}
|
||||
className={clsx(
|
||||
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||
selectedModules.includes("reading") || disableSelection
|
||||
? "border-mti-purple-light"
|
||||
: "border-mti-gray-platinum",
|
||||
)}
|
||||
>
|
||||
<div className="bg-ielts-reading absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||
<BsBook className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Reading:</span>
|
||||
<p className="text-left text-xs">
|
||||
Expand your vocabulary, improve your reading comprehension and
|
||||
improve your ability to interpret texts in English.
|
||||
</p>
|
||||
{!selectedModules.includes("reading") &&
|
||||
!selectedModules.includes("level") &&
|
||||
!disableSelection && (
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("reading") || disableSelection) && (
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && (
|
||||
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
onClick={
|
||||
!disableSelection && !selectedModules.includes("level")
|
||||
? () => toggleModule("listening")
|
||||
: undefined
|
||||
}
|
||||
className={clsx(
|
||||
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||
selectedModules.includes("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">
|
||||
<BsHeadphones className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Listening:</span>
|
||||
<p className="text-left text-xs">
|
||||
Improve your ability to follow conversations in English and your
|
||||
ability to understand different accents and intonations.
|
||||
</p>
|
||||
{!selectedModules.includes("listening") &&
|
||||
!selectedModules.includes("level") &&
|
||||
!disableSelection && (
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("listening") || disableSelection) && (
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && (
|
||||
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
onClick={
|
||||
!disableSelection && !selectedModules.includes("level")
|
||||
? () => toggleModule("writing")
|
||||
: undefined
|
||||
}
|
||||
className={clsx(
|
||||
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||
selectedModules.includes("writing") || disableSelection
|
||||
? "border-mti-purple-light"
|
||||
: "border-mti-gray-platinum",
|
||||
)}
|
||||
>
|
||||
<div className="bg-ielts-writing absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||
<BsPen className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Writing:</span>
|
||||
<p className="text-left text-xs">
|
||||
Allow you to practice writing in a variety of formats, from simple
|
||||
paragraphs to complex essays.
|
||||
</p>
|
||||
{!selectedModules.includes("writing") &&
|
||||
!selectedModules.includes("level") &&
|
||||
!disableSelection && (
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("writing") || disableSelection) && (
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && (
|
||||
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
onClick={
|
||||
!disableSelection && !selectedModules.includes("level")
|
||||
? () => toggleModule("speaking")
|
||||
: undefined
|
||||
}
|
||||
className={clsx(
|
||||
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||
selectedModules.includes("speaking") || disableSelection
|
||||
? "border-mti-purple-light"
|
||||
: "border-mti-gray-platinum",
|
||||
)}
|
||||
>
|
||||
<div className="bg-ielts-speaking absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||
<BsMegaphone className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Speaking:</span>
|
||||
<p className="text-left text-xs">
|
||||
You'll have access to interactive dialogs, pronunciation
|
||||
exercises and speech recordings.
|
||||
</p>
|
||||
{!selectedModules.includes("speaking") &&
|
||||
!selectedModules.includes("level") &&
|
||||
!disableSelection && (
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("speaking") || disableSelection) && (
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && (
|
||||
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
</div>
|
||||
{!disableSelection && (
|
||||
<div
|
||||
onClick={
|
||||
selectedModules.length === 0 ||
|
||||
selectedModules.includes("level")
|
||||
? () => toggleModule("level")
|
||||
: undefined
|
||||
}
|
||||
className={clsx(
|
||||
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||
selectedModules.includes("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">
|
||||
<BsClipboard className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<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>
|
||||
{!selectedModules.includes("level") &&
|
||||
selectedModules.length === 0 &&
|
||||
!disableSelection && (
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("level") || disableSelection) && (
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{!selectedModules.includes("level") &&
|
||||
selectedModules.length > 0 && (
|
||||
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<div className="-md:flex-col -md:gap-4 -md:justify-center flex w-full items-center md:justify-between">
|
||||
<div className="flex w-full flex-col items-center gap-3">
|
||||
<div
|
||||
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
|
||||
onClick={() => setAvoidRepeatedExams((prev) => !prev)}
|
||||
>
|
||||
<input type="checkbox" className="hidden" />
|
||||
<div
|
||||
className={clsx(
|
||||
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
|
||||
"transition duration-300 ease-in-out",
|
||||
avoidRepeatedExams && "!bg-mti-purple-light ",
|
||||
)}
|
||||
>
|
||||
<BsCheck color="white" className="h-full w-full" />
|
||||
</div>
|
||||
<span
|
||||
className="tooltip"
|
||||
data-tip="If possible, the platform will choose exams not yet done."
|
||||
>
|
||||
Avoid Repeated Questions
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
|
||||
// onClick={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
|
||||
>
|
||||
<input type="checkbox" className="hidden" disabled />
|
||||
<div
|
||||
className={clsx(
|
||||
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
|
||||
"transition duration-300 ease-in-out",
|
||||
variant === "full" && "!bg-mti-purple-light ",
|
||||
)}
|
||||
>
|
||||
<BsCheck color="white" className="h-full w-full" />
|
||||
</div>
|
||||
<span>Full length exams</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="tooltip w-full"
|
||||
data-tip={`Your screen size is too small to do ${page}`}
|
||||
>
|
||||
<Button
|
||||
color="purple"
|
||||
className="w-full max-w-xs px-12 md:hidden"
|
||||
disabled
|
||||
>
|
||||
Start Exam
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() =>
|
||||
onStart(
|
||||
!disableSelection
|
||||
? selectedModules.sort(sortByModuleName)
|
||||
: ["reading", "listening", "writing", "speaking"],
|
||||
avoidRepeatedExams,
|
||||
variant,
|
||||
)
|
||||
}
|
||||
color="purple"
|
||||
className="-md:hidden w-full max-w-xs px-12 md:self-end"
|
||||
disabled={selectedModules.length === 0 && !disableSelection}
|
||||
>
|
||||
Start Exam
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
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);
|
||||
|
||||
@@ -50,18 +50,16 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
||||
setHasExamEnded(false);
|
||||
|
||||
if (solution) {
|
||||
onFinish(
|
||||
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "speaking", exam: exam.id})),
|
||||
);
|
||||
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "speaking", exam: exam.id}]);
|
||||
} else {
|
||||
onFinish(userSolutions.map((x) => ({...x, module: "speaking", exam: exam.id})));
|
||||
onFinish(userSolutions);
|
||||
}
|
||||
};
|
||||
|
||||
const previousExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
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) {
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
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) {
|
||||
@@ -41,18 +41,16 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
|
||||
setHasExamEnded(false);
|
||||
|
||||
if (solution) {
|
||||
onFinish(
|
||||
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "writing", exam: exam.id})),
|
||||
);
|
||||
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "writing", exam: exam.id}]);
|
||||
} else {
|
||||
onFinish(userSolutions.map((x) => ({...x, module: "writing", exam: exam.id})));
|
||||
onFinish(userSolutions);
|
||||
}
|
||||
};
|
||||
|
||||
const previousExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
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) {
|
||||
|
||||
@@ -25,7 +25,13 @@ const thresholds = [
|
||||
level: "High A2/Low B1",
|
||||
label: "Pre-Intermediate",
|
||||
minValue: 8,
|
||||
maxValue: 12,
|
||||
maxValue: 11,
|
||||
},
|
||||
{
|
||||
level: "High B1/Low B2",
|
||||
label: "Intermediate",
|
||||
minValue: 12,
|
||||
maxValue: 15,
|
||||
},
|
||||
{
|
||||
level: "High B2/Low C1",
|
||||
|
||||
@@ -4,14 +4,18 @@ import React from "react";
|
||||
import {View, Text, Image} from "@react-pdf/renderer";
|
||||
import {styles} from "../styles";
|
||||
import {ModuleScore} from "@/interfaces/module.scores";
|
||||
import {calculateBandScore} from "@/utils/score";
|
||||
import {Module} from "@/interfaces";
|
||||
|
||||
export const RadialResult = ({module, score, total, png}: ModuleScore) => (
|
||||
<View style={[styles.textFont, styles.radialContainer]}>
|
||||
<Text style={[styles.textColor, styles.textBold, {fontSize: 10}]}>{module}</Text>
|
||||
<Image src={png} style={styles.image64}></Image>
|
||||
<View style={[styles.textColor, styles.radialResultContainer]}>
|
||||
<Text style={styles.textBold}>{score.toFixed(2)}</Text>
|
||||
<Text style={{fontSize: 8}}>out of {total}</Text>
|
||||
<Text style={styles.textBold}>
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -215,7 +215,7 @@ const GroupTestReport = ({
|
||||
</View>
|
||||
<View style={[{ paddingBottom: 30 }, styles.separator]}></View>
|
||||
<View style={{ flexGrow: 1 }}></View>
|
||||
<TestReportFooter />
|
||||
<TestReportFooter userId={id} />
|
||||
</Page>
|
||||
<Page style={styles.body}>
|
||||
<View
|
||||
@@ -297,7 +297,7 @@ const GroupTestReport = ({
|
||||
</View>
|
||||
|
||||
<View style={{ flexGrow: 1 }}></View>
|
||||
<TestReportFooter />
|
||||
<TestReportFooter userId={id} />
|
||||
</Page>
|
||||
</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>https://encoach.com</Text>
|
||||
<View style={styles.spacedRow}>
|
||||
<Text>Group ID: TRI64BNBOIU5043</Text>
|
||||
<Text
|
||||
// style={styles.pageNumber}
|
||||
render={({ pageNumber, totalPages }) =>
|
||||
|
||||
@@ -6,11 +6,17 @@ import { styles } from "./styles";
|
||||
|
||||
import { StyleSheet } from "@react-pdf/renderer";
|
||||
import TestReportFooter from "./test.report.footer";
|
||||
import ListItem from "./list.item";
|
||||
|
||||
const customStyles = StyleSheet.create({
|
||||
testDetails: {
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
},
|
||||
testDetailsContainer: {
|
||||
display: "flex",
|
||||
gap: 16,
|
||||
},
|
||||
});
|
||||
|
||||
interface Props {
|
||||
@@ -82,7 +88,6 @@ const TestReport = ({
|
||||
</Text>
|
||||
<View style={styles.textMargin}>
|
||||
<Text style={defaultTextStyle}>Name: {name}</Text>
|
||||
<Text style={defaultTextStyle}>ID: {id}</Text>
|
||||
<Text style={defaultTextStyle}>Email: {email}</Text>
|
||||
<Text style={defaultTextStyle}>Gender: {gender}</Text>
|
||||
<Text style={defaultTextStyle}>Passport ID: {passportId}</Text>
|
||||
@@ -124,7 +129,7 @@ const TestReport = ({
|
||||
</View>
|
||||
</View>
|
||||
<View style={[{ paddingTop: 30 }, styles.separator]}></View>
|
||||
<TestReportFooter userId={id}/>
|
||||
<TestReportFooter userId={id} />
|
||||
</Page>
|
||||
<Page style={styles.body}>
|
||||
<View>
|
||||
@@ -149,15 +154,46 @@ const TestReport = ({
|
||||
.filter(
|
||||
({ suggestions, evaluation }) => suggestions || evaluation
|
||||
)
|
||||
.map(({ module, suggestions, evaluation }) => (
|
||||
<View key={module} style={customStyles.testDetails}>
|
||||
<Text style={[...defaultSkillsTitleStyle, styles.textBold]}>
|
||||
{module}
|
||||
</Text>
|
||||
<Text style={defaultSkillsTextStyle}>{evaluation}</Text>
|
||||
<Text style={defaultSkillsTextStyle}>{suggestions}</Text>
|
||||
</View>
|
||||
))}
|
||||
.map(
|
||||
({
|
||||
module,
|
||||
suggestions,
|
||||
evaluation,
|
||||
bullet_points = [],
|
||||
}) => (
|
||||
<View key={module} style={customStyles.testDetailsContainer}>
|
||||
<View style={customStyles.testDetails}>
|
||||
<Text
|
||||
style={[...defaultSkillsTitleStyle, styles.textBold]}
|
||||
>
|
||||
{module}
|
||||
</Text>
|
||||
<Text style={defaultSkillsTextStyle}>{evaluation}</Text>
|
||||
<Text style={defaultSkillsTextStyle}>{suggestions}</Text>
|
||||
</View>
|
||||
<View style={customStyles.testDetails}>
|
||||
{bullet_points.length > 0 && (
|
||||
<>
|
||||
<Text
|
||||
style={defaultSkillsTitleStyle}
|
||||
>
|
||||
How to Improve:
|
||||
</Text>
|
||||
<View>
|
||||
{bullet_points.map((text: string) => (
|
||||
<ListItem
|
||||
key={text}
|
||||
text={text}
|
||||
textStyle={defaultSkillsTextStyle}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.alignRightRow}>
|
||||
<Image src={qrcode} style={styles.qrcode} />
|
||||
@@ -165,7 +201,7 @@ const TestReport = ({
|
||||
</View>
|
||||
<View style={[{ paddingBottom: 30 }, styles.separator]}></View>
|
||||
<View style={{ flexGrow: 1 }}></View>
|
||||
<TestReportFooter userId={id}/>
|
||||
<TestReportFooter userId={id} />
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
|
||||
26
src/hooks/useAcceptedTerms.tsx
Normal file
26
src/hooks/useAcceptedTerms.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import Checkbox from "@/components/Low/Checkbox";
|
||||
|
||||
const useAcceptedTerms = () => {
|
||||
const [acceptedTerms, setAcceptedTerms] = React.useState(false);
|
||||
|
||||
const renderCheckbox = () => (
|
||||
<Checkbox isChecked={acceptedTerms} onChange={setAcceptedTerms}>
|
||||
I agree to the
|
||||
<Link href={`https://encoach.com/terms`} className="text-mti-purple-light">
|
||||
{" "}
|
||||
Terms and Conditions
|
||||
</Link>{" "}
|
||||
and
|
||||
<Link href={`https://encoach.com/privacy-policy`} className="text-mti-purple-light">
|
||||
{" "}
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</Checkbox>
|
||||
);
|
||||
|
||||
return {acceptedTerms, renderCheckbox};
|
||||
};
|
||||
|
||||
export default useAcceptedTerms;
|
||||
42
src/hooks/useAssignmentArchive.tsx
Normal file
42
src/hooks/useAssignmentArchive.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
import axios from "axios";
|
||||
import {toast} from "react-toastify";
|
||||
import {BsArchive} from "react-icons/bs";
|
||||
|
||||
export const useAssignmentArchive = (assignmentId: string, reload?: Function) => {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const archive = () => {
|
||||
// archive assignment
|
||||
setLoading(true);
|
||||
axios
|
||||
.post(`/api/assignments/${assignmentId}/archive`)
|
||||
.then((res) => {
|
||||
toast.success("Assignment archived!");
|
||||
if (reload) reload();
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error("Failed to archive 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="Archive assignment"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
archive();
|
||||
}}>
|
||||
<BsArchive className={`${downloadClasses} text-2xl cursor-pointer tooltip`} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return renderIcon;
|
||||
};
|
||||
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";
|
||||
|
||||
/*fields example = [
|
||||
@@ -6,43 +6,33 @@ import Input from "@/components/Low/Input";
|
||||
['companyInformation', 'companyInformation', 'name']
|
||||
]*/
|
||||
|
||||
|
||||
const getFieldValue = (fields: string[], data: any): string => {
|
||||
if(fields.length === 0) return data;
|
||||
const [key, ...otherFields] = fields;
|
||||
if (fields.length === 0) return data;
|
||||
const [key, ...otherFields] = fields;
|
||||
|
||||
if(data[key]) return getFieldValue(otherFields, data[key]);
|
||||
return data;
|
||||
}
|
||||
|
||||
export const useListSearch = (fields: string[][], rows: any[]) => {
|
||||
const [text, setText] = useState('');
|
||||
|
||||
const renderSearch = () => (
|
||||
<Input
|
||||
label="Search"
|
||||
type="text"
|
||||
name="search"
|
||||
onChange={setText}
|
||||
placeholder="Enter search text"
|
||||
value={text}
|
||||
/>
|
||||
)
|
||||
|
||||
const updatedRows = useMemo(() => {
|
||||
const searchText = text.toLowerCase();
|
||||
return rows.filter((row) => {
|
||||
return fields.some((fieldsKeys) => {
|
||||
const value = getFieldValue(fieldsKeys, row);
|
||||
if(typeof value === 'string') {
|
||||
return value.toLowerCase().includes(searchText);
|
||||
}
|
||||
})
|
||||
})
|
||||
}, [fields, rows, text])
|
||||
|
||||
return {
|
||||
rows: updatedRows,
|
||||
renderSearch,
|
||||
}
|
||||
if (data[key]) return getFieldValue(otherFields, data[key]);
|
||||
return data;
|
||||
};
|
||||
|
||||
export function useListSearch<T>(fields: string[][], rows: T[]) {
|
||||
const [text, setText] = useState("");
|
||||
|
||||
const renderSearch = () => <Input label="Search" type="text" name="search" onChange={setText} placeholder="Enter search text" value={text} />;
|
||||
|
||||
const updatedRows = useMemo(() => {
|
||||
const searchText = text.toLowerCase();
|
||||
return rows.filter((row) => {
|
||||
return fields.some((fieldsKeys) => {
|
||||
const value = getFieldValue(fieldsKeys, row);
|
||||
if (typeof value === "string") {
|
||||
return value.toLowerCase().includes(searchText);
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [fields, rows, text]);
|
||||
|
||||
return {
|
||||
rows: updatedRows,
|
||||
renderSearch,
|
||||
};
|
||||
}
|
||||
23
src/hooks/usePaypalPayments.tsx
Normal file
23
src/hooks/usePaypalPayments.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import {PaypalPayment} from "@/interfaces/paypal";
|
||||
import axios from "axios";
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
export default function usePaypalPayments() {
|
||||
const [payments, setPayments] = useState<PaypalPayment[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const getData = () => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<PaypalPayment[]>("/api/payments/paypal")
|
||||
.then((response) => {
|
||||
return setPayments(response.data);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
useEffect(getData, []);
|
||||
|
||||
return {payments, isLoading, isError, reload: getData};
|
||||
}
|
||||
20
src/hooks/usePaypalTracking.tsx
Normal file
20
src/hooks/usePaypalTracking.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import axios from "axios";
|
||||
|
||||
export const usePaypalTracking = () => {
|
||||
const [trackingId, setTrackingId] = useState<string>();
|
||||
useEffect(() => {
|
||||
axios
|
||||
.put<{ ok: boolean; trackingId: string }>("/api/paypal/raas")
|
||||
.then((response) => {
|
||||
if (response.data.ok) {
|
||||
setTrackingId(response.data.trackingId);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return trackingId;
|
||||
};
|
||||
@@ -2,18 +2,22 @@ import {Stat, User} from "@/interfaces/user";
|
||||
import axios from "axios";
|
||||
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 [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const getData = () => {
|
||||
if (shouldNotQuery) return;
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.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));
|
||||
}, [id]);
|
||||
};
|
||||
|
||||
return {stats, isLoading, isError};
|
||||
useEffect(getData, [id, shouldNotQuery]);
|
||||
|
||||
return {stats, reload: getData, isLoading, isError};
|
||||
}
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { Ticket } from "@/interfaces/ticket";
|
||||
import { TicketWithCorporate } from "@/interfaces/ticket";
|
||||
import { Code, Group, User } from "@/interfaces/user";
|
||||
import axios from "axios";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
|
||||
export default function useTickets() {
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [tickets, setTickets] = useState<TicketWithCorporate[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const getData = () => {
|
||||
const getData = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<Ticket[]>(`/api/tickets`)
|
||||
.get<TicketWithCorporate[]>(`/api/tickets`)
|
||||
.then((response) => setTickets(response.data))
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(getData, []);
|
||||
useEffect(getData, [getData]);
|
||||
|
||||
return { tickets, isLoading, isError, reload: getData };
|
||||
}
|
||||
|
||||
29
src/hooks/useTicketsListener.tsx
Normal file
29
src/hooks/useTicketsListener.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
import useTickets from "./useTickets";
|
||||
|
||||
const useTicketsListener = (userId?: string) => {
|
||||
const { tickets, reload } = useTickets();
|
||||
|
||||
React.useEffect(() => {
|
||||
const intervalId = setInterval(() => {
|
||||
reload();
|
||||
}, 60 * 1000);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [reload]);
|
||||
|
||||
if (userId) {
|
||||
const assignedTickets = tickets.filter(
|
||||
(ticket) => ticket.assignedTo === userId && ticket.status === "submitted"
|
||||
);
|
||||
|
||||
return {
|
||||
assignedTickets,
|
||||
totalAssignedTickets: assignedTickets.length,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
export default useTicketsListener;
|
||||
@@ -2,6 +2,8 @@ import {Module} from ".";
|
||||
|
||||
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
|
||||
export type Variant = "full" | "partial";
|
||||
export type InstructorGender = "male" | "female" | "varied";
|
||||
export type Difficulty = "easy" | "medium" | "hard";
|
||||
|
||||
export interface ReadingExam {
|
||||
parts: ReadingPart[];
|
||||
@@ -11,6 +13,7 @@ export interface ReadingExam {
|
||||
type: "academic" | "general";
|
||||
isDiagnostic: boolean;
|
||||
variant?: Variant;
|
||||
difficulty?: Difficulty;
|
||||
}
|
||||
|
||||
export interface ReadingPart {
|
||||
@@ -28,6 +31,7 @@ export interface LevelExam {
|
||||
minTimer: number;
|
||||
isDiagnostic: boolean;
|
||||
variant?: Variant;
|
||||
difficulty?: Difficulty;
|
||||
}
|
||||
|
||||
export interface ListeningExam {
|
||||
@@ -37,6 +41,7 @@ export interface ListeningExam {
|
||||
minTimer: number;
|
||||
isDiagnostic: boolean;
|
||||
variant?: Variant;
|
||||
difficulty?: Difficulty;
|
||||
}
|
||||
|
||||
export interface ListeningPart {
|
||||
@@ -59,15 +64,17 @@ export interface UserSolution {
|
||||
missing: number;
|
||||
};
|
||||
exercise: string;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export interface WritingExam {
|
||||
module: "writing";
|
||||
id: string;
|
||||
exercises: Exercise[];
|
||||
exercises: WritingExercise[];
|
||||
minTimer: number;
|
||||
isDiagnostic: boolean;
|
||||
variant?: Variant;
|
||||
difficulty?: Difficulty;
|
||||
}
|
||||
|
||||
interface WordCounter {
|
||||
@@ -78,10 +85,12 @@ interface WordCounter {
|
||||
export interface SpeakingExam {
|
||||
id: string;
|
||||
module: "speaking";
|
||||
exercises: Exercise[];
|
||||
exercises: (SpeakingExercise | InteractiveSpeakingExercise)[];
|
||||
minTimer: number;
|
||||
isDiagnostic: boolean;
|
||||
variant?: Variant;
|
||||
instructorGender: InstructorGender;
|
||||
difficulty?: Difficulty;
|
||||
}
|
||||
|
||||
export type Exercise =
|
||||
@@ -140,6 +149,7 @@ export interface WritingExercise {
|
||||
solution: string;
|
||||
evaluation?: CommonEvaluation;
|
||||
}[];
|
||||
topic?: string;
|
||||
}
|
||||
|
||||
export interface SpeakingExercise {
|
||||
@@ -154,6 +164,7 @@ export interface SpeakingExercise {
|
||||
solution: string;
|
||||
evaluation?: SpeakingEvaluation;
|
||||
}[];
|
||||
topic?: string;
|
||||
}
|
||||
|
||||
export interface InteractiveSpeakingExercise {
|
||||
@@ -167,6 +178,7 @@ export interface InteractiveSpeakingExercise {
|
||||
solution: {questionIndex: number; question: string; answer: string}[];
|
||||
evaluation?: InteractiveSpeakingEvaluation;
|
||||
}[];
|
||||
topic?: string;
|
||||
}
|
||||
|
||||
export interface FillBlanksExercise {
|
||||
@@ -221,17 +233,21 @@ export interface MatchSentencesExercise {
|
||||
id: string;
|
||||
prompt: string;
|
||||
userSolutions: {question: string; option: string}[];
|
||||
sentences: {
|
||||
id: string;
|
||||
sentence: string;
|
||||
solution: string;
|
||||
color: string;
|
||||
}[];
|
||||
sentences: MatchSentenceExerciseSentence[];
|
||||
allowRepetition: boolean;
|
||||
options: {
|
||||
id: string;
|
||||
sentence: string;
|
||||
}[];
|
||||
options: MatchSentenceExerciseOption[];
|
||||
}
|
||||
|
||||
export interface MatchSentenceExerciseSentence {
|
||||
id: string;
|
||||
sentence: string;
|
||||
solution: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface MatchSentenceExerciseOption {
|
||||
id: string;
|
||||
sentence: string;
|
||||
}
|
||||
|
||||
export interface MultipleChoiceExercise {
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface ModuleScore {
|
||||
png?: string;
|
||||
evaluation?: string;
|
||||
suggestions?: string;
|
||||
bullet_points?: string[];
|
||||
}
|
||||
|
||||
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,13 @@ export interface Package {
|
||||
price: number;
|
||||
}
|
||||
|
||||
export interface Discount {
|
||||
id: string;
|
||||
percentage: number;
|
||||
domain: string;
|
||||
validUntil?: Date;
|
||||
}
|
||||
|
||||
export type DurationUnit = "weeks" | "days" | "months" | "years";
|
||||
|
||||
export interface Payment {
|
||||
@@ -35,3 +42,15 @@ export interface Payment {
|
||||
corporateTransfer?: string;
|
||||
commissionTransfer?: string;
|
||||
}
|
||||
|
||||
export interface PaypalPayment {
|
||||
orderId: string;
|
||||
userId: string;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
value: number;
|
||||
currency: string;
|
||||
subscriptionDuration: number;
|
||||
subscriptionDurationUnit: DurationUnit;
|
||||
subscriptionExpirationDate: Date;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {Module} from "@/interfaces";
|
||||
import {InstructorGender} from "./exam";
|
||||
import {Stat} from "./user";
|
||||
|
||||
export type UserResults = {[key in Module]: ModuleResult};
|
||||
@@ -19,7 +20,9 @@ export interface Assignment {
|
||||
type: "academic" | "general";
|
||||
stats: Stat[];
|
||||
}[];
|
||||
exams: {id: string; module: Module, assignee: string}[];
|
||||
exams: {id: string; module: Module; assignee: string}[];
|
||||
instructorGender?: InstructorGender;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
archived?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,34 +1,48 @@
|
||||
import { Type } from "./user";
|
||||
import {Module} from ".";
|
||||
import {Type} from "./user";
|
||||
|
||||
export interface Ticket {
|
||||
id: string;
|
||||
date: string;
|
||||
status: TicketStatus;
|
||||
type: TicketType;
|
||||
reporter: TicketReporter;
|
||||
reportedFrom: string;
|
||||
description: string;
|
||||
subject: string;
|
||||
assignedTo?: string;
|
||||
id: string;
|
||||
date: string;
|
||||
status: TicketStatus;
|
||||
type: TicketType;
|
||||
reporter: TicketReporter;
|
||||
reportedFrom: string;
|
||||
description: string;
|
||||
subject: string;
|
||||
assignedTo?: string;
|
||||
examInformation?: {
|
||||
exams: string[];
|
||||
exam: string;
|
||||
selectedModules: Module[];
|
||||
moduleIndex: number;
|
||||
partIndex: number;
|
||||
exerciseIndex: number;
|
||||
questionIndex: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TicketReporter {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
type: Type;
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
type: Type;
|
||||
}
|
||||
|
||||
export type TicketType = "feedback" | "bug" | "help";
|
||||
export const TicketTypeLabel: { [key in TicketType]: string } = {
|
||||
feedback: "Feedback",
|
||||
bug: "Bug",
|
||||
help: "Help",
|
||||
export const TicketTypeLabel: {[key in TicketType]: string} = {
|
||||
feedback: "Feedback",
|
||||
bug: "Bug",
|
||||
help: "Help",
|
||||
};
|
||||
|
||||
export type TicketStatus = "submitted" | "in-progress" | "completed";
|
||||
export const TicketStatusLabel: { [key in TicketStatus]: string } = {
|
||||
submitted: "Submitted",
|
||||
"in-progress": "In Progress",
|
||||
completed: "Completed",
|
||||
export const TicketStatusLabel: {[key in TicketStatus]: string} = {
|
||||
submitted: "Submitted",
|
||||
"in-progress": "In Progress",
|
||||
completed: "Completed",
|
||||
};
|
||||
|
||||
export interface TicketWithCorporate extends Ticket {
|
||||
corporate?: string;
|
||||
}
|
||||
|
||||
@@ -1,142 +1,177 @@
|
||||
import {Module} from ".";
|
||||
import { Module } from ".";
|
||||
import { InstructorGender } from "./exam";
|
||||
|
||||
export type User = StudentUser | TeacherUser | CorporateUser | AgentUser | AdminUser | DeveloperUser;
|
||||
export type User =
|
||||
| StudentUser
|
||||
| TeacherUser
|
||||
| CorporateUser
|
||||
| AgentUser
|
||||
| AdminUser
|
||||
| DeveloperUser;
|
||||
export type UserStatus = "active" | "disabled" | "paymentDue";
|
||||
|
||||
export interface BasicUser {
|
||||
email: string;
|
||||
name: string;
|
||||
profilePicture: string;
|
||||
id: string;
|
||||
isFirstLogin: boolean;
|
||||
focus: "academic" | "general";
|
||||
levels: {[key in Module]: number};
|
||||
desiredLevels: {[key in Module]: number};
|
||||
type: Type;
|
||||
bio: string;
|
||||
isVerified: boolean;
|
||||
subscriptionExpirationDate?: null | Date;
|
||||
registrationDate?: Date;
|
||||
status: "active" | "disabled" | "paymentDue";
|
||||
email: string;
|
||||
name: string;
|
||||
profilePicture: string;
|
||||
id: string;
|
||||
isFirstLogin: boolean;
|
||||
focus: "academic" | "general";
|
||||
levels: { [key in Module]: number };
|
||||
desiredLevels: { [key in Module]: number };
|
||||
type: Type;
|
||||
bio: string;
|
||||
isVerified: boolean;
|
||||
subscriptionExpirationDate?: null | Date;
|
||||
registrationDate?: Date;
|
||||
status: UserStatus;
|
||||
}
|
||||
|
||||
export interface StudentUser extends BasicUser {
|
||||
type: "student";
|
||||
demographicInformation?: DemographicInformation;
|
||||
type: "student";
|
||||
preferredGender?: InstructorGender;
|
||||
demographicInformation?: DemographicInformation;
|
||||
preferredTopics?: string[];
|
||||
}
|
||||
|
||||
export interface TeacherUser extends BasicUser {
|
||||
type: "teacher";
|
||||
demographicInformation?: DemographicInformation;
|
||||
type: "teacher";
|
||||
demographicInformation?: DemographicInformation;
|
||||
}
|
||||
|
||||
export interface CorporateUser extends BasicUser {
|
||||
type: "corporate";
|
||||
corporateInformation: CorporateInformation;
|
||||
demographicInformation?: DemographicCorporateInformation;
|
||||
type: "corporate";
|
||||
corporateInformation: CorporateInformation;
|
||||
demographicInformation?: DemographicCorporateInformation;
|
||||
}
|
||||
|
||||
export interface AgentUser extends BasicUser {
|
||||
type: "agent";
|
||||
agentInformation: AgentInformation;
|
||||
demographicInformation?: DemographicInformation;
|
||||
type: "agent";
|
||||
agentInformation: AgentInformation;
|
||||
demographicInformation?: DemographicInformation;
|
||||
}
|
||||
|
||||
export interface AdminUser extends BasicUser {
|
||||
type: "admin";
|
||||
demographicInformation?: DemographicInformation;
|
||||
type: "admin";
|
||||
demographicInformation?: DemographicInformation;
|
||||
}
|
||||
|
||||
export interface DeveloperUser extends BasicUser {
|
||||
type: "developer";
|
||||
demographicInformation?: DemographicInformation;
|
||||
type: "developer";
|
||||
preferredGender?: InstructorGender;
|
||||
demographicInformation?: DemographicInformation;
|
||||
preferredTopics?: string[];
|
||||
}
|
||||
|
||||
export interface CorporateInformation {
|
||||
companyInformation: CompanyInformation;
|
||||
monthlyDuration: number;
|
||||
payment?: {
|
||||
value: number;
|
||||
currency: string;
|
||||
commission: number;
|
||||
};
|
||||
referralAgent?: string;
|
||||
companyInformation: CompanyInformation;
|
||||
monthlyDuration: number;
|
||||
payment?: {
|
||||
value: number;
|
||||
currency: string;
|
||||
commission: number;
|
||||
};
|
||||
referralAgent?: string;
|
||||
}
|
||||
|
||||
export interface AgentInformation {
|
||||
companyName: string;
|
||||
commercialRegistration: string;
|
||||
companyName: string;
|
||||
commercialRegistration: string;
|
||||
companyArabName?: string;
|
||||
}
|
||||
|
||||
export interface CompanyInformation {
|
||||
name: string;
|
||||
userAmount: number;
|
||||
name: string;
|
||||
userAmount: number;
|
||||
}
|
||||
|
||||
export interface DemographicInformation {
|
||||
country: string;
|
||||
phone: string;
|
||||
gender: Gender;
|
||||
employment: EmploymentStatus;
|
||||
passport_id?: string;
|
||||
timezone?: string;
|
||||
country: string;
|
||||
phone: string;
|
||||
gender: Gender;
|
||||
employment: EmploymentStatus;
|
||||
passport_id?: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export interface DemographicCorporateInformation {
|
||||
country: string;
|
||||
phone: string;
|
||||
gender: Gender;
|
||||
position: string;
|
||||
timezone?: string;
|
||||
country: string;
|
||||
phone: string;
|
||||
gender: Gender;
|
||||
position: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export type Gender = "male" | "female" | "other";
|
||||
export type EmploymentStatus = "employed" | "student" | "self-employed" | "unemployed" | "retired" | "other";
|
||||
export const EMPLOYMENT_STATUS: {status: EmploymentStatus; label: string}[] = [
|
||||
{status: "student", label: "Student"},
|
||||
{status: "employed", label: "Employed"},
|
||||
{status: "unemployed", label: "Unemployed"},
|
||||
{status: "self-employed", label: "Self-employed"},
|
||||
{status: "retired", label: "Retired"},
|
||||
{status: "other", label: "Other"},
|
||||
];
|
||||
export type EmploymentStatus =
|
||||
| "employed"
|
||||
| "student"
|
||||
| "self-employed"
|
||||
| "unemployed"
|
||||
| "retired"
|
||||
| "other";
|
||||
export const EMPLOYMENT_STATUS: { status: EmploymentStatus; label: string }[] =
|
||||
[
|
||||
{ status: "student", label: "Student" },
|
||||
{ status: "employed", label: "Employed" },
|
||||
{ status: "unemployed", label: "Unemployed" },
|
||||
{ status: "self-employed", label: "Self-employed" },
|
||||
{ status: "retired", label: "Retired" },
|
||||
{ status: "other", label: "Other" },
|
||||
];
|
||||
|
||||
export interface Stat {
|
||||
id: string;
|
||||
user: string;
|
||||
exam: string;
|
||||
exercise: string;
|
||||
session: string;
|
||||
date: number;
|
||||
module: Module;
|
||||
solutions: any[];
|
||||
type: string;
|
||||
timeSpent?: number;
|
||||
assignment?: string;
|
||||
score: {
|
||||
correct: number;
|
||||
total: number;
|
||||
missing: number;
|
||||
};
|
||||
id: string;
|
||||
user: string;
|
||||
exam: string;
|
||||
exercise: string;
|
||||
session: string;
|
||||
date: number;
|
||||
module: Module;
|
||||
solutions: any[];
|
||||
type: string;
|
||||
timeSpent?: number;
|
||||
assignment?: string;
|
||||
score: {
|
||||
correct: number;
|
||||
total: number;
|
||||
missing: number;
|
||||
};
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
admin: string;
|
||||
name: string;
|
||||
participants: string[];
|
||||
id: string;
|
||||
disableEditing?: boolean;
|
||||
admin: string;
|
||||
name: string;
|
||||
participants: string[];
|
||||
id: string;
|
||||
disableEditing?: boolean;
|
||||
}
|
||||
|
||||
export interface Code {
|
||||
code: string;
|
||||
creator: string;
|
||||
expiryDate: Date;
|
||||
type: Type;
|
||||
userId?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
passport_id?: string;
|
||||
code: string;
|
||||
creator: string;
|
||||
expiryDate: Date;
|
||||
type: Type;
|
||||
creationDate?: string;
|
||||
userId?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
passport_id?: string;
|
||||
}
|
||||
|
||||
export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent";
|
||||
export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent"];
|
||||
export type Type =
|
||||
| "student"
|
||||
| "teacher"
|
||||
| "corporate"
|
||||
| "admin"
|
||||
| "developer"
|
||||
| "agent";
|
||||
export const userTypes: Type[] = [
|
||||
"student",
|
||||
"teacher",
|
||||
"corporate",
|
||||
"admin",
|
||||
"developer",
|
||||
"agent",
|
||||
];
|
||||
|
||||
@@ -1,319 +1,250 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import Checkbox from "@/components/Low/Checkbox";
|
||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import { Type, User } from "@/interfaces/user";
|
||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||
import {Type, User} from "@/interfaces/user";
|
||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import { capitalize, uniqBy } from "lodash";
|
||||
import {capitalize, uniqBy} from "lodash";
|
||||
import moment from "moment";
|
||||
import { useEffect, useState } from "react";
|
||||
import {useEffect, useState} from "react";
|
||||
import ReactDatePicker from "react-datepicker";
|
||||
import { toast } from "react-toastify";
|
||||
import {toast} from "react-toastify";
|
||||
import ShortUniqueId from "short-unique-id";
|
||||
import { useFilePicker } from "use-file-picker";
|
||||
import {useFilePicker} from "use-file-picker";
|
||||
import readXlsxFile from "read-excel-file";
|
||||
import Modal from "@/components/Modal";
|
||||
import { BsFileEarmarkEaselFill, BsQuestionCircleFill } from "react-icons/bs";
|
||||
import {BsFileEarmarkEaselFill, BsQuestionCircleFill} from "react-icons/bs";
|
||||
|
||||
const EMAIL_REGEX = new RegExp(
|
||||
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/,
|
||||
);
|
||||
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
||||
|
||||
const USER_TYPE_PERMISSIONS: { [key in Type]: Type[] } = {
|
||||
student: [],
|
||||
teacher: [],
|
||||
agent: [],
|
||||
corporate: ["student", "teacher"],
|
||||
admin: ["student", "teacher", "agent", "corporate", "admin"],
|
||||
developer: ["student", "teacher", "agent", "corporate", "admin", "developer"],
|
||||
const USER_TYPE_PERMISSIONS: {[key in Type]: Type[]} = {
|
||||
student: [],
|
||||
teacher: [],
|
||||
agent: [],
|
||||
corporate: ["student", "teacher"],
|
||||
admin: ["student", "teacher", "agent", "corporate", "admin"],
|
||||
developer: ["student", "teacher", "agent", "corporate", "admin", "developer"],
|
||||
};
|
||||
|
||||
export default function BatchCodeGenerator({ user }: { user: User }) {
|
||||
const [infos, setInfos] = useState<
|
||||
{ email: string; name: string; passport_id: string }[]
|
||||
>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
|
||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||
const [type, setType] = useState<Type>("student");
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
export default function BatchCodeGenerator({user}: {user: User}) {
|
||||
const [infos, setInfos] = useState<{email: string; name: string; passport_id: string}[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
|
||||
);
|
||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||
const [type, setType] = useState<Type>("student");
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
|
||||
const { users } = useUsers();
|
||||
const {users} = useUsers();
|
||||
|
||||
const { openFilePicker, filesContent, clear } = useFilePicker({
|
||||
accept: ".xlsx",
|
||||
multiple: false,
|
||||
readAs: "ArrayBuffer",
|
||||
});
|
||||
const {openFilePicker, filesContent, clear} = useFilePicker({
|
||||
accept: ".xlsx",
|
||||
multiple: false,
|
||||
readAs: "ArrayBuffer",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (user && (user.type === "corporate" || user.type === "teacher")) {
|
||||
setExpiryDate(user.subscriptionExpirationDate || null);
|
||||
setIsExpiryDateEnabled(!!user.subscriptionExpirationDate);
|
||||
}
|
||||
}, [user]);
|
||||
useEffect(() => console.log(expiryDate), [expiryDate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||
}, [isExpiryDateEnabled]);
|
||||
useEffect(() => {
|
||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||
}, [isExpiryDateEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filesContent.length > 0) {
|
||||
const file = filesContent[0];
|
||||
readXlsxFile(file.content).then((rows) => {
|
||||
try {
|
||||
const information = uniqBy(
|
||||
rows
|
||||
.map((row) => {
|
||||
const [
|
||||
firstName,
|
||||
lastName,
|
||||
country,
|
||||
passport_id,
|
||||
email,
|
||||
...phone
|
||||
] = row as string[];
|
||||
return EMAIL_REGEX.test(email.toString().trim())
|
||||
? {
|
||||
email: email.toString().trim().toLowerCase(),
|
||||
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
|
||||
passport_id: passport_id?.toString().trim() || undefined,
|
||||
}
|
||||
: undefined;
|
||||
})
|
||||
.filter((x) => !!x) as typeof infos,
|
||||
(x) => x.email,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (filesContent.length > 0) {
|
||||
const file = filesContent[0];
|
||||
readXlsxFile(file.content).then((rows) => {
|
||||
try {
|
||||
const information = uniqBy(
|
||||
rows
|
||||
.map((row) => {
|
||||
const [firstName, lastName, country, passport_id, email, ...phone] = row as string[];
|
||||
return EMAIL_REGEX.test(email.toString().trim())
|
||||
? {
|
||||
email: email.toString().trim().toLowerCase(),
|
||||
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
|
||||
passport_id: passport_id?.toString().trim() || undefined,
|
||||
}
|
||||
: undefined;
|
||||
})
|
||||
.filter((x) => !!x) as typeof infos,
|
||||
(x) => x.email,
|
||||
);
|
||||
|
||||
if (information.length === 0) {
|
||||
toast.error(
|
||||
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
|
||||
);
|
||||
return clear();
|
||||
}
|
||||
if (information.length === 0) {
|
||||
toast.error(
|
||||
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
|
||||
);
|
||||
return clear();
|
||||
}
|
||||
|
||||
setInfos(information);
|
||||
} catch {
|
||||
toast.error(
|
||||
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
|
||||
);
|
||||
return clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filesContent]);
|
||||
setInfos(information);
|
||||
} catch {
|
||||
toast.error(
|
||||
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
|
||||
);
|
||||
return clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filesContent]);
|
||||
|
||||
const generateAndInvite = async () => {
|
||||
const newUsers = infos.filter(
|
||||
(x) => !users.map((u) => u.email).includes(x.email),
|
||||
);
|
||||
const existingUsers = infos
|
||||
.filter((x) => users.map((u) => u.email).includes(x.email))
|
||||
.map((i) => users.find((u) => u.email === i.email))
|
||||
.filter((x) => !!x && x.type === "student") as User[];
|
||||
const generateAndInvite = async () => {
|
||||
const newUsers = infos.filter((x) => !users.map((u) => u.email).includes(x.email));
|
||||
const existingUsers = infos
|
||||
.filter((x) => users.map((u) => u.email).includes(x.email))
|
||||
.map((i) => users.find((u) => u.email === i.email))
|
||||
.filter((x) => !!x && x.type === "student") as User[];
|
||||
|
||||
const newUsersSentence =
|
||||
newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined;
|
||||
const existingUsersSentence =
|
||||
existingUsers.length > 0
|
||||
? `invite ${existingUsers.length} registered student(s)`
|
||||
: undefined;
|
||||
if (
|
||||
!confirm(
|
||||
`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
const newUsersSentence = newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined;
|
||||
const existingUsersSentence = existingUsers.length > 0 ? `invite ${existingUsers.length} registered student(s)` : undefined;
|
||||
if (
|
||||
!confirm(
|
||||
`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
setIsLoading(true);
|
||||
Promise.all(
|
||||
existingUsers.map(
|
||||
async (u) =>
|
||||
await axios.post(`/api/invites`, { to: u.id, from: user.id }),
|
||||
),
|
||||
)
|
||||
.then(() =>
|
||||
toast.success(
|
||||
`Successfully invited ${existingUsers.length} registered student(s)!`,
|
||||
),
|
||||
)
|
||||
.finally(() => {
|
||||
if (newUsers.length === 0) setIsLoading(false);
|
||||
});
|
||||
setIsLoading(true);
|
||||
Promise.all(existingUsers.map(async (u) => await axios.post(`/api/invites`, {to: u.id, from: user.id})))
|
||||
.then(() => toast.success(`Successfully invited ${existingUsers.length} registered student(s)!`))
|
||||
.finally(() => {
|
||||
if (newUsers.length === 0) setIsLoading(false);
|
||||
});
|
||||
|
||||
if (newUsers.length > 0) generateCode(type, newUsers);
|
||||
setInfos([]);
|
||||
};
|
||||
if (newUsers.length > 0) generateCode(type, newUsers);
|
||||
setInfos([]);
|
||||
};
|
||||
|
||||
const generateCode = (type: Type, informations: typeof infos) => {
|
||||
const uid = new ShortUniqueId();
|
||||
const codes = informations.map(() => uid.randomUUID(6));
|
||||
const generateCode = (type: Type, informations: typeof infos) => {
|
||||
const uid = new ShortUniqueId();
|
||||
const codes = informations.map(() => uid.randomUUID(6));
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.post<{ ok: boolean; valid?: number; reason?: string }>("/api/code", {
|
||||
type,
|
||||
codes,
|
||||
infos: informations,
|
||||
expiryDate,
|
||||
})
|
||||
.then(({ data, status }) => {
|
||||
if (data.ok) {
|
||||
toast.success(
|
||||
`Successfully generated${data.valid ? ` ${data.valid}/${informations.length}` : ""} ${capitalize(
|
||||
type,
|
||||
)} codes and they have been notified by e-mail!`,
|
||||
{ toastId: "success" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.post<{ok: boolean; valid?: number; reason?: string}>("/api/code", {
|
||||
type,
|
||||
codes,
|
||||
infos: informations,
|
||||
expiryDate,
|
||||
})
|
||||
.then(({data, status}) => {
|
||||
if (data.ok) {
|
||||
toast.success(
|
||||
`Successfully generated${data.valid ? ` ${data.valid}/${informations.length}` : ""} ${capitalize(
|
||||
type,
|
||||
)} codes and they have been notified by e-mail!`,
|
||||
{toastId: "success"},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, { toastId: "forbidden" });
|
||||
}
|
||||
})
|
||||
.catch(({ response: { status, data } }) => {
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, { toastId: "forbidden" });
|
||||
return;
|
||||
}
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, {toastId: "forbidden"});
|
||||
}
|
||||
})
|
||||
.catch(({response: {status, data}}) => {
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, {toastId: "forbidden"});
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(`Something went wrong, please try again later!`, {
|
||||
toastId: "error",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
return clear();
|
||||
});
|
||||
};
|
||||
toast.error(`Something went wrong, please try again later!`, {
|
||||
toastId: "error",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
return clear();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={showHelp}
|
||||
onClose={() => setShowHelp(false)}
|
||||
title="Excel File Format"
|
||||
>
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<span>Please upload an Excel file with the following format:</span>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border border-neutral-200 px-2 py-1">
|
||||
First Name
|
||||
</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">
|
||||
Last Name
|
||||
</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">Country</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">
|
||||
Passport/National ID
|
||||
</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">E-mail</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">
|
||||
Phone Number
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
<span className="mt-4">
|
||||
<b>Notes:</b>
|
||||
<ul>
|
||||
<li>- All incorrect e-mails will be ignored;</li>
|
||||
<li>- All already registered e-mails will be ignored;</li>
|
||||
<li>
|
||||
- You may have a header row with the format above, however, it
|
||||
is not necessary;
|
||||
</li>
|
||||
<li>
|
||||
- All of the e-mails in the file will receive an e-mail to join
|
||||
EnCoach with the role selected below.
|
||||
</li>
|
||||
</ul>
|
||||
</span>
|
||||
</div>
|
||||
</Modal>
|
||||
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
|
||||
<div className="flex items-end justify-between">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Choose an Excel file
|
||||
</label>
|
||||
<div
|
||||
className="tooltip cursor-pointer"
|
||||
data-tip="Excel File Format"
|
||||
onClick={() => setShowHelp(true)}
|
||||
>
|
||||
<BsQuestionCircleFill />
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={openFilePicker}
|
||||
isLoading={isLoading}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
||||
</Button>
|
||||
{user && (user.type === "developer" || user.type === "admin") && (
|
||||
<>
|
||||
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Expiry Date
|
||||
</label>
|
||||
<Checkbox
|
||||
isChecked={isExpiryDateEnabled}
|
||||
onChange={setIsExpiryDateEnabled}
|
||||
>
|
||||
Enabled
|
||||
</Checkbox>
|
||||
</div>
|
||||
{isExpiryDateEnabled && (
|
||||
<ReactDatePicker
|
||||
className={clsx(
|
||||
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||
"hover:border-mti-purple tooltip",
|
||||
"transition duration-300 ease-in-out",
|
||||
)}
|
||||
filterDate={(date) => moment(date).isAfter(new Date())}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={expiryDate}
|
||||
onChange={(date) => setExpiryDate(date)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Select the type of user they should be
|
||||
</label>
|
||||
{user && (
|
||||
<select
|
||||
defaultValue="student"
|
||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none"
|
||||
>
|
||||
{Object.keys(USER_TYPE_LABELS)
|
||||
.filter((x) =>
|
||||
USER_TYPE_PERMISSIONS[user.type].includes(x as Type),
|
||||
)
|
||||
.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Button
|
||||
onClick={generateAndInvite}
|
||||
disabled={
|
||||
infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)
|
||||
}
|
||||
>
|
||||
Generate & Send
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={showHelp} onClose={() => setShowHelp(false)} title="Excel File Format">
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<span>Please upload an Excel file with the following format:</span>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border border-neutral-200 px-2 py-1">First Name</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">Last Name</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">Country</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">Passport/National ID</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">E-mail</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">Phone Number</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
<span className="mt-4">
|
||||
<b>Notes:</b>
|
||||
<ul>
|
||||
<li>- All incorrect e-mails will be ignored;</li>
|
||||
<li>- All already registered e-mails will be ignored;</li>
|
||||
<li>- You may have a header row with the format above, however, it is not necessary;</li>
|
||||
<li>- All of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below.</li>
|
||||
</ul>
|
||||
</span>
|
||||
</div>
|
||||
</Modal>
|
||||
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
|
||||
<div className="flex items-end justify-between">
|
||||
<label className="text-mti-gray-dim text-base font-normal">Choose an Excel file</label>
|
||||
<div className="tooltip cursor-pointer" data-tip="Excel File Format" onClick={() => setShowHelp(true)}>
|
||||
<BsQuestionCircleFill />
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
|
||||
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
||||
</Button>
|
||||
{user && (user.type === "developer" || user.type === "admin" || 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">
|
||||
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
|
||||
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}>
|
||||
Enabled
|
||||
</Checkbox>
|
||||
</div>
|
||||
{isExpiryDateEnabled && (
|
||||
<ReactDatePicker
|
||||
className={clsx(
|
||||
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||
"hover:border-mti-purple tooltip",
|
||||
"transition duration-300 ease-in-out",
|
||||
)}
|
||||
filterDate={(date) =>
|
||||
moment(date).isAfter(new Date()) &&
|
||||
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
|
||||
}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={expiryDate}
|
||||
onChange={(date) => setExpiryDate(date)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<label className="text-mti-gray-dim text-base font-normal">Select the type of user they should be</label>
|
||||
{user && (
|
||||
<select
|
||||
defaultValue="student"
|
||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none">
|
||||
{Object.keys(USER_TYPE_LABELS)
|
||||
.filter((x) => USER_TYPE_PERMISSIONS[user.type].includes(x as Type))
|
||||
.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Button onClick={generateAndInvite} disabled={infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}>
|
||||
Generate & Send
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,16 +23,12 @@ const USER_TYPE_PERMISSIONS: {[key in Type]: Type[]} = {
|
||||
|
||||
export default function CodeGenerator({user}: {user: User}) {
|
||||
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 [type, setType] = useState<Type>("student");
|
||||
|
||||
useEffect(() => {
|
||||
if (user && (user.type === "corporate" || user.type === "teacher")) {
|
||||
setExpiryDate(user.subscriptionExpirationDate || null);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||
}, [isExpiryDateEnabled]);
|
||||
@@ -81,22 +77,25 @@ export default function CodeGenerator({user}: {user: User}) {
|
||||
))}
|
||||
</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">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
|
||||
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled}>
|
||||
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
||||
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
|
||||
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}>
|
||||
Enabled
|
||||
</Checkbox>
|
||||
</div>
|
||||
{isExpiryDateEnabled && (
|
||||
<ReactDatePicker
|
||||
className={clsx(
|
||||
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||
"hover:border-mti-purple tooltip",
|
||||
"transition duration-300 ease-in-out",
|
||||
)}
|
||||
filterDate={(date) => moment(date).isAfter(new Date())}
|
||||
filterDate={(date) =>
|
||||
moment(date).isAfter(new Date()) &&
|
||||
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
|
||||
}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={expiryDate}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
307
src/pages/(admin)/Lists/DiscountList.tsx
Normal file
307
src/pages/(admin)/Lists/DiscountList.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
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 clsx from "clsx";
|
||||
import moment from "moment";
|
||||
import {useEffect, useState} from "react";
|
||||
import ReactDatePicker from "react-datepicker";
|
||||
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 [validUntil, setValidUntil] = useState(discount?.validUntil);
|
||||
|
||||
const submit = async () => {
|
||||
const body = {percentage, domain, validUntil: validUntil?.toISOString() || undefined};
|
||||
|
||||
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 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 className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Valid Until</label>
|
||||
<div className="flex gap-4 items-center w-full">
|
||||
<ReactDatePicker
|
||||
wrapperClassName="w-full z-[900]"
|
||||
calendarClassName="z-[900]"
|
||||
popperClassName="z-[900]"
|
||||
isClearable
|
||||
className={clsx(
|
||||
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||
"hover:border-mti-purple tooltip",
|
||||
"transition duration-300 ease-in-out",
|
||||
)}
|
||||
filterDate={(date) => moment(date).isAfter(new Date())}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={validUntil}
|
||||
onChange={(date) => setValidUntil(date ? moment(date).endOf("day").toDate() : undefined)}
|
||||
/>
|
||||
</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()}%`,
|
||||
}),
|
||||
columnHelper.accessor("validUntil", {
|
||||
header: "Valid Until",
|
||||
cell: (info) => (info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : ""),
|
||||
}),
|
||||
{
|
||||
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,400 +3,335 @@ import Input from "@/components/Low/Input";
|
||||
import Modal from "@/components/Modal";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import { Group, User } from "@/interfaces/user";
|
||||
import {
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import {CorporateUser, Group, User} from "@/interfaces/user";
|
||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
||||
import axios from "axios";
|
||||
import { capitalize, uniq } from "lodash";
|
||||
import { useEffect, useState } from "react";
|
||||
import { BsPencil, BsQuestionCircleFill, BsTrash } from "react-icons/bs";
|
||||
import {capitalize, uniq} from "lodash";
|
||||
import {useEffect, useState} from "react";
|
||||
import {BsPencil, BsQuestionCircleFill, BsTrash} from "react-icons/bs";
|
||||
import Select from "react-select";
|
||||
import { toast } from "react-toastify";
|
||||
import {toast} from "react-toastify";
|
||||
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 EMAIL_REGEX = new RegExp(
|
||||
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/,
|
||||
);
|
||||
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
||||
|
||||
const 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 {
|
||||
user: User;
|
||||
users: User[];
|
||||
group?: Group;
|
||||
onClose: () => void;
|
||||
user: User;
|
||||
users: User[];
|
||||
group?: Group;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
||||
const [name, setName] = useState<string | undefined>(
|
||||
group?.name || undefined,
|
||||
);
|
||||
const [admin, setAdmin] = useState<string>(group?.admin || user.id);
|
||||
const [participants, setParticipants] = useState<string[]>(
|
||||
group?.participants || [],
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
||||
const [name, setName] = useState<string | undefined>(group?.name || undefined);
|
||||
const [admin, setAdmin] = useState<string>(group?.admin || user.id);
|
||||
const [participants, setParticipants] = useState<string[]>(group?.participants || []);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { openFilePicker, filesContent, clear } = useFilePicker({
|
||||
accept: ".xlsx",
|
||||
multiple: false,
|
||||
readAs: "ArrayBuffer",
|
||||
});
|
||||
const {openFilePicker, filesContent, clear} = useFilePicker({
|
||||
accept: ".xlsx",
|
||||
multiple: false,
|
||||
readAs: "ArrayBuffer",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (filesContent.length > 0) {
|
||||
setIsLoading(true);
|
||||
useEffect(() => {
|
||||
if (filesContent.length > 0) {
|
||||
setIsLoading(true);
|
||||
|
||||
const file = filesContent[0];
|
||||
readXlsxFile(file.content).then((rows) => {
|
||||
const emails = uniq(
|
||||
rows
|
||||
.map((row) => {
|
||||
const [email] = row as string[];
|
||||
return EMAIL_REGEX.test(email) &&
|
||||
!users.map((u) => u.email).includes(email)
|
||||
? email.toString().trim()
|
||||
: undefined;
|
||||
})
|
||||
.filter((x) => !!x),
|
||||
);
|
||||
const file = filesContent[0];
|
||||
readXlsxFile(file.content).then((rows) => {
|
||||
const emails = uniq(
|
||||
rows
|
||||
.map((row) => {
|
||||
const [email] = row as string[];
|
||||
return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email) ? email.toString().trim() : undefined;
|
||||
})
|
||||
.filter((x) => !!x),
|
||||
);
|
||||
|
||||
if (emails.length === 0) {
|
||||
toast.error("Please upload an Excel file containing e-mails!");
|
||||
clear();
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (emails.length === 0) {
|
||||
toast.error("Please upload an Excel file containing e-mails!");
|
||||
clear();
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const emailUsers = [...new Set(emails)]
|
||||
.map((x) => users.find((y) => y.email.toLowerCase() === x))
|
||||
.filter((x) => x !== undefined);
|
||||
const filteredUsers = emailUsers.filter(
|
||||
(x) =>
|
||||
((user.type === "developer" ||
|
||||
user.type === "admin" ||
|
||||
user.type === "corporate") &&
|
||||
(x?.type === "student" || x?.type === "teacher")) ||
|
||||
(user.type === "teacher" && x?.type === "student"),
|
||||
);
|
||||
const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined);
|
||||
const filteredUsers = emailUsers.filter(
|
||||
(x) =>
|
||||
((user.type === "developer" || user.type === "admin" || user.type === "corporate") &&
|
||||
(x?.type === "student" || x?.type === "teacher")) ||
|
||||
(user.type === "teacher" && x?.type === "student"),
|
||||
);
|
||||
|
||||
setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id));
|
||||
toast.success(
|
||||
user.type !== "teacher"
|
||||
? "Added all teachers and students found in the file you've provided!"
|
||||
: "Added all students found in the file you've provided!",
|
||||
{ toastId: "upload-success" },
|
||||
);
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filesContent, user.type, users]);
|
||||
setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id));
|
||||
toast.success(
|
||||
user.type !== "teacher"
|
||||
? "Added all teachers and students found in the file you've provided!"
|
||||
: "Added all students found in the file you've provided!",
|
||||
{toastId: "upload-success"},
|
||||
);
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filesContent, user.type, users]);
|
||||
|
||||
const submit = () => {
|
||||
setIsLoading(true);
|
||||
const submit = () => {
|
||||
setIsLoading(true);
|
||||
|
||||
if (name !== group?.name && (name === "Students" || name === "Teachers")) {
|
||||
toast.error(
|
||||
"That group name is reserved and cannot be used, please enter another one.",
|
||||
);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (name !== group?.name && (name === "Students" || name === "Teachers")) {
|
||||
toast.error("That group name is reserved and cannot be used, please enter another one.");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
(group ? axios.patch : axios.post)(
|
||||
group ? `/api/groups/${group.id}` : "/api/groups",
|
||||
{ name, admin, participants },
|
||||
)
|
||||
.then(() => {
|
||||
toast.success(
|
||||
`Group "${name}" ${group ? "edited" : "created"} successfully`,
|
||||
);
|
||||
return true;
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
return false;
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
onClose();
|
||||
});
|
||||
};
|
||||
(group ? axios.patch : axios.post)(group ? `/api/groups/${group.id}` : "/api/groups", {name, admin, participants})
|
||||
.then(() => {
|
||||
toast.success(`Group "${name}" ${group ? "edited" : "created"} successfully`);
|
||||
return true;
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
return false;
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
onClose();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex w-full flex-col gap-12 px-4 py-2">
|
||||
<div className="flex flex-col gap-8">
|
||||
<Input
|
||||
name="name"
|
||||
type="text"
|
||||
label="Name"
|
||||
defaultValue={name}
|
||||
onChange={setName}
|
||||
required
|
||||
disabled={group?.disableEditing}
|
||||
/>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Participants
|
||||
</label>
|
||||
<div
|
||||
className="tooltip"
|
||||
data-tip="The Excel file should only include a column with the desired e-mails."
|
||||
>
|
||||
<BsQuestionCircleFill />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full gap-8">
|
||||
<Select
|
||||
className="w-full"
|
||||
value={participants.map((x) => ({
|
||||
value: x,
|
||||
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
|
||||
}))}
|
||||
placeholder="Participants..."
|
||||
defaultValue={participants.map((x) => ({
|
||||
value: x,
|
||||
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
|
||||
}))}
|
||||
options={users
|
||||
.filter((x) =>
|
||||
user.type === "teacher"
|
||||
? x.type === "student"
|
||||
: x.type === "student" || x.type === "teacher",
|
||||
)
|
||||
.map((x) => ({ value: x.id, label: `${x.email} - ${x.name}` }))}
|
||||
onChange={(value) => setParticipants(value.map((x) => x.value))}
|
||||
isMulti
|
||||
isSearchable
|
||||
menuPortalTarget={document?.body}
|
||||
styles={{
|
||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
backgroundColor: "white",
|
||||
borderRadius: "999px",
|
||||
padding: "1rem 1.5rem",
|
||||
zIndex: "40",
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
{user.type !== "teacher" && (
|
||||
<Button
|
||||
className="w-full max-w-[300px]"
|
||||
onClick={openFilePicker}
|
||||
isLoading={isLoading}
|
||||
variant="outline"
|
||||
>
|
||||
{filesContent.length === 0
|
||||
? "Upload participants Excel file"
|
||||
: filesContent[0].name}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 flex w-full items-center justify-end gap-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
className="w-full max-w-[200px]"
|
||||
isLoading={isLoading}
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full max-w-[200px]"
|
||||
onClick={submit}
|
||||
isLoading={isLoading}
|
||||
disabled={!name}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="mt-4 flex w-full flex-col gap-12 px-4 py-2">
|
||||
<div className="flex flex-col gap-8">
|
||||
<Input name="name" type="text" label="Name" defaultValue={name} onChange={setName} required disabled={group?.disableEditing} />
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-mti-gray-dim text-base font-normal">Participants</label>
|
||||
<div className="tooltip" data-tip="The Excel file should only include a column with the desired e-mails.">
|
||||
<BsQuestionCircleFill />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full gap-8">
|
||||
<Select
|
||||
className="w-full"
|
||||
value={participants.map((x) => ({
|
||||
value: x,
|
||||
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
|
||||
}))}
|
||||
placeholder="Participants..."
|
||||
defaultValue={participants.map((x) => ({
|
||||
value: x,
|
||||
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
|
||||
}))}
|
||||
options={users
|
||||
.filter((x) => (user.type === "teacher" ? x.type === "student" : x.type === "student" || x.type === "teacher"))
|
||||
.map((x) => ({value: x.id, label: `${x.email} - ${x.name}`}))}
|
||||
onChange={(value) => setParticipants(value.map((x) => x.value))}
|
||||
isMulti
|
||||
isSearchable
|
||||
menuPortalTarget={document?.body}
|
||||
styles={{
|
||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
backgroundColor: "white",
|
||||
borderRadius: "999px",
|
||||
padding: "1rem 1.5rem",
|
||||
zIndex: "40",
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
{user.type !== "teacher" && (
|
||||
<Button className="w-full max-w-[300px]" onClick={openFilePicker} isLoading={isLoading} variant="outline">
|
||||
{filesContent.length === 0 ? "Upload participants Excel file" : filesContent[0].name}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 flex w-full items-center justify-end gap-8">
|
||||
<Button variant="outline" color="red" className="w-full max-w-[200px]" isLoading={isLoading} onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full max-w-[200px]" onClick={submit} isLoading={isLoading} disabled={!name}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const filterTypes = ["corporate", "teacher"];
|
||||
|
||||
export default function GroupList({ user }: { user: User }) {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [editingGroup, setEditingGroup] = useState<Group>();
|
||||
const [filterByUser, setFilterByUser] = useState(false);
|
||||
export default function GroupList({user}: {user: User}) {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [editingGroup, setEditingGroup] = useState<Group>();
|
||||
const [filterByUser, setFilterByUser] = useState(false);
|
||||
|
||||
const { users } = useUsers();
|
||||
const { groups, reload } = useGroups(
|
||||
user && filterTypes.includes(user?.type) ? user.id : undefined,
|
||||
);
|
||||
const {users} = useUsers();
|
||||
const {groups, reload} = useGroups(user && filterTypes.includes(user?.type) ? user.id : undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (user && (user.type === "corporate" || user.type === "teacher")) {
|
||||
setFilterByUser(true);
|
||||
}
|
||||
}, [user]);
|
||||
useEffect(() => {
|
||||
if (user && (user.type === "corporate" || user.type === "teacher")) {
|
||||
setFilterByUser(true);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const deleteGroup = (group: Group) => {
|
||||
if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return;
|
||||
const deleteGroup = (group: Group) => {
|
||||
if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return;
|
||||
|
||||
axios
|
||||
.delete<{ ok: boolean }>(`/api/groups/${group.id}`)
|
||||
.then(() => toast.success(`Group "${group.name}" deleted successfully`))
|
||||
.catch(() => toast.error("Something went wrong, please try again later!"))
|
||||
.finally(reload);
|
||||
};
|
||||
axios
|
||||
.delete<{ok: boolean}>(`/api/groups/${group.id}`)
|
||||
.then(() => toast.success(`Group "${group.name}" deleted successfully`))
|
||||
.catch(() => toast.error("Something went wrong, please try again later!"))
|
||||
.finally(reload);
|
||||
};
|
||||
|
||||
const defaultColumns = [
|
||||
columnHelper.accessor("id", {
|
||||
header: "ID",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("name", {
|
||||
header: "Name",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("admin", {
|
||||
header: "Admin",
|
||||
cell: (info) => (
|
||||
<div
|
||||
className="tooltip"
|
||||
data-tip={capitalize(
|
||||
users.find((x) => x.id === info.getValue())?.type,
|
||||
)}
|
||||
>
|
||||
{users.find((x) => x.id === info.getValue())?.name}
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("participants", {
|
||||
header: "Participants",
|
||||
cell: (info) =>
|
||||
info
|
||||
.getValue()
|
||||
.map((x) => users.find((y) => y.id === x)?.name)
|
||||
.join(", "),
|
||||
}),
|
||||
{
|
||||
header: "",
|
||||
id: "actions",
|
||||
cell: ({ row }: { row: { original: Group } }) => {
|
||||
return (
|
||||
<>
|
||||
{user &&
|
||||
(user.type === "developer" ||
|
||||
user.type === "admin" ||
|
||||
user.id === row.original.admin) && (
|
||||
<div className="flex gap-2">
|
||||
{(!row.original.disableEditing ||
|
||||
["developer", "admin"].includes(user.type)) && (
|
||||
<div
|
||||
data-tip="Edit"
|
||||
className="tooltip cursor-pointer"
|
||||
onClick={() => setEditingGroup(row.original)}
|
||||
>
|
||||
<BsPencil className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
||||
</div>
|
||||
)}
|
||||
{(!row.original.disableEditing ||
|
||||
["developer", "admin"].includes(user.type)) && (
|
||||
<div
|
||||
data-tip="Delete"
|
||||
className="tooltip cursor-pointer"
|
||||
onClick={() => deleteGroup(row.original)}
|
||||
>
|
||||
<BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
const defaultColumns = [
|
||||
columnHelper.accessor("id", {
|
||||
header: "ID",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("name", {
|
||||
header: "Name",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("admin", {
|
||||
header: "Admin",
|
||||
cell: (info) => (
|
||||
<div className="tooltip" data-tip={capitalize(users.find((x) => x.id === info.getValue())?.type)}>
|
||||
{users.find((x) => x.id === info.getValue())?.name}
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("admin", {
|
||||
header: "Linked Corporate",
|
||||
cell: (info) => <LinkedCorporate userId={info.getValue()} users={users} groups={groups} />,
|
||||
}),
|
||||
columnHelper.accessor("participants", {
|
||||
header: "Participants",
|
||||
cell: (info) =>
|
||||
info
|
||||
.getValue()
|
||||
.map((x) => users.find((y) => y.id === x)?.name)
|
||||
.join(", "),
|
||||
}),
|
||||
{
|
||||
header: "",
|
||||
id: "actions",
|
||||
cell: ({row}: {row: {original: Group}}) => {
|
||||
return (
|
||||
<>
|
||||
{user && (user.type === "developer" || user.type === "admin" || user.id === row.original.admin) && (
|
||||
<div className="flex gap-2">
|
||||
{(!row.original.disableEditing || ["developer", "admin"].includes(user.type)) && (
|
||||
<div data-tip="Edit" className="tooltip cursor-pointer" onClick={() => setEditingGroup(row.original)}>
|
||||
<BsPencil className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
||||
</div>
|
||||
)}
|
||||
{(!row.original.disableEditing || ["developer", "admin"].includes(user.type)) && (
|
||||
<div data-tip="Delete" className="tooltip cursor-pointer" onClick={() => deleteGroup(row.original)}>
|
||||
<BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: groups,
|
||||
columns: defaultColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
const table = useReactTable({
|
||||
data: groups,
|
||||
columns: defaultColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const closeModal = () => {
|
||||
setIsCreating(false);
|
||||
setEditingGroup(undefined);
|
||||
reload();
|
||||
};
|
||||
const closeModal = () => {
|
||||
setIsCreating(false);
|
||||
setEditingGroup(undefined);
|
||||
reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full rounded-xl">
|
||||
<Modal
|
||||
isOpen={isCreating || !!editingGroup}
|
||||
onClose={closeModal}
|
||||
title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}
|
||||
>
|
||||
<CreatePanel
|
||||
group={editingGroup}
|
||||
user={user}
|
||||
onClose={closeModal}
|
||||
users={
|
||||
user?.type === "corporate" || user?.type === "teacher"
|
||||
? users.filter(
|
||||
(u) =>
|
||||
groups
|
||||
.filter((g) => g.admin === user.id)
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(u.id) ||
|
||||
groups.flatMap((g) => g.participants).includes(u.id),
|
||||
)
|
||||
: users
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
<table className="bg-mti-purple-ultralight/40 w-full rounded-xl">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th className="py-4" key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="px-2">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr
|
||||
className="even:bg-mti-purple-ultralight/40 rounded-lg py-2 odd:bg-white"
|
||||
key={row.id}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td className="px-4 py-2" key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
return (
|
||||
<div className="h-full w-full rounded-xl">
|
||||
<Modal isOpen={isCreating || !!editingGroup} onClose={closeModal} title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}>
|
||||
<CreatePanel
|
||||
group={editingGroup}
|
||||
user={user}
|
||||
onClose={closeModal}
|
||||
users={
|
||||
user?.type === "corporate" || user?.type === "teacher"
|
||||
? users.filter(
|
||||
(u) =>
|
||||
groups
|
||||
.filter((g) => g.admin === user.id)
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(u.id) || groups.flatMap((g) => g.participants).includes(u.id),
|
||||
)
|
||||
: users
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
<table className="bg-mti-purple-ultralight/40 w-full rounded-xl">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th className="py-4" key={header.id}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="px-2">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr className="even:bg-mti-purple-ultralight/40 rounded-lg py-2 odd:bg-white" key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td className="px-4 py-2" key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out"
|
||||
>
|
||||
New Group
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out">
|
||||
New Group
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
|
||||
const [unit, setUnit] = useState<DurationUnit>(pack?.duration_unit || "months");
|
||||
|
||||
const [price, setPrice] = useState(pack?.price || 0);
|
||||
const [currency, setCurrency] = useState<string>(pack?.currency || "EUR");
|
||||
const [currency, setCurrency] = useState<string>(pack?.currency || "OMR");
|
||||
|
||||
const submit = () => {
|
||||
(pack ? axios.patch : axios.post)(pack ? `/api/packages/${pack.id}` : "/api/packages", {
|
||||
|
||||
@@ -2,7 +2,7 @@ import Button from "@/components/Low/Button";
|
||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
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 {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
||||
import axios from "axios";
|
||||
@@ -16,18 +16,30 @@ import {countries, TCountries} from "countries-list";
|
||||
import countryCodes from "country-codes-list";
|
||||
import Modal from "@/components/Modal";
|
||||
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 {useRouter} from "next/router";
|
||||
import {isCorporateUser} from '@/resources/user';
|
||||
import { useListSearch } from "@/hooks/useListSearch";
|
||||
import {isCorporateUser} from "@/resources/user";
|
||||
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 searchFields = [
|
||||
['name'],
|
||||
['email'],
|
||||
['corporateInformation', 'companyInformation', 'name'],
|
||||
];
|
||||
const searchFields = [["name"], ["email"], ["corporateInformation", "companyInformation", "name"]];
|
||||
|
||||
const CompanyNameCell = ({users, user, groups}: {user: User; users: User[]; groups: Group[]}) => {
|
||||
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)[]}) {
|
||||
const [showDemographicInformation, setShowDemographicInformation] = useState(false);
|
||||
const [sorter, setSorter] = useState<string>();
|
||||
@@ -51,16 +63,20 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user && users) {
|
||||
const filterUsers =
|
||||
user.type === "corporate" || user.type === "teacher"
|
||||
? users.filter((u) => groups.flatMap((g) => g.participants).includes(u.id))
|
||||
: users;
|
||||
(async () => {
|
||||
if (user && users) {
|
||||
const filterUsers =
|
||||
user.type === "corporate" || user.type === "teacher"
|
||||
? users.filter((u) => groups.flatMap((g) => g.participants).includes(u.id))
|
||||
: users;
|
||||
|
||||
const filteredUsers = filters.reduce((d, f) => d.filter(f), filterUsers);
|
||||
const filteredUsers = filters.reduce((d, f) => d.filter(f), filterUsers);
|
||||
const sortedUsers = await asyncSorter<User>(filteredUsers, sortFunction);
|
||||
console.log(sortedUsers);
|
||||
|
||||
setDisplayUsers([...filteredUsers.sort(sortFunction)]);
|
||||
}
|
||||
setDisplayUsers([...sortedUsers]);
|
||||
}
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user, users, sorter, groups]);
|
||||
|
||||
@@ -331,14 +347,14 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
|
||||
) as any,
|
||||
cell: (info) => USER_TYPE_LABELS[info.getValue()],
|
||||
}),
|
||||
columnHelper.accessor('corporateInformation.companyInformation.name', {
|
||||
columnHelper.accessor("corporateInformation.companyInformation.name", {
|
||||
header: (
|
||||
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "companyName"))}>
|
||||
<span>Company Name</span>
|
||||
<SorterArrow name="companyName" />
|
||||
</button>
|
||||
) as any,
|
||||
cell: (info) => getCorporateName(info.row.original),
|
||||
cell: (info) => <CompanyNameCell user={info.row.original} users={users} groups={groups} />,
|
||||
}),
|
||||
columnHelper.accessor("subscriptionExpirationDate", {
|
||||
header: (
|
||||
@@ -393,15 +409,7 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getCorporateName = (user: User) => {
|
||||
if(isCorporateUser(user)) {
|
||||
return user.corporateInformation?.companyInformation?.name
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
const sortFunction = (a: User, b: User) => {
|
||||
const sortFunction = async (a: User, b: User) => {
|
||||
if (sorter === "name" || sorter === reverseString("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);
|
||||
}
|
||||
|
||||
if(sorter === 'companyName' || sorter === reverseString('companyName')) {
|
||||
const aCorporateName = getCorporateName(a);
|
||||
const bCorporateName = getCorporateName(b);
|
||||
if (sorter === "companyName" || sorter === reverseString("companyName")) {
|
||||
const aCorporateName = getUserCompanyName(a, users, groups);
|
||||
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 0;
|
||||
|
||||
return sorter === "companyName"
|
||||
? aCorporateName.localeCompare(bCorporateName)
|
||||
: bCorporateName.localeCompare(aCorporateName);
|
||||
return sorter === "companyName" ? aCorporateName.localeCompare(bCorporateName) : bCorporateName.localeCompare(aCorporateName);
|
||||
}
|
||||
|
||||
return a.id.localeCompare(b.id);
|
||||
};
|
||||
|
||||
const { rows: filteredRows, renderSearch } = useListSearch(
|
||||
searchFields,
|
||||
displayUsers,
|
||||
)
|
||||
const {rows: filteredRows, renderSearch} = useListSearch<User>(searchFields, displayUsers);
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredRows,
|
||||
@@ -494,6 +497,18 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
|
||||
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 (
|
||||
<div className="w-full">
|
||||
<Modal isOpen={!!selectedUser} onClose={() => setSelectedUser(undefined)}>
|
||||
@@ -573,7 +588,12 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
|
||||
</>
|
||||
</Modal>
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
{renderSearch()}
|
||||
<div className="w-full flex gap-2 items-end">
|
||||
{renderSearch()}
|
||||
<Button className="w-full max-w-[200px] mb-1" variant="outline" onClick={downloadExcel}>
|
||||
Download List
|
||||
</Button>
|
||||
</div>
|
||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
|
||||
@@ -1,82 +1,138 @@
|
||||
import {User} from "@/interfaces/user";
|
||||
import {Tab} from "@headlessui/react";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { Tab } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import CodeList from "./CodeList";
|
||||
import DiscountList from "./DiscountList";
|
||||
import ExamList from "./ExamList";
|
||||
import GroupList from "./GroupList";
|
||||
import PackageList from "./PackageList";
|
||||
import UserList from "./UserList";
|
||||
|
||||
export default function Lists({user}: {user: User}) {
|
||||
return (
|
||||
<Tab.Group>
|
||||
<Tab.List className="flex space-x-1 rounded-xl bg-mti-purple-ultralight/40 p-1">
|
||||
<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",
|
||||
)
|
||||
}>
|
||||
User List
|
||||
</Tab>
|
||||
{user?.type === "developer" && (
|
||||
<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",
|
||||
)
|
||||
}>
|
||||
Exam List
|
||||
</Tab>
|
||||
)}
|
||||
<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",
|
||||
)
|
||||
}>
|
||||
Group List
|
||||
</Tab>
|
||||
{user && ["developer", "admin"].includes(user.type) && (
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
|
||||
)
|
||||
}>
|
||||
Package List
|
||||
</Tab>
|
||||
)}
|
||||
</Tab.List>
|
||||
<Tab.Panels className="mt-2">
|
||||
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
||||
<UserList user={user} />
|
||||
</Tab.Panel>
|
||||
{user?.type === "developer" && (
|
||||
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
||||
<ExamList user={user} />
|
||||
</Tab.Panel>
|
||||
)}
|
||||
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
||||
<GroupList user={user} />
|
||||
</Tab.Panel>
|
||||
{user && ["developer", "admin"].includes(user.type) && (
|
||||
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
||||
<PackageList user={user} />
|
||||
</Tab.Panel>
|
||||
)}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
);
|
||||
export default function Lists({ user }: { user: User }) {
|
||||
return (
|
||||
<Tab.Group>
|
||||
<Tab.List className="flex space-x-1 rounded-xl bg-mti-purple-ultralight/40 p-1">
|
||||
<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",
|
||||
)
|
||||
}
|
||||
>
|
||||
User List
|
||||
</Tab>
|
||||
{user?.type === "developer" && (
|
||||
<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",
|
||||
)
|
||||
}
|
||||
>
|
||||
Exam List
|
||||
</Tab>
|
||||
)}
|
||||
<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",
|
||||
)
|
||||
}
|
||||
>
|
||||
Group List
|
||||
</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) && (
|
||||
<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",
|
||||
)
|
||||
}
|
||||
>
|
||||
Package List
|
||||
</Tab>
|
||||
)}
|
||||
{user && ["developer", "admin"].includes(user.type) && (
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected
|
||||
? "bg-white shadow"
|
||||
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
|
||||
)
|
||||
}
|
||||
>
|
||||
Discount List
|
||||
</Tab>
|
||||
)}
|
||||
</Tab.List>
|
||||
<Tab.Panels className="mt-2">
|
||||
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
||||
<UserList user={user} />
|
||||
</Tab.Panel>
|
||||
{user?.type === "developer" && (
|
||||
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
||||
<ExamList user={user} />
|
||||
</Tab.Panel>
|
||||
)}
|
||||
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
||||
<GroupList user={user} />
|
||||
</Tab.Panel>
|
||||
{user && ["developer", "admin", "corporate"].includes(user.type) && (
|
||||
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
||||
<CodeList user={user} />
|
||||
</Tab.Panel>
|
||||
)}
|
||||
{user && ["developer", "admin"].includes(user.type) && (
|
||||
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
||||
<PackageList user={user} />
|
||||
</Tab.Panel>
|
||||
)}
|
||||
{user && ["developer", "admin"].includes(user.type) && (
|
||||
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
||||
<DiscountList user={user} />
|
||||
</Tab.Panel>
|
||||
)}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ export default function ExamPage({page}: Props) {
|
||||
const assignment = useExamStore((state) => state.assignment);
|
||||
const initialTimeSpent = useExamStore((state) => state.timeSpent);
|
||||
|
||||
const examStore = useExamStore;
|
||||
|
||||
const {exam, setExam} = useExamStore((state) => state);
|
||||
const {exams, setExams} = useExamStore((state) => state);
|
||||
const {sessionId, setSessionId} = useExamStore((state) => state);
|
||||
@@ -123,6 +125,7 @@ export default function ExamPage({page}: Props) {
|
||||
setSessionId(shortUID.randomUUID(8));
|
||||
}
|
||||
}, [setSessionId, selectedModules, sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.type === "developer") console.log(exam);
|
||||
}, [exam, user]);
|
||||
@@ -150,7 +153,7 @@ export default function ExamPage({page}: Props) {
|
||||
const nextExam = exams[moduleIndex];
|
||||
|
||||
if (partIndex === -1 && nextExam.module !== "listening") setPartIndex(0);
|
||||
if (exerciseIndex === -1 && !["reading", "listening"].includes(nextExam.module)) setExerciseIndex(0);
|
||||
if (exerciseIndex === -1 && !["reading", "listening"].includes(nextExam?.module)) setExerciseIndex(0);
|
||||
setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined);
|
||||
}
|
||||
})();
|
||||
@@ -160,7 +163,14 @@ export default function ExamPage({page}: Props) {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (selectedModules.length > 0 && exams.length === 0) {
|
||||
const examPromises = selectedModules.map((module) => getExam(module, avoidRepeated, variant));
|
||||
const examPromises = selectedModules.map((module) =>
|
||||
getExam(
|
||||
module,
|
||||
avoidRepeated,
|
||||
variant,
|
||||
user?.type === "student" || user?.type === "developer" ? user.preferredGender : undefined,
|
||||
),
|
||||
);
|
||||
Promise.all(examPromises).then((values) => {
|
||||
if (values.every((x) => !!x)) {
|
||||
setExams(values.map((x) => x!));
|
||||
@@ -181,10 +191,11 @@ export default function ExamPage({page}: Props) {
|
||||
id: solution.id || uuidv4(),
|
||||
timeSpent,
|
||||
session: sessionId,
|
||||
exam: exam!.id,
|
||||
module: exam!.module,
|
||||
exam: solution.exam!,
|
||||
module: solution.module!,
|
||||
user: user?.id || "",
|
||||
date: new Date().getTime(),
|
||||
isDisabled: solution.isDisabled,
|
||||
...(assignment ? {assignment: assignment.id} : {}),
|
||||
}));
|
||||
|
||||
@@ -209,29 +220,33 @@ export default function ExamPage({page}: Props) {
|
||||
|
||||
const checkIfStatsHaveBeenEvaluated = (ids: string[]) => {
|
||||
setTimeout(async () => {
|
||||
const awaitedStats = await Promise.all(ids.map(async (id) => (await axios.get<Stat>(`/api/stats/${id}`)).data));
|
||||
const solutionsEvaluated = awaitedStats.every((stat) => stat.solutions.every((x) => x.evaluation !== null));
|
||||
if (solutionsEvaluated) {
|
||||
const statsUserSolutions: UserSolution[] = awaitedStats.map((stat) => ({
|
||||
id: stat.id,
|
||||
exercise: stat.exercise,
|
||||
score: stat.score,
|
||||
solutions: stat.solutions,
|
||||
type: stat.type,
|
||||
exam: stat.exam,
|
||||
module: stat.module,
|
||||
}));
|
||||
try {
|
||||
const awaitedStats = await Promise.all(ids.map(async (id) => (await axios.get<Stat>(`/api/stats/${id}`)).data));
|
||||
const solutionsEvaluated = awaitedStats.every((stat) => stat.solutions.every((x) => x.evaluation !== null));
|
||||
if (solutionsEvaluated) {
|
||||
const statsUserSolutions: UserSolution[] = awaitedStats.map((stat) => ({
|
||||
id: stat.id,
|
||||
exercise: stat.exercise,
|
||||
score: stat.score,
|
||||
solutions: stat.solutions,
|
||||
type: stat.type,
|
||||
exam: stat.exam,
|
||||
module: stat.module,
|
||||
}));
|
||||
|
||||
const updatedUserSolutions = userSolutions.map((x) => {
|
||||
const respectiveSolution = statsUserSolutions.find((y) => y.exercise === x.exercise);
|
||||
return respectiveSolution ? respectiveSolution : x;
|
||||
});
|
||||
const updatedUserSolutions = userSolutions.map((x) => {
|
||||
const respectiveSolution = statsUserSolutions.find((y) => y.exercise === x.exercise);
|
||||
return respectiveSolution ? respectiveSolution : x;
|
||||
});
|
||||
|
||||
setUserSolutions(updatedUserSolutions);
|
||||
return setStatsAwaitingEvaluation((prev) => prev.filter((x) => !ids.includes(x)));
|
||||
setUserSolutions(updatedUserSolutions);
|
||||
return setStatsAwaitingEvaluation((prev) => prev.filter((x) => !ids.includes(x)));
|
||||
}
|
||||
|
||||
return checkIfStatsHaveBeenEvaluated(ids);
|
||||
} catch {
|
||||
return checkIfStatsHaveBeenEvaluated(ids);
|
||||
}
|
||||
|
||||
return checkIfStatsHaveBeenEvaluated(ids);
|
||||
}, 5 * 1000);
|
||||
};
|
||||
|
||||
@@ -257,38 +272,44 @@ export default function ExamPage({page}: Props) {
|
||||
return Object.assign(exam, {exercises});
|
||||
};
|
||||
|
||||
const onFinish = (solutions: UserSolution[]) => {
|
||||
const onFinish = async (solutions: UserSolution[]) => {
|
||||
const solutionIds = solutions.map((x) => x.exercise);
|
||||
const solutionExams = solutions.map((x) => x.exam);
|
||||
|
||||
let newSolutions = [...solutions];
|
||||
|
||||
if (exam && !solutionExams.includes(exam.id)) return;
|
||||
|
||||
if (exam && (exam.module === "writing" || exam.module === "speaking") && solutions.length > 0 && !showSolutions) {
|
||||
setHasBeenUploaded(true);
|
||||
setIsEvaluationLoading(true);
|
||||
|
||||
Promise.all(
|
||||
exam.exercises.map(async (exercise) => {
|
||||
const evaluationID = uuidv4();
|
||||
if (exercise.type === "writing")
|
||||
return await evaluateWritingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!, evaluationID);
|
||||
const responses: UserSolution[] = (
|
||||
await Promise.all(
|
||||
exam.exercises.map(async (exercise, index) => {
|
||||
const evaluationID = uuidv4();
|
||||
if (exercise.type === "writing")
|
||||
return await evaluateWritingAnswer(exercise, index + 1, solutions.find((x) => x.exercise === exercise.id)!, evaluationID);
|
||||
|
||||
if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking")
|
||||
return await evaluateSpeakingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!, evaluationID);
|
||||
}),
|
||||
)
|
||||
.then((responses) => {
|
||||
setStatsAwaitingEvaluation((prev) => [...prev, ...responses.filter((x) => !!x).map((r) => (r as any).id)]);
|
||||
setUserSolutions([...userSolutions, ...responses.filter((x) => !!x)] as any);
|
||||
})
|
||||
.finally(() => {
|
||||
setHasBeenUploaded(false);
|
||||
});
|
||||
if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking")
|
||||
return await evaluateSpeakingAnswer(
|
||||
exercise,
|
||||
solutions.find((x) => x.exercise === exercise.id)!,
|
||||
evaluationID,
|
||||
index === 0 ? 1 : 2,
|
||||
);
|
||||
}),
|
||||
)
|
||||
).filter((x) => !!x) as UserSolution[];
|
||||
|
||||
newSolutions = [...newSolutions.filter((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);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
setPartIndex(-1);
|
||||
@@ -296,7 +317,12 @@ export default function ExamPage({page}: Props) {
|
||||
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: {
|
||||
[key in Module]: {total: number; missing: number; correct: number};
|
||||
} = {
|
||||
@@ -327,13 +353,14 @@ export default function ExamPage({page}: Props) {
|
||||
},
|
||||
};
|
||||
|
||||
answers.forEach((x) => {
|
||||
console.log({x});
|
||||
userSolutions.forEach((x) => {
|
||||
const examModule =
|
||||
x.module || (x.type === "writing" ? "writing" : x.type === "speaking" || x.type === "interactiveSpeaking" ? "speaking" : undefined);
|
||||
|
||||
scores[x.module!] = {
|
||||
total: scores[x.module!].total + x.score.total,
|
||||
correct: scores[x.module!].correct + x.score.correct,
|
||||
missing: scores[x.module!].missing + x.score.missing,
|
||||
scores[examModule!] = {
|
||||
total: scores[examModule!].total + x.score.total,
|
||||
correct: scores[examModule!].correct + x.score.correct,
|
||||
missing: scores[examModule!].missing + x.score.missing,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -365,14 +392,14 @@ export default function ExamPage({page}: Props) {
|
||||
isLoading={isEvaluationLoading}
|
||||
user={user!}
|
||||
modules={selectedModules}
|
||||
onViewResults={() => {
|
||||
onViewResults={(index?: number) => {
|
||||
setShowSolutions(true);
|
||||
setModuleIndex(0);
|
||||
setModuleIndex(index || 0);
|
||||
setExerciseIndex(["reading", "listening"].includes(exams[0].module) ? -1 : 0);
|
||||
setPartIndex(exams[0].module === "listening" ? -1 : 0);
|
||||
setExam(exams[0]);
|
||||
}}
|
||||
scores={aggregateScoresByModule(userSolutions)}
|
||||
scores={aggregateScoresByModule()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -415,7 +442,7 @@ export default function ExamPage({page}: Props) {
|
||||
<AbandonPopup
|
||||
isOpen={showAbandonPopup}
|
||||
abandonPopupTitle="Leave Exercise"
|
||||
abandonPopupDescription="Are you sure you want to leave the exercise? You will lose all your progress."
|
||||
abandonPopupDescription="Are you sure you want to leave the exercise? Your progress will be saved and this exam can be resumed on the Dashboard."
|
||||
abandonConfirmButtonText="Confirm"
|
||||
onAbandon={() => {
|
||||
reset();
|
||||
|
||||
@@ -1,23 +1,87 @@
|
||||
import {LevelExam, MultipleChoiceExercise} from "@/interfaces/exam";
|
||||
import Select from "@/components/Low/Select";
|
||||
import {Difficulty, LevelExam, MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {playSound} from "@/utils/sound";
|
||||
import {Tab} from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize, sample} from "lodash";
|
||||
import {useRouter} from "next/router";
|
||||
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 {v4} from "uuid";
|
||||
|
||||
const TaskTab = ({exam, setExam}: {exam?: LevelExam; setExam: (exam: LevelExam) => void}) => {
|
||||
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 [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const generate = () => {
|
||||
const url = new URLSearchParams();
|
||||
url.append("difficulty", difficulty);
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get(`/api/exam/level/generate/level`)
|
||||
.get(`/api/exam/level/generate/level?${url.toString()}`)
|
||||
.then((result) => {
|
||||
playSound(typeof result.data === "string" ? "error" : "check");
|
||||
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
||||
@@ -30,6 +94,20 @@ const TaskTab = ({exam, setExam}: {exam?: LevelExam; setExam: (exam: LevelExam)
|
||||
.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 (
|
||||
<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">
|
||||
@@ -73,25 +151,7 @@ const TaskTab = ({exam, setExam}: {exam?: LevelExam; setExam: (exam: LevelExam)
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{exercise.questions.map((question) => (
|
||||
<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) => (
|
||||
<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>
|
||||
<QuestionDisplay question={question} onUpdate={onUpdate} key={question.id} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,6 +167,7 @@ const LevelGeneration = () => {
|
||||
const [generatedExam, setGeneratedExam] = useState<LevelExam>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [resultingExam, setResultingExam] = useState<LevelExam>();
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -163,6 +224,16 @@ const LevelGeneration = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-4 w-1/2">
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||
<Select
|
||||
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
|
||||
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
|
||||
value={{value: difficulty, label: capitalize(difficulty)}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Tab.Group>
|
||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1">
|
||||
<Tab
|
||||
@@ -178,7 +249,7 @@ const LevelGeneration = () => {
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<TaskTab exam={generatedExam} setExam={setGeneratedExam} />
|
||||
<TaskTab difficulty={difficulty} exam={generatedExam} setExam={setGeneratedExam} />
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
<div className="w-full flex justify-end gap-4">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Input from "@/components/Low/Input";
|
||||
import {Exercise, ListeningExam} from "@/interfaces/exam";
|
||||
import Select from "@/components/Low/Select";
|
||||
import {Difficulty, Exercise, ListeningExam} from "@/interfaces/exam";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {playSound} from "@/utils/sound";
|
||||
@@ -7,17 +8,34 @@ import {convertCamelCaseToReadable} from "@/utils/string";
|
||||
import {Tab} from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize, sample} from "lodash";
|
||||
import {useRouter} from "next/router";
|
||||
import {useEffect, useState} from "react";
|
||||
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
|
||||
const PartTab = ({part, types, index, setPart}: {part?: ListeningPart; types: string[]; index: number; setPart: (part?: ListeningPart) => void}) => {
|
||||
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
||||
|
||||
const PartTab = ({
|
||||
part,
|
||||
types,
|
||||
difficulty,
|
||||
index,
|
||||
setPart,
|
||||
}: {
|
||||
part?: ListeningPart;
|
||||
difficulty: Difficulty;
|
||||
types: string[];
|
||||
index: number;
|
||||
setPart: (part?: ListeningPart) => void;
|
||||
}) => {
|
||||
const [topic, setTopic] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const generate = () => {
|
||||
const url = new URLSearchParams();
|
||||
url.append("difficulty", difficulty);
|
||||
|
||||
if (topic) url.append("topic", topic);
|
||||
if (types) types.forEach((t) => url.append("exercises", t));
|
||||
|
||||
@@ -115,6 +133,7 @@ const ListeningGeneration = () => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [resultingExam, setResultingExam] = useState<ListeningExam>();
|
||||
const [types, setTypes] = useState<string[]>([]);
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||
|
||||
useEffect(() => {
|
||||
const part1Timer = part1 ? 5 : 0;
|
||||
@@ -148,7 +167,7 @@ const ListeningGeneration = () => {
|
||||
setIsLoading(true);
|
||||
|
||||
axios
|
||||
.post(`/api/exam/listening/generate/listening`, {parts, minTimer})
|
||||
.post(`/api/exam/listening/generate/listening`, {parts, minTimer, difficulty})
|
||||
.then((result) => {
|
||||
playSound("sent");
|
||||
console.log(`Generated Exam ID: ${result.data.id}`);
|
||||
@@ -159,6 +178,7 @@ const ListeningGeneration = () => {
|
||||
setPart2(undefined);
|
||||
setPart3(undefined);
|
||||
setPart4(undefined);
|
||||
setDifficulty(sample(DIFFICULTIES)!);
|
||||
setTypes([]);
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -186,15 +206,26 @@ const ListeningGeneration = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="minTimer"
|
||||
onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))}
|
||||
value={minTimer}
|
||||
className="max-w-[300px]"
|
||||
/>
|
||||
<div className="flex gap-4 w-1/2">
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="minTimer"
|
||||
onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))}
|
||||
value={minTimer}
|
||||
className="max-w-[300px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||
<Select
|
||||
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
|
||||
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
|
||||
value={{value: difficulty, label: capitalize(difficulty)}}
|
||||
disabled={!!part1 || !!part2 || !!part3 || !!part4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -271,7 +302,7 @@ const ListeningGeneration = () => {
|
||||
{part: part3, setPart: setPart3},
|
||||
{part: part4, setPart: setPart4},
|
||||
].map(({part, setPart}, index) => (
|
||||
<PartTab part={part} types={types} index={index + 1} key={index} setPart={setPart} />
|
||||
<PartTab part={part} difficulty={difficulty} types={types} index={index + 1} key={index} setPart={setPart} />
|
||||
))}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Input from "@/components/Low/Input";
|
||||
import {ReadingExam, ReadingPart} from "@/interfaces/exam";
|
||||
import Select from "@/components/Low/Select";
|
||||
import {Difficulty, ReadingExam, ReadingPart} from "@/interfaces/exam";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {playSound} from "@/utils/sound";
|
||||
@@ -7,18 +8,35 @@ import {convertCamelCaseToReadable} from "@/utils/string";
|
||||
import {Tab} from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize, sample} from "lodash";
|
||||
import {useRouter} from "next/router";
|
||||
import {useEffect, useState} from "react";
|
||||
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import {v4} from "uuid";
|
||||
|
||||
const PartTab = ({part, types, index, setPart}: {part?: ReadingPart; types: string[]; index: number; setPart: (part?: ReadingPart) => void}) => {
|
||||
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
||||
|
||||
const PartTab = ({
|
||||
part,
|
||||
types,
|
||||
difficulty,
|
||||
index,
|
||||
setPart,
|
||||
}: {
|
||||
part?: ReadingPart;
|
||||
types: string[];
|
||||
index: number;
|
||||
difficulty: Difficulty;
|
||||
setPart: (part?: ReadingPart) => void;
|
||||
}) => {
|
||||
const [topic, setTopic] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const generate = () => {
|
||||
const url = new URLSearchParams();
|
||||
url.append("difficulty", difficulty);
|
||||
|
||||
if (topic) url.append("topic", topic);
|
||||
if (types) types.forEach((t) => url.append("exercises", t));
|
||||
|
||||
@@ -92,6 +110,7 @@ const ReadingGeneration = () => {
|
||||
const [types, setTypes] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [resultingExam, setResultingExam] = useState<ReadingExam>();
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||
|
||||
useEffect(() => {
|
||||
const parts = [part1, part2, part3].filter((x) => !!x);
|
||||
@@ -105,9 +124,9 @@ const ReadingGeneration = () => {
|
||||
|
||||
const availableTypes = [
|
||||
{type: "fillBlanks", label: "Fill the Blanks"},
|
||||
{type: "multipleChoice", label: "Multiple Choice"},
|
||||
{type: "trueFalse", label: "True or False"},
|
||||
{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]));
|
||||
@@ -144,6 +163,7 @@ const ReadingGeneration = () => {
|
||||
id: v4(),
|
||||
type: "academic",
|
||||
variant: parts.length === 3 ? "full" : "partial",
|
||||
difficulty,
|
||||
};
|
||||
|
||||
axios
|
||||
@@ -157,6 +177,7 @@ const ReadingGeneration = () => {
|
||||
setPart1(undefined);
|
||||
setPart2(undefined);
|
||||
setPart3(undefined);
|
||||
setDifficulty(sample(DIFFICULTIES)!);
|
||||
setMinTimer(60);
|
||||
setTypes([]);
|
||||
})
|
||||
@@ -169,15 +190,26 @@ const ReadingGeneration = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="minTimer"
|
||||
onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))}
|
||||
value={minTimer}
|
||||
className="max-w-[300px]"
|
||||
/>
|
||||
<div className="flex gap-4 w-1/2">
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="minTimer"
|
||||
onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))}
|
||||
value={minTimer}
|
||||
className="max-w-[300px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||
<Select
|
||||
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
|
||||
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
|
||||
value={{value: difficulty, label: capitalize(difficulty)}}
|
||||
disabled={!!part1 || !!part2 || !!part3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -240,7 +272,7 @@ const ReadingGeneration = () => {
|
||||
{part: part2, setPart: setPart2},
|
||||
{part: part3, setPart: setPart3},
|
||||
].map(({part, setPart}, index) => (
|
||||
<PartTab part={part} types={types} index={index + 1} key={index} setPart={setPart} />
|
||||
<PartTab part={part} types={types} difficulty={difficulty} index={index + 1} key={index} setPart={setPart} />
|
||||
))}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import Input from "@/components/Low/Input";
|
||||
import {Exercise, InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise} from "@/interfaces/exam";
|
||||
import Select from "@/components/Low/Select";
|
||||
import {Difficulty, Exercise, InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise} from "@/interfaces/exam";
|
||||
import {AVATARS} from "@/resources/speakingAvatars";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {playSound} from "@/utils/sound";
|
||||
@@ -7,6 +9,7 @@ import {convertCamelCaseToReadable} from "@/utils/string";
|
||||
import {Tab} from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize, sample, uniq} from "lodash";
|
||||
import moment from "moment";
|
||||
import {useRouter} from "next/router";
|
||||
import {useEffect, useState} from "react";
|
||||
@@ -14,15 +17,31 @@ import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import {v4} from "uuid";
|
||||
|
||||
const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; setPart: (part?: SpeakingPart) => void}) => {
|
||||
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
||||
|
||||
const PartTab = ({
|
||||
part,
|
||||
index,
|
||||
difficulty,
|
||||
setPart,
|
||||
}: {
|
||||
part?: SpeakingPart;
|
||||
difficulty: Difficulty;
|
||||
index: number;
|
||||
setPart: (part?: SpeakingPart) => void;
|
||||
}) => {
|
||||
const [gender, setGender] = useState<"male" | "female">("male");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const generate = () => {
|
||||
setPart(undefined);
|
||||
setIsLoading(true);
|
||||
|
||||
const url = new URLSearchParams();
|
||||
url.append("difficulty", difficulty);
|
||||
|
||||
axios
|
||||
.get(`/api/exam/speaking/generate/speaking_task_${index}`)
|
||||
.get(`/api/exam/speaking/generate/speaking_task_${index}?${url.toString()}`)
|
||||
.then((result) => {
|
||||
playSound(typeof result.data === "string" ? "error" : "check");
|
||||
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
||||
@@ -39,17 +58,19 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
|
||||
if (!part) return toast.error("Please generate the first part before generating the video!");
|
||||
toast.info("This will take quite a while, please do not leave this page or close the tab/window.");
|
||||
|
||||
const avatar = sample(AVATARS.filter((x) => x.gender === gender));
|
||||
|
||||
setIsLoading(true);
|
||||
const initialTime = moment();
|
||||
|
||||
axios
|
||||
.post(`/api/exam/speaking/generate/speaking/generate_${index === 3 ? "interactive" : "speaking"}_video`, part)
|
||||
.post(`/api/exam/speaking/generate/speaking/generate_${index === 3 ? "interactive" : "speaking"}_video`, {...part, avatar: avatar?.id})
|
||||
.then((result) => {
|
||||
const isError = typeof result.data === "string" || moment().diff(initialTime, "seconds") < 60;
|
||||
|
||||
playSound(isError ? "error" : "check");
|
||||
if (isError) return toast.error("Something went wrong, please try to generate the video again.");
|
||||
setPart({...part, result: result.data});
|
||||
setPart({...part, result: {...result.data, topic: part?.topic}, gender, avatar});
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.error("Something went wrong!");
|
||||
@@ -60,6 +81,18 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
|
||||
|
||||
return (
|
||||
<Tab.Panel className="w-full bg-ielts-speaking/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Gender</label>
|
||||
<Select
|
||||
options={[
|
||||
{value: "male", label: "Male"},
|
||||
{value: "female", label: "Female"},
|
||||
]}
|
||||
value={{value: gender, label: capitalize(gender)}}
|
||||
onChange={(value) => (value ? setGender(value.value as typeof gender) : null)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4 items-end">
|
||||
<button
|
||||
onClick={generate}
|
||||
@@ -128,6 +161,11 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
|
||||
</div>
|
||||
)}
|
||||
{part.result && <span className="font-bold mt-4">Video Generated: ✅</span>}
|
||||
{part.avatar && part.gender && (
|
||||
<span>
|
||||
<b>Instructor:</b> {part.avatar.name} - {capitalize(part.avatar.gender)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Tab.Panel>
|
||||
@@ -140,6 +178,8 @@ interface SpeakingPart {
|
||||
questions?: string[];
|
||||
topic: string;
|
||||
result?: SpeakingExercise | InteractiveSpeakingExercise;
|
||||
gender?: "male" | "female";
|
||||
avatar?: (typeof AVATARS)[number];
|
||||
}
|
||||
|
||||
const SpeakingGeneration = () => {
|
||||
@@ -149,6 +189,7 @@ const SpeakingGeneration = () => {
|
||||
const [minTimer, setMinTimer] = useState(14);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [resultingExam, setResultingExam] = useState<SpeakingExam>();
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||
|
||||
useEffect(() => {
|
||||
const parts = [part1, part2, part3].filter((x) => !!x);
|
||||
@@ -165,6 +206,8 @@ const SpeakingGeneration = () => {
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const genders = [part1?.gender, part2?.gender, part3?.gender].filter((x) => !!x);
|
||||
|
||||
const exam: SpeakingExam = {
|
||||
id: v4(),
|
||||
isDiagnostic: false,
|
||||
@@ -172,6 +215,7 @@ const SpeakingGeneration = () => {
|
||||
minTimer,
|
||||
variant: minTimer >= 14 ? "full" : "partial",
|
||||
module: "speaking",
|
||||
instructorGender: genders.every((x) => x === "male") ? "male" : genders.every((x) => x === "female") ? "female" : "varied",
|
||||
};
|
||||
|
||||
axios
|
||||
@@ -185,6 +229,7 @@ const SpeakingGeneration = () => {
|
||||
setPart1(undefined);
|
||||
setPart2(undefined);
|
||||
setPart3(undefined);
|
||||
setDifficulty(sample(DIFFICULTIES)!);
|
||||
setMinTimer(14);
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -212,15 +257,26 @@ const SpeakingGeneration = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="minTimer"
|
||||
onChange={(e) => setMinTimer(parseInt(e) < 5 ? 5 : parseInt(e))}
|
||||
value={minTimer}
|
||||
className="max-w-[300px]"
|
||||
/>
|
||||
<div className="flex gap-4 w-1/2">
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="minTimer"
|
||||
onChange={(e) => setMinTimer(parseInt(e) < 5 ? 5 : parseInt(e))}
|
||||
value={minTimer}
|
||||
className="max-w-[300px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||
<Select
|
||||
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
|
||||
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
|
||||
value={{value: difficulty, label: capitalize(difficulty)}}
|
||||
disabled={!!part1 || !!part2 || !!part3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tab.Group>
|
||||
@@ -265,7 +321,7 @@ const SpeakingGeneration = () => {
|
||||
{part: part2, setPart: setPart2},
|
||||
{part: part3, setPart: setPart3},
|
||||
].map(({part, setPart}, index) => (
|
||||
<PartTab part={part} index={index + 1} key={index} setPart={setPart} />
|
||||
<PartTab difficulty={difficulty} part={part} index={index + 1} key={index} setPart={setPart} />
|
||||
))}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
import Input from "@/components/Low/Input";
|
||||
import {WritingExam, WritingExercise} from "@/interfaces/exam";
|
||||
import Select from "@/components/Low/Select";
|
||||
import {Difficulty, WritingExam, WritingExercise} from "@/interfaces/exam";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {playSound} from "@/utils/sound";
|
||||
import {Tab} from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize, sample} from "lodash";
|
||||
import {useRouter} from "next/router";
|
||||
import {useEffect, useState} from "react";
|
||||
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import {v4} from "uuid";
|
||||
|
||||
const TaskTab = ({task, index, setTask}: {task?: string; index: number; setTask: (task: string) => void}) => {
|
||||
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
||||
|
||||
const TaskTab = ({task, index, difficulty, setTask}: {task?: string; difficulty: Difficulty; index: number; setTask: (task: string) => void}) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const generate = () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const url = new URLSearchParams();
|
||||
url.append("difficulty", difficulty);
|
||||
|
||||
axios
|
||||
.get(`/api/exam/writing/generate/writing_task${index}_general`)
|
||||
.get(`/api/exam/writing/generate/writing_task${index}_general?${url.toString()}`)
|
||||
.then((result) => {
|
||||
playSound(typeof result.data === "string" ? "error" : "check");
|
||||
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
||||
@@ -72,6 +80,7 @@ const WritingGeneration = () => {
|
||||
const [minTimer, setMinTimer] = useState(60);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [resultingExam, setResultingExam] = useState<WritingExam>();
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||
|
||||
useEffect(() => {
|
||||
const task1Timer = task1 ? 20 : 0;
|
||||
@@ -144,6 +153,7 @@ const WritingGeneration = () => {
|
||||
exercises: [...(exercise1 ? [exercise1] : []), ...(exercise2 ? [exercise2] : [])],
|
||||
id: v4(),
|
||||
variant: exercise1 && exercise2 ? "full" : "partial",
|
||||
difficulty,
|
||||
};
|
||||
|
||||
axios
|
||||
@@ -156,6 +166,7 @@ const WritingGeneration = () => {
|
||||
|
||||
setTask1(undefined);
|
||||
setTask2(undefined);
|
||||
setDifficulty(sample(DIFFICULTIES)!);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
@@ -166,15 +177,26 @@ const WritingGeneration = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="minTimer"
|
||||
onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))}
|
||||
value={minTimer}
|
||||
className="max-w-[300px]"
|
||||
/>
|
||||
<div className="flex gap-4 w-1/2">
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="minTimer"
|
||||
onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))}
|
||||
value={minTimer}
|
||||
className="max-w-[300px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||
<Select
|
||||
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
|
||||
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
|
||||
value={{value: difficulty, label: capitalize(difficulty)}}
|
||||
disabled={!!task1 || !!task2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tab.Group>
|
||||
@@ -207,7 +229,7 @@ const WritingGeneration = () => {
|
||||
{task: task1, setTask: setTask1},
|
||||
{task: task2, setTask: setTask2},
|
||||
].map(({task, setTask}, index) => (
|
||||
<TaskTab task={task} index={index + 1} key={index} setTask={setTask} />
|
||||
<TaskTab difficulty={difficulty} task={task} index={index + 1} key={index} setTask={setTask} />
|
||||
))}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { toast } from "react-toastify";
|
||||
import { KeyedMutator } from "swr";
|
||||
import Select from "react-select";
|
||||
import moment from "moment";
|
||||
import useAcceptedTerms from "@/hooks/useAcceptedTerms";
|
||||
|
||||
interface Props {
|
||||
isLoading: boolean;
|
||||
@@ -40,6 +41,7 @@ export default function RegisterCorporate({
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const [companyUsers, setCompanyUsers] = useState(0);
|
||||
const [subscriptionDuration, setSubscriptionDuration] = useState(1);
|
||||
const {acceptedTerms, renderCheckbox} = useAcceptedTerms();
|
||||
|
||||
const { users } = useUsers();
|
||||
|
||||
@@ -257,7 +259,9 @@ export default function RegisterCorporate({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col items-start gap-4">
|
||||
{renderCheckbox()}
|
||||
</div>
|
||||
<Button
|
||||
className="w-full lg:mt-8"
|
||||
color="purple"
|
||||
|
||||
@@ -4,9 +4,10 @@ import Input from "@/components/Low/Input";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { sendEmailVerification } from "@/utils/email";
|
||||
import axios from "axios";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { KeyedMutator } from "swr";
|
||||
import useAcceptedTerms from "@/hooks/useAcceptedTerms";
|
||||
|
||||
interface Props {
|
||||
queryCode?: string;
|
||||
@@ -35,6 +36,7 @@ export default function RegisterIndividual({
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [code, setCode] = useState(queryCode || "");
|
||||
const [hasCode, setHasCode] = useState<boolean>(!!queryCode);
|
||||
const {acceptedTerms, renderCheckbox} = useAcceptedTerms();
|
||||
|
||||
const onSuccess = () =>
|
||||
toast.success(
|
||||
@@ -146,7 +148,9 @@ export default function RegisterIndividual({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col items-start gap-4">
|
||||
{renderCheckbox()}
|
||||
</div>
|
||||
<Button
|
||||
className="w-full lg:mt-8"
|
||||
color="purple"
|
||||
@@ -156,6 +160,7 @@ export default function RegisterIndividual({
|
||||
!name ||
|
||||
!password ||
|
||||
!confirmPassword ||
|
||||
!acceptedTerms ||
|
||||
password !== confirmPassword ||
|
||||
(hasCode ? !code : false)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Layout from "@/components/High/Layout";
|
||||
import PayPalPayment from "@/components/PayPalPayment";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import usePackages from "@/hooks/usePackages";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {User} from "@/interfaces/user";
|
||||
import clsx from "clsx";
|
||||
import {capitalize} from "lodash";
|
||||
import {useState} from "react";
|
||||
import getSymbolFromCurrency from "currency-symbol-map";
|
||||
import {useEffect, useState} from "react";
|
||||
import useInvites from "@/hooks/useInvites";
|
||||
import {BsArrowRepeat} from "react-icons/bs";
|
||||
import InviteCard from "@/components/Medium/InviteCard";
|
||||
import {useRouter} from "next/router";
|
||||
import {PayPalScriptProvider} from "@paypal/react-paypal-js";
|
||||
import {ToastContainer} from "react-toastify";
|
||||
import useDiscounts from "@/hooks/useDiscounts";
|
||||
import PaymobPayment from "@/components/PaymobPayment";
|
||||
import moment from "moment";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
@@ -24,14 +25,26 @@ interface Props {
|
||||
|
||||
export default function PaymentDue({user, hasExpired = false, clientID, reload}: Props) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [appliedDiscount, setAppliedDiscount] = useState(0);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const {packages} = usePackages();
|
||||
const {discounts} = useDiscounts();
|
||||
const {users} = useUsers();
|
||||
const {groups} = useGroups();
|
||||
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user?.id});
|
||||
|
||||
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 || (biggestDiscount.validUntil && moment(biggestDiscount.validUntil).isBefore(moment()))) return;
|
||||
|
||||
setAppliedDiscount(biggestDiscount.percentage);
|
||||
}, [discounts, user]);
|
||||
|
||||
const isIndividual = () => {
|
||||
if (user?.type === "developer") return true;
|
||||
if (user?.type !== "student") return false;
|
||||
@@ -45,11 +58,18 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToastContainer />
|
||||
{isLoading && (
|
||||
<div className="absolute left-0 top-0 z-[999] h-screen w-screen overflow-hidden bg-black/60">
|
||||
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-8 text-white">
|
||||
<span className={clsx("loading loading-infinity w-48")} />
|
||||
<span className={clsx("text-2xl font-bold")}>Completing your payment...</span>
|
||||
<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 animate-pulse")} />
|
||||
<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>
|
||||
)}
|
||||
@@ -89,51 +109,56 @@ 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:
|
||||
</span>
|
||||
<div className="flex w-full flex-wrap justify-center gap-8">
|
||||
<PayPalScriptProvider
|
||||
options={{
|
||||
clientId: clientID,
|
||||
currency: "USD",
|
||||
intent: "capture",
|
||||
commit: true,
|
||||
vault: true,
|
||||
}}>
|
||||
{packages.map((p) => (
|
||||
<div key={p.id} className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}>
|
||||
<div className="mb-2 flex flex-col items-start">
|
||||
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" />
|
||||
<span className="text-xl font-semibold">
|
||||
EnCoach - {p.duration}{" "}
|
||||
{capitalize(
|
||||
p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-start gap-2">
|
||||
<span className="text-2xl">
|
||||
{p.price}
|
||||
{getSymbolFromCurrency(p.currency)}
|
||||
</span>
|
||||
<PayPalPayment
|
||||
key={clientID}
|
||||
{...p}
|
||||
clientID={clientID}
|
||||
setIsLoading={setIsLoading}
|
||||
onSuccess={() => {
|
||||
setTimeout(reload, 500);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<span>This includes:</span>
|
||||
<ul className="flex flex-col items-start text-sm">
|
||||
<li>- Train your abilities for the IELTS exam</li>
|
||||
<li>- Gain insights into your weaknesses and strengths</li>
|
||||
<li>- Allow yourself to correctly prepare for the exam</li>
|
||||
</ul>
|
||||
</div>
|
||||
{packages.map((p) => (
|
||||
<div key={p.id} className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}>
|
||||
<div className="mb-2 flex flex-col items-start">
|
||||
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" />
|
||||
<span className="text-xl font-semibold">
|
||||
EnCoach - {p.duration}{" "}
|
||||
{capitalize(
|
||||
p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</PayPalScriptProvider>
|
||||
<div className="flex w-full flex-col items-start gap-2">
|
||||
{appliedDiscount === 0 && (
|
||||
<span className="text-2xl">
|
||||
{p.price} {p.currency}
|
||||
</span>
|
||||
)}
|
||||
{appliedDiscount > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl line-through">
|
||||
{p.price} {p.currency}
|
||||
</span>
|
||||
<span className="text-2xl text-mti-red-light">
|
||||
{(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} {p.currency}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<PaymobPayment
|
||||
key={clientID}
|
||||
user={user}
|
||||
setIsPaymentLoading={setIsLoading}
|
||||
onSuccess={() => {
|
||||
setTimeout(reload, 500);
|
||||
}}
|
||||
currency={p.currency}
|
||||
duration={p.duration}
|
||||
duration_unit={p.duration_unit}
|
||||
price={+(p.price - p.price * (appliedDiscount / 100)).toFixed(2)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<span>This includes:</span>
|
||||
<ul className="flex flex-col items-start text-sm">
|
||||
<li>- Train your abilities for the IELTS exam</li>
|
||||
<li>- Gain insights into your weaknesses and strengths</li>
|
||||
<li>- Allow yourself to correctly prepare for the exam</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -149,13 +174,12 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-start gap-2">
|
||||
<span className="text-2xl">
|
||||
{user.corporateInformation.payment.value}
|
||||
{getSymbolFromCurrency(user.corporateInformation.payment.currency)}
|
||||
{user.corporateInformation.payment.value} {user.corporateInformation.payment.currency}
|
||||
</span>
|
||||
<PayPalPayment
|
||||
<PaymobPayment
|
||||
key={clientID}
|
||||
clientID={clientID}
|
||||
setIsLoading={setIsLoading}
|
||||
user={user}
|
||||
setIsPaymentLoading={setIsLoading}
|
||||
currency={user.corporateInformation.payment.currency}
|
||||
price={user.corporateInformation.payment.value}
|
||||
duration={user.corporateInformation.monthlyDuration}
|
||||
@@ -164,7 +188,6 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
|
||||
setIsLoading(false);
|
||||
setTimeout(reload, 500);
|
||||
}}
|
||||
loadScript
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
|
||||
@@ -1,227 +1,170 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { toast, ToastContainer } from "react-toastify";
|
||||
import {toast, ToastContainer} from "react-toastify";
|
||||
import axios from "axios";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import {FormEvent, useEffect, useState} from "react";
|
||||
import Head from "next/head";
|
||||
import useUser from "@/hooks/useUser";
|
||||
import { Divider } from "primereact/divider";
|
||||
import {Divider} from "primereact/divider";
|
||||
import Button from "@/components/Low/Button";
|
||||
import { BsArrowRepeat } from "react-icons/bs";
|
||||
import {BsArrowRepeat} from "react-icons/bs";
|
||||
import Link from "next/link";
|
||||
import Input from "@/components/Low/Input";
|
||||
import { useRouter } from "next/router";
|
||||
import {useRouter} from "next/router";
|
||||
|
||||
export function getServerSideProps({
|
||||
query,
|
||||
res,
|
||||
query,
|
||||
res,
|
||||
}: {
|
||||
query: {
|
||||
oobCode: string;
|
||||
mode: string;
|
||||
continueUrl?: string;
|
||||
};
|
||||
res: any;
|
||||
query: {
|
||||
oobCode: string;
|
||||
mode: string;
|
||||
continueUrl?: string;
|
||||
};
|
||||
res: any;
|
||||
}) {
|
||||
if (!query || !query.oobCode || !query.mode) {
|
||||
res.setHeader("location", "/login");
|
||||
res.statusCode = 302;
|
||||
res.end();
|
||||
return {
|
||||
props: {},
|
||||
};
|
||||
}
|
||||
if (!query || !query.oobCode || !query.mode) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/login",
|
||||
permanent: false,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
code: query.oobCode,
|
||||
mode: query.mode,
|
||||
...(query.continueUrl ? { continueUrl: query.continueUrl } : {}),
|
||||
},
|
||||
};
|
||||
return {
|
||||
props: {
|
||||
code: query.oobCode,
|
||||
mode: query.mode,
|
||||
...(query.continueUrl ? {continueUrl: query.continueUrl} : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function Reset({
|
||||
code,
|
||||
mode,
|
||||
continueUrl,
|
||||
}: {
|
||||
code: string;
|
||||
mode: string;
|
||||
continueUrl?: string;
|
||||
}) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
export default function Reset({code, mode, continueUrl}: {code: string; mode: string; continueUrl?: string}) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const router = useRouter();
|
||||
|
||||
useUser({
|
||||
redirectTo: "/",
|
||||
redirectIfFound: true,
|
||||
});
|
||||
useUser({
|
||||
redirectTo: "/",
|
||||
redirectIfFound: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "signIn") {
|
||||
axios
|
||||
.post<{ ok: boolean }>("/api/reset/verify", {
|
||||
email: continueUrl?.replace("https://platform.encoach.com/", ""),
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.data.ok) {
|
||||
toast.success("Your account has been verified!", {
|
||||
toastId: "verify-successful",
|
||||
});
|
||||
setTimeout(() => {
|
||||
router.push("/");
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (mode === "signIn") {
|
||||
axios
|
||||
.post<{ok: boolean}>("/api/reset/verify", {
|
||||
email: continueUrl?.replace("https://platform.encoach.com/", "").replace("https://staging.encoach.com/", ""),
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.data.ok) {
|
||||
toast.success("Your account has been verified!", {
|
||||
toastId: "verify-successful",
|
||||
});
|
||||
setTimeout(() => {
|
||||
router.push("/");
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(
|
||||
"Something went wrong! Please make sure to click the link in your e-mail again and input the correct e-mail!",
|
||||
{
|
||||
toastId: "verify-error",
|
||||
},
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Something went wrong! Please make sure to click the link in your e-mail again and input the correct e-mail!",
|
||||
{
|
||||
toastId: "verify-error",
|
||||
},
|
||||
);
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
toast.error("Something went wrong! Please make sure to click the link in your e-mail again and input the correct e-mail!", {
|
||||
toastId: "verify-error",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong! Please make sure to click the link in your e-mail again and input the correct e-mail!", {
|
||||
toastId: "verify-error",
|
||||
});
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const login = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const login = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.post<{ ok: boolean }>("/api/reset/confirm", { code, password })
|
||||
.then((response) => {
|
||||
if (response.data.ok) {
|
||||
toast.success("Your password has been reset!", {
|
||||
toastId: "reset-successful",
|
||||
});
|
||||
setTimeout(() => {
|
||||
router.push("/login");
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.post<{ok: boolean}>("/api/reset/confirm", {code, password})
|
||||
.then((response) => {
|
||||
if (response.data.ok) {
|
||||
toast.success("Your password has been reset!", {
|
||||
toastId: "reset-successful",
|
||||
});
|
||||
setTimeout(() => {
|
||||
router.push("/login");
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(
|
||||
"Something went wrong! Please make sure to click the link in your e-mail again!",
|
||||
{ toastId: "reset-error" },
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Something went wrong! Please make sure to click the link in your e-mail again!",
|
||||
{ toastId: "reset-error" },
|
||||
);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
toast.error("Something went wrong! Please make sure to click the link in your e-mail again!", {toastId: "reset-error"});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong! Please make sure to click the link in your e-mail again!", {toastId: "reset-error"});
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Reset | EnCoach</title>
|
||||
<meta name="description" content="Generated by create next app" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<main className="flex h-[100vh] w-full bg-white text-black">
|
||||
<ToastContainer />
|
||||
<section className="relative hidden h-full w-fit min-w-fit lg:flex">
|
||||
<div className="bg-mti-rose-light absolute z-10 h-full w-full bg-opacity-50" />
|
||||
<img
|
||||
src="/people-talking-tablet.png"
|
||||
alt="People smiling looking at a tablet"
|
||||
className="aspect-auto h-full"
|
||||
/>
|
||||
</section>
|
||||
{mode === "resetPassword" && (
|
||||
<section className="flex h-full w-full flex-col items-center justify-center gap-2">
|
||||
<div className="relative flex flex-col items-center gap-2">
|
||||
<img
|
||||
src="/logo_title.png"
|
||||
alt="EnCoach's Logo"
|
||||
className="absolute -top-36 w-36 lg:-top-64 lg:w-64"
|
||||
/>
|
||||
<h1 className="text-2xl font-bold lg:text-4xl">
|
||||
Reset your password
|
||||
</h1>
|
||||
<p className="text-mti-gray-cool self-start text-sm font-normal lg:text-base">
|
||||
to your registered Email Address
|
||||
</p>
|
||||
</div>
|
||||
<Divider className="max-w-xs lg:max-w-md" />
|
||||
<form
|
||||
className="-lg:px-8 flex w-full flex-col items-center gap-6 lg:w-1/2"
|
||||
onSubmit={login}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
name="password"
|
||||
onChange={(e) => setPassword(e)}
|
||||
placeholder="Password"
|
||||
/>
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Reset | EnCoach</title>
|
||||
<meta name="description" content="Generated by create next app" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<main className="flex h-[100vh] w-full bg-white text-black">
|
||||
<ToastContainer />
|
||||
<section className="relative hidden h-full w-fit min-w-fit lg:flex">
|
||||
<div className="bg-mti-rose-light absolute z-10 h-full w-full bg-opacity-50" />
|
||||
<img src="/people-talking-tablet.png" alt="People smiling looking at a tablet" className="aspect-auto h-full" />
|
||||
</section>
|
||||
{mode === "resetPassword" && (
|
||||
<section className="flex h-full w-full flex-col items-center justify-center gap-2">
|
||||
<div className="relative flex flex-col items-center gap-2">
|
||||
<img src="/logo_title.png" alt="EnCoach's Logo" className="absolute -top-36 w-36 lg:-top-64 lg:w-64" />
|
||||
<h1 className="text-2xl font-bold lg:text-4xl">Reset your password</h1>
|
||||
<p className="text-mti-gray-cool self-start text-sm font-normal lg:text-base">to your registered Email Address</p>
|
||||
</div>
|
||||
<Divider className="max-w-xs lg:max-w-md" />
|
||||
<form className="-lg:px-8 flex w-full flex-col items-center gap-6 lg:w-1/2" onSubmit={login}>
|
||||
<Input type="password" name="password" onChange={(e) => setPassword(e)} placeholder="Password" />
|
||||
|
||||
<Button
|
||||
className="mt-8 w-full"
|
||||
color="purple"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{!isLoading && "Reset"}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat
|
||||
className="animate-spin text-white"
|
||||
size={25}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
<span className="text-mti-gray-cool mt-8 text-sm font-normal">
|
||||
Don't have an account?{" "}
|
||||
<Link className="text-mti-purple-light" href="/register">
|
||||
Sign up
|
||||
</Link>
|
||||
</span>
|
||||
</section>
|
||||
)}
|
||||
{mode === "signIn" && (
|
||||
<section className="flex h-full w-full flex-col items-center justify-center gap-2">
|
||||
<div className="relative flex flex-col items-center gap-2">
|
||||
<img
|
||||
src="/logo_title.png"
|
||||
alt="EnCoach's Logo"
|
||||
className="absolute -top-36 w-36 lg:-top-64 lg:w-64"
|
||||
/>
|
||||
<h1 className="text-2xl font-bold lg:text-4xl">
|
||||
Confirm your account
|
||||
</h1>
|
||||
<p className="text-mti-gray-cool self-start text-sm font-normal lg:text-base">
|
||||
to your registered Email Address
|
||||
</p>
|
||||
</div>
|
||||
<Divider className="max-w-xs lg:max-w-md" />
|
||||
<div className="-lg:px-8 flex w-full flex-col items-center gap-6 lg:w-1/2">
|
||||
<span className="text-center">
|
||||
Your e-mail is currently being verified, please wait a second.{" "}
|
||||
<br /> <br />
|
||||
Once it has been verified, you will be redirected to the home
|
||||
page.
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
<Button className="mt-8 w-full" color="purple" disabled={isLoading}>
|
||||
{!isLoading && "Reset"}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
<span className="text-mti-gray-cool mt-8 text-sm font-normal">
|
||||
Don't have an account?{" "}
|
||||
<Link className="text-mti-purple-light" href="/register">
|
||||
Sign up
|
||||
</Link>
|
||||
</span>
|
||||
</section>
|
||||
)}
|
||||
{mode === "signIn" && (
|
||||
<section className="flex h-full w-full flex-col items-center justify-center gap-2">
|
||||
<div className="relative flex flex-col items-center gap-2">
|
||||
<img src="/logo_title.png" alt="EnCoach's Logo" className="absolute -top-36 w-36 lg:-top-64 lg:w-64" />
|
||||
<h1 className="text-2xl font-bold lg:text-4xl">Confirm your account</h1>
|
||||
<p className="text-mti-gray-cool self-start text-sm font-normal lg:text-base">to your registered Email Address</p>
|
||||
</div>
|
||||
<Divider className="max-w-xs lg:max-w-md" />
|
||||
<div className="-lg:px-8 flex w-full flex-col items-center gap-6 lg:w-1/2">
|
||||
<span className="text-center">
|
||||
Your e-mail is currently being verified, please wait a second. <br /> <br />
|
||||
Once it has been verified, you will be redirected to the home page.
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
33
src/pages/api/assignments/[id]/archive.tsx
Normal file
33
src/pages/api/assignments/[id]/archive.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: true }, { 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 });
|
||||
}
|
||||
@@ -370,7 +370,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
studentsData={studentsData}
|
||||
showLevel={showLevel}
|
||||
summaryPNG={overallPNG}
|
||||
summaryScore={`${(overallResult * 100).toFixed(0)}%`}
|
||||
summaryScore={`${Math.floor(overallResult * 100)}%`}
|
||||
groupScoreSummary={groupScoreSummary}
|
||||
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});
|
||||
}
|
||||
@@ -1,198 +1,172 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { app } from "@/firebase";
|
||||
import {
|
||||
getFirestore,
|
||||
collection,
|
||||
getDocs,
|
||||
query,
|
||||
where,
|
||||
setDoc,
|
||||
doc,
|
||||
getDoc,
|
||||
} from "firebase/firestore";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { uuidv4 } from "@firebase/util";
|
||||
import { Module } from "@/interfaces";
|
||||
import { getExams } from "@/utils/exams.be";
|
||||
import { Exam, Variant } from "@/interfaces/exam";
|
||||
import { capitalize, flatten, uniqBy } from "lodash";
|
||||
import { User } from "@/interfaces/user";
|
||||
import type {NextApiRequest, NextApiResponse} from "next";
|
||||
import {app} from "@/firebase";
|
||||
import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc} from "firebase/firestore";
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {uuidv4} from "@firebase/util";
|
||||
import {Module} from "@/interfaces";
|
||||
import {getExams} from "@/utils/exams.be";
|
||||
import {Exam, InstructorGender, Variant} from "@/interfaces/exam";
|
||||
import {capitalize, flatten, uniqBy} from "lodash";
|
||||
import {User} from "@/interfaces/user";
|
||||
import moment from "moment";
|
||||
import { sendEmail } from "@/email";
|
||||
import {sendEmail} from "@/email";
|
||||
|
||||
const db = getFirestore(app);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET") return GET(req, res);
|
||||
if (req.method === "POST") return POST(req, res);
|
||||
if (req.method === "GET") return GET(req, res);
|
||||
if (req.method === "POST") return POST(req, res);
|
||||
|
||||
res.status(404).json({ ok: false });
|
||||
res.status(404).json({ok: false});
|
||||
}
|
||||
|
||||
async function GET(req: NextApiRequest, res: NextApiResponse) {
|
||||
const q = query(collection(db, "assignments"));
|
||||
const snapshot = await getDocs(q);
|
||||
const q = query(collection(db, "assignments"));
|
||||
const snapshot = await getDocs(q);
|
||||
|
||||
const docs = snapshot.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
...doc.data(),
|
||||
}));
|
||||
const docs = snapshot.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
...doc.data(),
|
||||
}));
|
||||
|
||||
res.status(200).json(docs);
|
||||
res.status(200).json(docs);
|
||||
}
|
||||
|
||||
interface ExamWithUser {
|
||||
module: Module;
|
||||
id: string;
|
||||
assignee: string;
|
||||
module: Module;
|
||||
id: string;
|
||||
assignee: string;
|
||||
}
|
||||
|
||||
function getRandomIndex(arr: any[]): number {
|
||||
const randomIndex = Math.floor(Math.random() * arr.length);
|
||||
return randomIndex;
|
||||
const randomIndex = Math.floor(Math.random() * arr.length);
|
||||
return randomIndex;
|
||||
}
|
||||
|
||||
const generateExams = async (
|
||||
generateMultiple: Boolean,
|
||||
selectedModules: Module[],
|
||||
assignees: string[],
|
||||
variant?: Variant,
|
||||
generateMultiple: Boolean,
|
||||
selectedModules: Module[],
|
||||
assignees: string[],
|
||||
variant?: Variant,
|
||||
instructorGender?: InstructorGender,
|
||||
): Promise<ExamWithUser[]> => {
|
||||
if (generateMultiple) {
|
||||
// for optimization purposes, it would be better to create a new endpoint that returned the answers for all users at once
|
||||
const allExams = assignees.map(async (assignee) => {
|
||||
const selectedModulePromises = selectedModules.map(
|
||||
async (module: Module) => {
|
||||
try {
|
||||
const exams: Exam[] = await getExams(
|
||||
db,
|
||||
module,
|
||||
"true",
|
||||
assignee,
|
||||
variant,
|
||||
);
|
||||
if (generateMultiple) {
|
||||
// for optimization purposes, it would be better to create a new endpoint that returned the answers for all users at once
|
||||
const allExams = assignees.map(async (assignee) => {
|
||||
const selectedModulePromises = selectedModules.map(async (module: Module) => {
|
||||
try {
|
||||
const exams: Exam[] = await getExams(db, module, "true", assignee, variant, instructorGender);
|
||||
|
||||
const exam = exams[getRandomIndex(exams)];
|
||||
if (exam) {
|
||||
return { module: exam.module, id: exam.id, assignee };
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
const newModules = await Promise.all(selectedModulePromises);
|
||||
const exam = exams[getRandomIndex(exams)];
|
||||
if (exam) {
|
||||
return {module: exam.module, id: exam.id, assignee};
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
const newModules = await Promise.all(selectedModulePromises);
|
||||
|
||||
return newModules;
|
||||
}, []);
|
||||
return newModules;
|
||||
}, []);
|
||||
|
||||
const exams = flatten(await Promise.all(allExams)).filter(
|
||||
(x) => x !== null,
|
||||
) as ExamWithUser[];
|
||||
return exams;
|
||||
}
|
||||
const exams = flatten(await Promise.all(allExams)).filter((x) => x !== null) as ExamWithUser[];
|
||||
return exams;
|
||||
}
|
||||
|
||||
const selectedModulePromises = selectedModules.map(async (module: Module) => {
|
||||
const exams: Exam[] = await getExams(db, module, "false", undefined);
|
||||
const exam = exams[getRandomIndex(exams)];
|
||||
const selectedModulePromises = selectedModules.map(async (module: Module) => {
|
||||
const exams: Exam[] = await getExams(db, module, "false", undefined, variant, instructorGender);
|
||||
const exam = exams[getRandomIndex(exams)];
|
||||
|
||||
if (exam) {
|
||||
return { module: exam.module, id: exam.id };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (exam) {
|
||||
return {module: exam.module, id: exam.id};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const exams = await Promise.all(selectedModulePromises);
|
||||
const examesFiltered = exams.filter((x) => x !== null) as ExamWithUser[];
|
||||
return flatten(
|
||||
assignees.map((assignee) =>
|
||||
examesFiltered.map((exam) => ({ ...exam, assignee })),
|
||||
),
|
||||
);
|
||||
const exams = await Promise.all(selectedModulePromises);
|
||||
const examesFiltered = exams.filter((x) => x !== null) as ExamWithUser[];
|
||||
return flatten(assignees.map((assignee) => examesFiltered.map((exam) => ({...exam, assignee}))));
|
||||
};
|
||||
|
||||
async function POST(req: NextApiRequest, res: NextApiResponse) {
|
||||
const {
|
||||
selectedModules,
|
||||
assignees,
|
||||
// Generate multiple true would generate an unique exam for each user
|
||||
// false would generate the same exam for all users
|
||||
generateMultiple = false,
|
||||
variant,
|
||||
...body
|
||||
} = req.body as {
|
||||
selectedModules: Module[];
|
||||
assignees: string[];
|
||||
generateMultiple: Boolean;
|
||||
name: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
variant?: Variant;
|
||||
};
|
||||
const {
|
||||
selectedModules,
|
||||
assignees,
|
||||
// Generate multiple true would generate an unique exam for each user
|
||||
// false would generate the same exam for all users
|
||||
generateMultiple = false,
|
||||
variant,
|
||||
instructorGender,
|
||||
...body
|
||||
} = req.body as {
|
||||
selectedModules: Module[];
|
||||
assignees: string[];
|
||||
generateMultiple: Boolean;
|
||||
name: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
variant?: Variant;
|
||||
instructorGender?: InstructorGender;
|
||||
};
|
||||
|
||||
const exams: ExamWithUser[] = await generateExams(
|
||||
generateMultiple,
|
||||
selectedModules,
|
||||
assignees,
|
||||
variant,
|
||||
);
|
||||
const exams: ExamWithUser[] = await generateExams(generateMultiple, selectedModules, assignees, variant, instructorGender);
|
||||
|
||||
if (exams.length === 0) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ ok: false, error: "No exams found for the selected modules" });
|
||||
return;
|
||||
}
|
||||
if (exams.length === 0) {
|
||||
res.status(400).json({ok: false, error: "No exams found for the selected modules"});
|
||||
return;
|
||||
}
|
||||
|
||||
await setDoc(doc(db, "assignments", uuidv4()), {
|
||||
assigner: req.session.user?.id,
|
||||
assignees,
|
||||
results: [],
|
||||
exams,
|
||||
...body,
|
||||
});
|
||||
await setDoc(doc(db, "assignments", uuidv4()), {
|
||||
assigner: req.session.user?.id,
|
||||
assignees,
|
||||
results: [],
|
||||
exams,
|
||||
instructorGender,
|
||||
...body,
|
||||
});
|
||||
|
||||
res.status(200).json({ ok: true });
|
||||
res.status(200).json({ok: true});
|
||||
|
||||
for (const assigneeID of assignees) {
|
||||
const assigneeSnapshot = await getDoc(doc(db, "users", assigneeID));
|
||||
if (!assigneeSnapshot.exists()) continue;
|
||||
for (const assigneeID of assignees) {
|
||||
const assigneeSnapshot = await getDoc(doc(db, "users", assigneeID));
|
||||
if (!assigneeSnapshot.exists()) continue;
|
||||
|
||||
const assignee = { id: assigneeID, ...assigneeSnapshot.data() } as User;
|
||||
const name = body.name;
|
||||
const teacher = req.session.user!;
|
||||
const examModulesLabel = uniqBy(exams, (x) => x.module)
|
||||
.map((x) => capitalize(x.module))
|
||||
.join(", ");
|
||||
const startDate = moment(body.startDate).format("DD/MM/YYYY - HH:mm");
|
||||
const endDate = moment(body.endDate).format("DD/MM/YYYY - HH:mm");
|
||||
const assignee = {id: assigneeID, ...assigneeSnapshot.data()} as User;
|
||||
const name = body.name;
|
||||
const teacher = req.session.user!;
|
||||
const examModulesLabel = uniqBy(exams, (x) => x.module)
|
||||
.map((x) => capitalize(x.module))
|
||||
.join(", ");
|
||||
const startDate = moment(body.startDate).format("DD/MM/YYYY - HH:mm");
|
||||
const endDate = moment(body.endDate).format("DD/MM/YYYY - HH:mm");
|
||||
|
||||
await sendEmail(
|
||||
"assignment",
|
||||
{
|
||||
user: { name: assignee.name },
|
||||
assignment: {
|
||||
name,
|
||||
startDate,
|
||||
endDate,
|
||||
modules: examModulesLabel,
|
||||
assigner: teacher.name,
|
||||
},
|
||||
},
|
||||
[assignee.email],
|
||||
"EnCoach - New Assignment!",
|
||||
);
|
||||
}
|
||||
await sendEmail(
|
||||
"assignment",
|
||||
{
|
||||
user: {name: assignee.name},
|
||||
assignment: {
|
||||
name,
|
||||
startDate,
|
||||
endDate,
|
||||
modules: examModulesLabel,
|
||||
assigner: teacher.name,
|
||||
},
|
||||
environment: process.env.ENVIRONMENT,
|
||||
},
|
||||
[assignee.email],
|
||||
"EnCoach - New Assignment!",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ const db = getFirestore(app);
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") return GET(req, res);
|
||||
if (req.method === "DELETE") return DELETE(req, res);
|
||||
|
||||
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});
|
||||
}
|
||||
|
||||
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,
|
||||
where,
|
||||
getDocs,
|
||||
getDoc,
|
||||
deleteDoc,
|
||||
} from "firebase/firestore";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { Type } from "@/interfaces/user";
|
||||
import { Code, Type } from "@/interfaces/user";
|
||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||
import { uuidv4 } from "@firebase/util";
|
||||
import { prepareMailer, prepareMailOptions } from "@/email";
|
||||
@@ -24,6 +26,7 @@ export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") return get(req, res);
|
||||
if (req.method === "POST") return post(req, res);
|
||||
if (req.method === "DELETE") return del(req, res);
|
||||
|
||||
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 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"));
|
||||
|
||||
res.status(200).json(snapshot.docs.map((doc) => doc.data()));
|
||||
@@ -60,23 +66,22 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
const permission = PERMISSIONS.generateCode[type];
|
||||
|
||||
if (!permission.includes(req.session.user.type)) {
|
||||
res
|
||||
.status(403)
|
||||
.json({
|
||||
ok: false,
|
||||
reason:
|
||||
"Your account type does not have permissions to generate a code for that type of user!",
|
||||
});
|
||||
res.status(403).json({
|
||||
ok: false,
|
||||
reason:
|
||||
"Your account type does not have permissions to generate a code for that type of user!",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const codesGeneratedByUserSnapshot = await getDocs(
|
||||
query(collection(db, "codes"), where("creator", "==", req.session.user.id)),
|
||||
);
|
||||
const userCodes = codesGeneratedByUserSnapshot.docs.map((x) => ({
|
||||
...x.data(),
|
||||
}));
|
||||
|
||||
if (req.session.user.type === "corporate") {
|
||||
const codesGeneratedByUserSnapshot = await getDocs(
|
||||
query(
|
||||
collection(db, "codes"),
|
||||
where("creator", "==", req.session.user.id),
|
||||
),
|
||||
);
|
||||
const totalCodes = codesGeneratedByUserSnapshot.docs.length + codes.length;
|
||||
const allowedCodes =
|
||||
req.session.user.corporateInformation?.companyInformation.userAmount || 0;
|
||||
@@ -94,21 +99,24 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
const codePromises = codes.map(async (code, index) => {
|
||||
const codeRef = doc(db, "codes", code);
|
||||
const codeInformation = {
|
||||
let codeInformation = {
|
||||
type,
|
||||
code,
|
||||
creator: req.session.user!.id,
|
||||
creationDate: new Date().toISOString(),
|
||||
expiryDate,
|
||||
};
|
||||
|
||||
if (infos && infos.length > index) {
|
||||
const { email, name, passport_id } = infos[index];
|
||||
const previousCode = userCodes.find((x) => x.email === email) as Code;
|
||||
|
||||
const transport = prepareMailer();
|
||||
const mailOptions = prepareMailOptions(
|
||||
{
|
||||
type,
|
||||
code,
|
||||
code: previousCode ? previousCode.code : code,
|
||||
environment: process.env.ENVIRONMENT,
|
||||
},
|
||||
[email.toLowerCase().trim()],
|
||||
"EnCoach Registration",
|
||||
@@ -117,16 +125,19 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
try {
|
||||
await transport.sendMail(mailOptions);
|
||||
await setDoc(
|
||||
codeRef,
|
||||
{
|
||||
...codeInformation,
|
||||
email: email.trim().toLowerCase(),
|
||||
name: name.trim(),
|
||||
...(passport_id ? { passport_id: passport_id.trim() } : {}),
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
|
||||
if (!previousCode) {
|
||||
await setDoc(
|
||||
codeRef,
|
||||
{
|
||||
...codeInformation,
|
||||
email: email.trim().toLowerCase(),
|
||||
name: name.trim(),
|
||||
...(passport_id ? { passport_id: passport_id.trim() } : {}),
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
} 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 });
|
||||
});
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user