Compare commits
103 Commits
feature/99
...
feature/ex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89250fb98e | ||
|
|
b09fe79cb7 | ||
|
|
870ed57166 | ||
|
|
2a9e204041 | ||
|
|
00f6aaf058 | ||
|
|
044a4f91aa | ||
|
|
65fe1ec8ed | ||
|
|
779fb76b8b | ||
|
|
4ec439492e | ||
|
|
c4b61c4787 | ||
|
|
934394b17f | ||
|
|
8baa25c445 | ||
|
|
f6166ca9e1 | ||
|
|
e6017854fd | ||
|
|
0bd8b0ab24 | ||
|
|
401d212d85 | ||
|
|
9383929ebb | ||
|
|
5dcab23fdb | ||
|
|
d111be2f70 | ||
|
|
00c171b161 | ||
|
|
53d3f843da | ||
|
|
8d7f312a83 | ||
|
|
6f11818876 | ||
|
|
81bc4e7a0c | ||
|
|
48265a8e54 | ||
|
|
0053105dd3 | ||
|
|
846d829d10 | ||
|
|
c0c3e37568 | ||
|
|
a872190e1b | ||
|
|
147a450be2 | ||
|
|
908ce5b5b9 | ||
|
|
0ec62c107c | ||
|
|
626655d0d0 | ||
|
|
16eeba76fd | ||
|
|
85729116e7 | ||
|
|
2de9636c8b | ||
|
|
bcad5b5646 | ||
|
|
4e40dc9c8c | ||
|
|
e3bcaf6b30 | ||
|
|
a35c85545e | ||
|
|
c4707d6426 | ||
|
|
3564d0af6b | ||
|
|
e7acdb5858 | ||
|
|
8bff64dd13 | ||
|
|
2c4168a014 | ||
|
|
800d04da37 | ||
|
|
b7b2718387 | ||
|
|
a862e59574 | ||
|
|
688d8ba0b2 | ||
|
|
8b7e550a70 | ||
|
|
cf1cb6f270 | ||
|
|
476a6b0188 | ||
|
|
01e55f970d | ||
|
|
bca73dff2e | ||
|
|
aef3800c08 | ||
|
|
a40c21ca53 | ||
|
|
34b1c7f25b | ||
|
|
7c641508ce | ||
|
|
4163076524 | ||
|
|
009c610033 | ||
|
|
c05df7d6b7 | ||
|
|
b881969bd4 | ||
|
|
5e6af11156 | ||
|
|
c1162c5e88 | ||
|
|
213bdd0c8f | ||
|
|
13401562fb | ||
|
|
4e199931aa | ||
|
|
3eafc799ab | ||
|
|
9b87764afb | ||
|
|
a969e90c98 | ||
|
|
c38c1d9ff6 | ||
|
|
bcacbbdd15 | ||
|
|
fa481dc50e | ||
|
|
710c7931aa | ||
|
|
d3f80603c4 | ||
|
|
fea2d311ae | ||
|
|
5f475fb7a7 | ||
|
|
bd0fab4c8f | ||
|
|
74d3f30c93 | ||
|
|
67c2e06575 | ||
|
|
506ee1e0e4 | ||
|
|
81943dbf42 | ||
|
|
c868ea8795 | ||
|
|
cfde8ac9f0 | ||
|
|
8c1da3a84a | ||
|
|
52143d2472 | ||
|
|
c7f303e410 | ||
|
|
da93b79c78 | ||
|
|
83b8ab7774 | ||
|
|
f6bb69f994 | ||
|
|
a97c40dc47 | ||
|
|
3de0357369 | ||
|
|
8eb8a7af46 | ||
|
|
9773f1da72 | ||
|
|
2ef86344cd | ||
|
|
5e8b6f96bb | ||
|
|
b757cbbed7 | ||
|
|
4e08afb259 | ||
|
|
68069d118f | ||
|
|
b7ae9fb837 | ||
|
|
9baf3109c9 | ||
|
|
8aca34e8b5 | ||
|
|
aaaf7f646d |
BIN
public/audio/error.mp3
Normal file
BIN
public/audio/error.mp3
Normal file
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
import {EmploymentStatus, EMPLOYMENT_STATUS, Gender, User} from "@/interfaces/user";
|
||||
import {FormEvent, useState} from "react";
|
||||
import {FormEvent, useEffect, useState} from "react";
|
||||
import countryCodes from "country-codes-list";
|
||||
import {RadioGroup} from "@headlessui/react";
|
||||
import Input from "./Low/Input";
|
||||
@@ -12,6 +12,8 @@ import {KeyedMutator} from "swr";
|
||||
import CountrySelect from "./Low/CountrySelect";
|
||||
import GenderInput from "@/components/High/GenderInput";
|
||||
import EmploymentStatusInput from "@/components/High/EmploymentStatusInput";
|
||||
import TimezoneSelect from "./Low/TImezoneSelect";
|
||||
import moment from "moment";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
@@ -25,6 +27,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
|
||||
const [gender, setGender] = useState<Gender>();
|
||||
const [employment, setEmployment] = useState<EmploymentStatus>();
|
||||
const [position, setPosition] = useState<string>();
|
||||
const [timezone, setTimezone] = useState<string>(moment.tz.guess());
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [companyName, setCompanyName] = useState<string>();
|
||||
@@ -43,6 +46,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
|
||||
employment: user.type === "corporate" ? undefined : employment,
|
||||
position: user.type === "corporate" ? position : undefined,
|
||||
passport_id,
|
||||
timezone,
|
||||
},
|
||||
agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined,
|
||||
})
|
||||
@@ -94,6 +98,12 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
|
||||
required
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Timezone</label>
|
||||
<TimezoneSelect value={timezone} onChange={setTimezone} />
|
||||
</div>
|
||||
|
||||
<GenderInput value={gender} onChange={setGender} />
|
||||
{user.type === "corporate" && (
|
||||
<Input name="position" onChange={setPosition} type="text" label="Position" placeholder="CEO, Head of Marketing..." required />
|
||||
|
||||
@@ -14,6 +14,7 @@ import {useEffect, useState} from "react";
|
||||
import {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen, BsQuestionSquare} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import Button from "./Low/Button";
|
||||
import ModuleLevelSelector from "./Medium/ModuleLevelSelector";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
@@ -36,7 +37,7 @@ export default function Diagnostic({onFinish}: Props) {
|
||||
};
|
||||
|
||||
const selectExam = () => {
|
||||
const examPromises = MODULE_ARRAY.map((module) => getExam(module, true));
|
||||
const examPromises = MODULE_ARRAY.map((module) => getExam(module, true, "partial"));
|
||||
|
||||
Promise.all(examPromises).then((exams) => {
|
||||
if (exams.every((x) => !!x)) {
|
||||
@@ -90,140 +91,44 @@ export default function Diagnostic({onFinish}: Props) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center gap-8 w-full">
|
||||
<h2 className="font-semibold text-xl">What is your current IELTS level?</h2>
|
||||
<div className="flex flex-col gap-32 w-full mb-20">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-16 mb-24">
|
||||
<div className="w-full flex flex-col gap-3.5 relative">
|
||||
<span className="text-sm text-mti-gray-dim">
|
||||
<span className="font-bold">Reading</span> level
|
||||
</span>
|
||||
<Menu>
|
||||
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
||||
<BsBook className="text-ielts-reading" size={34} />
|
||||
<span className="text-mti-gray-cool text-sm">
|
||||
{levels.reading === -1 ? "Select your reading level" : `Level ${levels.reading}`}
|
||||
</span>
|
||||
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
||||
</Menu.Button>
|
||||
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
|
||||
{Object.values(writingMarking).map((x) => (
|
||||
<Menu.Item key={x}>
|
||||
<span
|
||||
onClick={() => setLevels((prev) => ({...prev, reading: x}))}
|
||||
className="w-full py-4 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
||||
Level {x}
|
||||
</span>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-3.5 relative">
|
||||
<span className="text-sm text-mti-gray-dim">
|
||||
<span className="font-bold">Listening</span> level
|
||||
</span>
|
||||
<Menu>
|
||||
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
||||
<BsHeadphones className="text-ielts-listening" size={34} />
|
||||
<span className="text-mti-gray-cool text-sm">
|
||||
{levels.listening === -1 ? "Select your listening level" : `Level ${levels.listening}`}
|
||||
</span>
|
||||
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
||||
</Menu.Button>
|
||||
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
|
||||
{Object.values(writingMarking).map((x) => (
|
||||
<Menu.Item key={x}>
|
||||
<span
|
||||
onClick={() => setLevels((prev) => ({...prev, listening: x}))}
|
||||
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
||||
Level {x}
|
||||
</span>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-3.5 relative">
|
||||
<span className="text-sm text-mti-gray-dim">
|
||||
<span className="font-bold">Writing</span> level
|
||||
</span>
|
||||
<Menu>
|
||||
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
||||
<BsPen className="text-ielts-writing" size={34} />
|
||||
<span className="text-mti-gray-cool text-sm">
|
||||
{levels.writing === -1 ? "Select your writing level" : `Level ${levels.writing}`}
|
||||
</span>
|
||||
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
||||
</Menu.Button>
|
||||
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
|
||||
{Object.values(writingMarking).map((x) => (
|
||||
<Menu.Item key={x}>
|
||||
<span
|
||||
onClick={() => setLevels((prev) => ({...prev, writing: x}))}
|
||||
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
||||
Level {x}
|
||||
</span>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-3.5 relative">
|
||||
<span className="text-sm text-mti-gray-dim">
|
||||
<span className="font-bold">Speaking</span> level
|
||||
</span>
|
||||
<Menu>
|
||||
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
||||
<BsMegaphone className="text-ielts-speaking" size={34} />
|
||||
<span className="text-mti-gray-cool text-sm">
|
||||
{levels.speaking === -1 ? "Select your speaking level" : `Level ${levels.speaking}`}
|
||||
</span>
|
||||
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
||||
</Menu.Button>
|
||||
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
|
||||
{Object.values(writingMarking).map((x) => (
|
||||
<Menu.Item key={x}>
|
||||
<span
|
||||
onClick={() => setLevels((prev) => ({...prev, speaking: x}))}
|
||||
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
||||
Level {x}
|
||||
</span>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:self-end flex -md:flex-col justify-between w-full gap-8 absolute bottom-8 left-0 px-4 md:px-8">
|
||||
<div className="w-full tooltip" data-tip="Your screen size is too small to perform a diagnostic test">
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
className="group flex items-center justify-center gap-6 relative md:max-w-[400px] w-full md:hidden"
|
||||
disabled>
|
||||
<BsQuestionSquare className="text-mti-purple-light transition duration-300 ease-in-out" size={20} />
|
||||
<span>Perform diagnostic test instead</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => updateUser(selectExam)}
|
||||
color="purple"
|
||||
variant="outline"
|
||||
className="group flex items-center justify-center gap-6 relative md:max-w-[400px] w-full -md:hidden"
|
||||
disabled={!focus}>
|
||||
<BsQuestionSquare
|
||||
className="text-mti-purple-light group-hover:text-white transition duration-300 ease-in-out"
|
||||
size={20}
|
||||
onClick={() => updateUser(selectExam)}
|
||||
/>
|
||||
<span onClick={() => updateUser(selectExam)}>Perform diagnostic test instead</span>
|
||||
</Button>
|
||||
<Button color="purple" className="md:max-w-[400px] w-full" onClick={() => updateUser(onFinish)} disabled={isNextDisabled()}>
|
||||
Next Step
|
||||
</Button>
|
||||
</div>
|
||||
<ModuleLevelSelector levels={levels} setLevels={setLevels} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center gap-8 w-full mb-44">
|
||||
<h2 className="font-semibold text-xl">What is your desired IELTS level?</h2>
|
||||
<ModuleLevelSelector levels={desiredLevels} setLevels={setDesiredLevels} />
|
||||
</div>
|
||||
|
||||
<div className="md:self-end flex -md:flex-col justify-between w-full gap-8 absolute bottom-8 left-0 px-4 md:px-8">
|
||||
<div className="w-full tooltip" data-tip="Your screen size is too small to perform a diagnostic test">
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
className="group flex items-center justify-center gap-6 relative md:max-w-[400px] w-full md:hidden"
|
||||
disabled>
|
||||
<BsQuestionSquare className="text-mti-purple-light transition duration-300 ease-in-out" size={20} />
|
||||
<span>Perform diagnostic test instead</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => updateUser(selectExam)}
|
||||
color="purple"
|
||||
variant="outline"
|
||||
className="group flex items-center justify-center gap-6 relative md:max-w-[400px] w-full -md:hidden"
|
||||
disabled={!focus}>
|
||||
<BsQuestionSquare
|
||||
className="text-mti-purple-light group-hover:text-white transition duration-300 ease-in-out"
|
||||
size={20}
|
||||
onClick={() => updateUser(selectExam)}
|
||||
/>
|
||||
<span onClick={() => updateUser(selectExam)}>Perform diagnostic test instead</span>
|
||||
</Button>
|
||||
<Button color="purple" className="md:max-w-[400px] w-full" onClick={() => updateUser(onFinish)} disabled={isNextDisabled()}>
|
||||
Next Step
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,8 @@ import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill}
|
||||
import dynamic from "next/dynamic";
|
||||
import Button from "../Low/Button";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {downloadBlob} from "@/utils/evaluation";
|
||||
import axios from "axios";
|
||||
|
||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
||||
@@ -14,9 +16,11 @@ const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mo
|
||||
export default function InteractiveSpeaking({
|
||||
id,
|
||||
title,
|
||||
examID,
|
||||
text,
|
||||
type,
|
||||
prompts,
|
||||
userSolutions,
|
||||
updateIndex,
|
||||
onNext,
|
||||
onBack,
|
||||
@@ -24,20 +28,109 @@ export default function InteractiveSpeaking({
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||
const [promptIndex, setPromptIndex] = useState(0);
|
||||
const [answers, setAnswers] = useState<{prompt: string; blob: string}[]>([]);
|
||||
const [answers, setAnswers] = useState<{prompt: string; blob: string; questionIndex: number}[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
|
||||
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
|
||||
const saveToStorage = async (previousURL?: string) => {
|
||||
if (mediaBlob && mediaBlob.startsWith("blob")) {
|
||||
const blobBuffer = await downloadBlob(mediaBlob);
|
||||
const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"});
|
||||
|
||||
const seed = Math.random().toString().replace("0.", "");
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("audio", audioFile, `${seed}.wav`);
|
||||
formData.append("root", "speaking_recordings");
|
||||
|
||||
const config = {
|
||||
headers: {
|
||||
"Content-Type": "audio/wav",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await axios.post<{path: string}>("/api/storage/insert", formData, config);
|
||||
if (previousURL && !previousURL.startsWith("blob")) await axios.post("/api/storage/delete", {path: previousURL});
|
||||
return response.data.path;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const back = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const answer = await saveAnswer(questionIndex);
|
||||
if (questionIndex - 1 >= 0) {
|
||||
setQuestionIndex(questionIndex - 1);
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
setIsLoading(false);
|
||||
|
||||
onBack({
|
||||
exercise: id,
|
||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
type,
|
||||
});
|
||||
};
|
||||
|
||||
const next = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const answer = await saveAnswer(questionIndex);
|
||||
if (questionIndex + 1 < prompts.length) {
|
||||
setQuestionIndex(questionIndex + 1);
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
setIsLoading(false);
|
||||
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
type,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (updateIndex) updateIndex(promptIndex);
|
||||
}, [promptIndex, updateIndex]);
|
||||
if (userSolutions.length > 0 && answers.length === 0) {
|
||||
console.log(userSolutions);
|
||||
const solutions = userSolutions as unknown as typeof answers;
|
||||
setAnswers(solutions);
|
||||
|
||||
if (!mediaBlob) setMediaBlob(solutions.find((x) => x.questionIndex === questionIndex)?.blob);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userSolutions, mediaBlob, answers]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log({answers});
|
||||
}, [answers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (updateIndex) updateIndex(questionIndex);
|
||||
}, [questionIndex, updateIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded) {
|
||||
const answer = {
|
||||
questionIndex,
|
||||
prompt: prompts[questionIndex].text,
|
||||
blob: mediaBlob!,
|
||||
};
|
||||
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
type,
|
||||
});
|
||||
@@ -59,19 +152,38 @@ export default function InteractiveSpeaking({
|
||||
}, [isRecording]);
|
||||
|
||||
useEffect(() => {
|
||||
if (promptIndex === answers.length - 1) {
|
||||
setMediaBlob(answers[promptIndex].blob);
|
||||
if (questionIndex <= answers.length - 1) {
|
||||
const blob = answers.find((x) => x.questionIndex === questionIndex)?.blob;
|
||||
setMediaBlob(blob);
|
||||
}
|
||||
}, [answers, promptIndex]);
|
||||
}, [answers, questionIndex]);
|
||||
|
||||
const saveAnswer = async (index: number) => {
|
||||
const previousURL = answers.find((x) => x.questionIndex === questionIndex)?.blob;
|
||||
const audioPath = await saveToStorage(previousURL);
|
||||
|
||||
const saveAnswer = () => {
|
||||
const answer = {
|
||||
prompt: prompts[promptIndex].text,
|
||||
blob: mediaBlob!,
|
||||
questionIndex,
|
||||
prompt: prompts[questionIndex].text,
|
||||
blob: audioPath ? audioPath : mediaBlob!,
|
||||
};
|
||||
|
||||
setAnswers((prev) => [...prev, answer]);
|
||||
setAnswers((prev) => [...prev.filter((x) => x.questionIndex !== index), answer]);
|
||||
setMediaBlob(undefined);
|
||||
|
||||
setUserSolutions([
|
||||
...storeUserSolutions.filter((x) => x.exercise !== id),
|
||||
{
|
||||
exercise: id,
|
||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
module: "speaking",
|
||||
exam: examID,
|
||||
type,
|
||||
},
|
||||
]);
|
||||
|
||||
return answer;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -82,8 +194,8 @@ export default function InteractiveSpeaking({
|
||||
</div>
|
||||
{prompts && prompts.length > 0 && (
|
||||
<div className="flex flex-col gap-4 w-full items-center">
|
||||
<video key={promptIndex} autoPlay controls className="max-w-3xl rounded-xl">
|
||||
<source src={prompts[promptIndex].video_url} />
|
||||
<video key={questionIndex} autoPlay controls className="max-w-3xl rounded-xl">
|
||||
<source src={prompts[questionIndex].video_url} />
|
||||
</video>
|
||||
</div>
|
||||
)}
|
||||
@@ -91,13 +203,13 @@ export default function InteractiveSpeaking({
|
||||
|
||||
<ReactMediaRecorder
|
||||
audio
|
||||
key={promptIndex}
|
||||
key={questionIndex}
|
||||
onStop={(blob) => setMediaBlob(blob)}
|
||||
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => (
|
||||
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
||||
<p className="text-base font-normal">Record your answer:</p>
|
||||
<div className="flex gap-8 items-center justify-center py-8">
|
||||
{status === "idle" && (
|
||||
{status === "idle" && !mediaBlob && (
|
||||
<>
|
||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||
{status === "idle" && (
|
||||
@@ -176,9 +288,9 @@ export default function InteractiveSpeaking({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{status === "stopped" && mediaBlobUrl && (
|
||||
{((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && (
|
||||
<>
|
||||
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||
<Waveform audio={mediaBlobUrl ? mediaBlobUrl : mediaBlob!} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||
<div className="flex gap-4 items-center">
|
||||
<BsTrashFill
|
||||
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||
@@ -208,44 +320,11 @@ export default function InteractiveSpeaking({
|
||||
/>
|
||||
|
||||
<div className="self-end flex justify-between w-full gap-8">
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onBack({
|
||||
exercise: id,
|
||||
solutions: answers,
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
type,
|
||||
})
|
||||
}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
color="purple"
|
||||
disabled={!mediaBlob}
|
||||
onClick={() => {
|
||||
saveAnswer();
|
||||
if (promptIndex + 1 < prompts.length) {
|
||||
setPromptIndex((prev) => prev + 1);
|
||||
return;
|
||||
}
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: [
|
||||
...answers,
|
||||
{
|
||||
prompt: prompts[promptIndex].text,
|
||||
blob: mediaBlob!,
|
||||
},
|
||||
],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
type,
|
||||
});
|
||||
}}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
{promptIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
|
||||
<Button color="purple" disabled={!mediaBlob} isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
|
||||
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -59,10 +59,18 @@ export default function MultipleChoice({
|
||||
onBack,
|
||||
}: MultipleChoiceExercise & CommonProps) {
|
||||
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
||||
const [questionIndex, setQuestionIndex] = useState(0);
|
||||
|
||||
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
|
||||
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
|
||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||
|
||||
useEffect(() => {
|
||||
setUserSolutions([...storeUserSolutions.filter((x) => x.exercise !== id), {exercise: id, solutions: answers, score: calculateScore(), type}]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [answers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -91,16 +99,20 @@ export default function MultipleChoice({
|
||||
if (questionIndex === questions.length - 1) {
|
||||
onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||
} else {
|
||||
setQuestionIndex((prev) => prev + 1);
|
||||
setQuestionIndex(questionIndex + 1);
|
||||
}
|
||||
|
||||
scrollToTop();
|
||||
};
|
||||
|
||||
const back = () => {
|
||||
if (questionIndex === 0) {
|
||||
onBack({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||
} else {
|
||||
setQuestionIndex((prev) => prev - 1);
|
||||
setQuestionIndex(questionIndex - 1);
|
||||
}
|
||||
|
||||
scrollToTop();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,28 +5,58 @@ import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill}
|
||||
import dynamic from "next/dynamic";
|
||||
import Button from "../Low/Button";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {downloadBlob} from "@/utils/evaluation";
|
||||
import axios from "axios";
|
||||
|
||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function Speaking({id, title, text, video_url, type, prompts, onNext, onBack}: SpeakingExercise & CommonProps) {
|
||||
export default function Speaking({id, title, text, video_url, type, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||
const [audioURL, setAudioURL] = useState<string>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded) {
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
type,
|
||||
});
|
||||
const saveToStorage = async () => {
|
||||
if (mediaBlob && mediaBlob.startsWith("blob")) {
|
||||
const blobBuffer = await downloadBlob(mediaBlob);
|
||||
const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"});
|
||||
|
||||
const seed = Math.random().toString().replace("0.", "");
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("audio", audioFile, `${seed}.wav`);
|
||||
formData.append("root", "speaking_recordings");
|
||||
|
||||
const config = {
|
||||
headers: {
|
||||
"Content-Type": "audio/wav",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await axios.post<{path: string}>("/api/storage/insert", formData, config);
|
||||
if (audioURL) await axios.post("/api/storage/delete", {path: audioURL});
|
||||
return response.data.path;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (userSolutions.length > 0) {
|
||||
const {solution} = userSolutions[0] as {solution?: string};
|
||||
if (solution && !mediaBlob) setMediaBlob(solution);
|
||||
if (solution && !solution.startsWith("blob")) setAudioURL(solution);
|
||||
}
|
||||
}, [userSolutions, mediaBlob]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded) next();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasExamEnded]);
|
||||
|
||||
@@ -43,6 +73,32 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
|
||||
};
|
||||
}, [isRecording]);
|
||||
|
||||
const next = async () => {
|
||||
setIsLoading(true);
|
||||
const storagePath = await saveToStorage();
|
||||
setIsLoading(false);
|
||||
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: storagePath ? [{id, solution: storagePath}] : [],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
type,
|
||||
});
|
||||
};
|
||||
|
||||
const back = async () => {
|
||||
setIsLoading(true);
|
||||
const storagePath = await saveToStorage();
|
||||
setIsLoading(false);
|
||||
|
||||
onBack({
|
||||
exercise: id,
|
||||
solutions: storagePath ? [{id, solution: storagePath}] : [],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
type,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full gap-9">
|
||||
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
|
||||
@@ -89,7 +145,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
|
||||
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
||||
<p className="text-base font-normal">Record your answer:</p>
|
||||
<div className="flex gap-8 items-center justify-center py-8">
|
||||
{status === "idle" && (
|
||||
{status === "idle" && !mediaBlob && (
|
||||
<>
|
||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||
{status === "idle" && (
|
||||
@@ -168,9 +224,9 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{status === "stopped" && mediaBlobUrl && (
|
||||
{((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && (
|
||||
<>
|
||||
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||
<Waveform audio={mediaBlobUrl ? mediaBlobUrl : mediaBlob!} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||
<div className="flex gap-4 items-center">
|
||||
<BsTrashFill
|
||||
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||
@@ -200,32 +256,10 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
|
||||
/>
|
||||
|
||||
<div className="self-end flex justify-between w-full gap-8">
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onBack({
|
||||
exercise: id,
|
||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
type,
|
||||
})
|
||||
}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
color="purple"
|
||||
disabled={!mediaBlob}
|
||||
onClick={() =>
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
type,
|
||||
})
|
||||
}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -22,9 +22,31 @@ export default function Writing({
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : "");
|
||||
const [isSubmitEnabled, setIsSubmitEnabled] = useState(false);
|
||||
const [saveTimer, setSaveTimer] = useState(0);
|
||||
|
||||
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
|
||||
useEffect(() => {
|
||||
const saveTimerInterval = setInterval(() => {
|
||||
setSaveTimer((prev) => prev + 1);
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(saveTimerInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputText.length > 0 && saveTimer % 10 === 0) {
|
||||
setUserSolutions([
|
||||
...storeUserSolutions.filter((x) => x.exercise !== id),
|
||||
{exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type},
|
||||
]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [saveTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem("enable_paste")) return;
|
||||
|
||||
@@ -132,7 +154,14 @@ export default function Writing({
|
||||
<Button
|
||||
color="purple"
|
||||
disabled={!isSubmitEnabled}
|
||||
onClick={() => onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type})}
|
||||
onClick={() =>
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: [{id, solution: inputText.replaceAll(/\s{2,}/g, " ")}],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
type,
|
||||
})
|
||||
}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
</Button>
|
||||
|
||||
@@ -22,6 +22,7 @@ import InteractiveSpeaking from "./InteractiveSpeaking";
|
||||
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false});
|
||||
|
||||
export interface CommonProps {
|
||||
examID?: string;
|
||||
updateIndex?: (internalIndex: number) => void;
|
||||
onNext: (userSolutions: UserSolution) => void;
|
||||
onBack: (userSolutions: UserSolution) => void;
|
||||
@@ -29,17 +30,18 @@ export interface CommonProps {
|
||||
|
||||
export const renderExercise = (
|
||||
exercise: Exercise,
|
||||
examID: string,
|
||||
onNext: (userSolutions: UserSolution) => void,
|
||||
onBack: (userSolutions: UserSolution) => void,
|
||||
updateIndex?: (internalIndex: number) => void,
|
||||
) => {
|
||||
switch (exercise.type) {
|
||||
case "fillBlanks":
|
||||
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||
case "trueFalse":
|
||||
return <TrueFalse key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <TrueFalse key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||
case "matchSentences":
|
||||
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||
case "multipleChoice":
|
||||
return (
|
||||
<MultipleChoice
|
||||
@@ -48,19 +50,21 @@ export const renderExercise = (
|
||||
updateIndex={updateIndex}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
examID={examID}
|
||||
/>
|
||||
);
|
||||
case "writeBlanks":
|
||||
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||
case "writing":
|
||||
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||
case "speaking":
|
||||
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||
case "interactiveSpeaking":
|
||||
return (
|
||||
<InteractiveSpeaking
|
||||
key={exercise.id}
|
||||
{...(exercise as InteractiveSpeakingExercise)}
|
||||
examID={examID}
|
||||
updateIndex={updateIndex}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
|
||||
254
src/components/High/TicketDisplay.tsx
Normal file
254
src/components/High/TicketDisplay.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {
|
||||
Ticket,
|
||||
TicketStatus,
|
||||
TicketStatusLabel,
|
||||
TicketType,
|
||||
TicketTypeLabel,
|
||||
} from "@/interfaces/ticket";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||
import axios from "axios";
|
||||
import moment from "moment";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import ShortUniqueId from "short-unique-id";
|
||||
import Button from "../Low/Button";
|
||||
import Input from "../Low/Input";
|
||||
import Select from "../Low/Select";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
ticket: Ticket;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function TicketDisplay({ user, ticket, onClose }: Props) {
|
||||
const [subject] = useState(ticket.subject);
|
||||
const [type, setType] = useState<TicketType>(ticket.type);
|
||||
const [description] = useState(ticket.description);
|
||||
const [reporter] = useState(ticket.reporter);
|
||||
const [reportedFrom] = useState(ticket.reportedFrom);
|
||||
const [status, setStatus] = useState(ticket.status);
|
||||
const [assignedTo, setAssignedTo] = useState<string | null>(
|
||||
ticket.assignedTo || null,
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { users } = useUsers();
|
||||
|
||||
const submit = () => {
|
||||
if (!type)
|
||||
return toast.error("Please choose a type!", { toastId: "missing-type" });
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.patch(`/api/tickets/${ticket.id}`, {
|
||||
subject,
|
||||
type,
|
||||
description,
|
||||
reporter,
|
||||
reportedFrom,
|
||||
status,
|
||||
assignedTo,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(`The ticket has been updated!`, { toastId: "submitted" });
|
||||
onClose();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong, please try again later!", {
|
||||
toastId: "error",
|
||||
});
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
const del = () => {
|
||||
if (!confirm("Are you sure you want to delete this ticket?")) return;
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.delete(`/api/tickets/${ticket.id}`)
|
||||
.then(() => {
|
||||
toast.success(`The ticket has been deleted!`, { toastId: "submitted" });
|
||||
onClose();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong, please try again later!", {
|
||||
toastId: "error",
|
||||
});
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4 pt-8">
|
||||
<Input
|
||||
label="Subject"
|
||||
type="text"
|
||||
name="subject"
|
||||
placeholder="Subject..."
|
||||
value={subject}
|
||||
onChange={(e) => null}
|
||||
disabled
|
||||
/>
|
||||
|
||||
<div className="-md:flex-col flex w-full items-center gap-4">
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Status
|
||||
</label>
|
||||
<Select
|
||||
options={Object.keys(TicketStatusLabel).map((x) => ({
|
||||
value: x,
|
||||
label: TicketStatusLabel[x as keyof typeof TicketStatusLabel],
|
||||
}))}
|
||||
value={{ value: status, label: TicketStatusLabel[status] }}
|
||||
onChange={(value) =>
|
||||
setStatus((value?.value as TicketStatus) ?? undefined)
|
||||
}
|
||||
placeholder="Status..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Type
|
||||
</label>
|
||||
<Select
|
||||
options={Object.keys(TicketTypeLabel).map((x) => ({
|
||||
value: x,
|
||||
label: TicketTypeLabel[x as keyof typeof TicketTypeLabel],
|
||||
}))}
|
||||
value={{ value: type, label: TicketTypeLabel[type] }}
|
||||
onChange={(value) => setType(value!.value as TicketType)}
|
||||
placeholder="Type..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Assignee
|
||||
</label>
|
||||
<Select
|
||||
options={[
|
||||
{ value: "me", label: "Assign to me" },
|
||||
...users
|
||||
.filter((x) => ["admin", "developer"].includes(x.type))
|
||||
.map((u) => ({
|
||||
value: u.id,
|
||||
label: `${u.name} - ${u.email}`,
|
||||
})),
|
||||
]}
|
||||
disabled={user.type === "agent"}
|
||||
value={
|
||||
assignedTo
|
||||
? {
|
||||
value: assignedTo,
|
||||
label: `${users.find((u) => u.id === assignedTo)?.name} - ${users.find((u) => u.id === assignedTo)?.email}`,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onChange={(value) =>
|
||||
value
|
||||
? setAssignedTo(value.value === "me" ? user.id : value.value)
|
||||
: setAssignedTo(null)
|
||||
}
|
||||
placeholder="Assignee..."
|
||||
isClearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="-md:flex-col flex w-full items-center gap-4">
|
||||
<Input
|
||||
label="Reported From"
|
||||
type="text"
|
||||
name="reportedFrom"
|
||||
onChange={() => null}
|
||||
value={reportedFrom}
|
||||
disabled
|
||||
/>
|
||||
<Input
|
||||
label="Date"
|
||||
type="text"
|
||||
name="date"
|
||||
onChange={() => null}
|
||||
value={moment(ticket.date).format("DD/MM/YYYY - HH:mm")}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="-md:flex-col flex w-full items-center gap-4">
|
||||
<Input
|
||||
label="Reporter's Name"
|
||||
type="text"
|
||||
name="reporter"
|
||||
onChange={() => null}
|
||||
value={reporter.name}
|
||||
disabled
|
||||
/>
|
||||
<Input
|
||||
label="Reporter's E-mail"
|
||||
type="text"
|
||||
name="reporter"
|
||||
onChange={() => null}
|
||||
value={reporter.email}
|
||||
disabled
|
||||
/>
|
||||
<Input
|
||||
label="Reporter's Type"
|
||||
type="text"
|
||||
name="reporterType"
|
||||
onChange={() => null}
|
||||
value={USER_TYPE_LABELS[reporter.type]}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8"
|
||||
placeholder="Write your ticket's description here..."
|
||||
contentEditable={false}
|
||||
value={description}
|
||||
spellCheck
|
||||
/>
|
||||
|
||||
<div className="-md:flex-col-reverse mt-2 flex w-full items-center justify-between gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
color="red"
|
||||
className="w-full md:max-w-[200px]"
|
||||
variant="outline"
|
||||
onClick={del}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
<div className="-md:flex-col-reverse flex w-full items-center justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
color="red"
|
||||
className="w-full md:max-w-[200px]"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full md:max-w-[200px]"
|
||||
isLoading={isLoading}
|
||||
onClick={submit}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
101
src/components/High/TicketSubmission.tsx
Normal file
101
src/components/High/TicketSubmission.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import {Ticket, TicketType, TicketTypeLabel} from "@/interfaces/ticket";
|
||||
import {User} from "@/interfaces/user";
|
||||
import axios from "axios";
|
||||
import {useState} from "react";
|
||||
import {toast} from "react-toastify";
|
||||
import ShortUniqueId from "short-unique-id";
|
||||
import Button from "../Low/Button";
|
||||
import Input from "../Low/Input";
|
||||
import Select from "../Low/Select";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
page: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function TicketSubmission({user, page, onClose}: Props) {
|
||||
const [subject, setSubject] = useState("");
|
||||
const [type, setType] = useState<TicketType>();
|
||||
const [description, setDescription] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const submit = () => {
|
||||
if (!type) return toast.error("Please choose a type!", {toastId: "missing-type"});
|
||||
if (subject.trim() === "")
|
||||
return toast.error("Please input a subject!", {
|
||||
toastId: "missing-subject",
|
||||
});
|
||||
if (description.trim() === "")
|
||||
return toast.error("Please describe your ticket!", {
|
||||
toastId: "missing-desc",
|
||||
});
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const shortUID = new ShortUniqueId();
|
||||
const ticket: Ticket = {
|
||||
id: shortUID.randomUUID(8),
|
||||
date: new Date().toISOString(),
|
||||
reporter: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
type: user.type,
|
||||
},
|
||||
status: "submitted",
|
||||
subject,
|
||||
type,
|
||||
reportedFrom: page,
|
||||
description,
|
||||
};
|
||||
|
||||
axios
|
||||
.post(`/api/tickets`, ticket)
|
||||
.then(() => {
|
||||
toast.success(`Your ticket has been submitted! You will be contacted by e-mail for further discussion.`, {toastId: "submitted"});
|
||||
onClose();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong, please try again later!", {
|
||||
toastId: "error",
|
||||
});
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4 pt-8">
|
||||
<Input label="Subject" type="text" name="subject" placeholder="Subject..." onChange={(e) => setSubject(e)} />
|
||||
<div className="-md:flex-col flex w-full items-center gap-4">
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<label className="text-mti-gray-dim text-base font-normal">Type</label>
|
||||
<Select
|
||||
options={Object.keys(TicketTypeLabel).map((x) => ({
|
||||
value: x,
|
||||
label: TicketTypeLabel[x as keyof typeof TicketTypeLabel],
|
||||
}))}
|
||||
onChange={(value) => setType((value?.value as TicketType) ?? undefined)}
|
||||
placeholder="Type..."
|
||||
/>
|
||||
</div>
|
||||
<Input label="Reporter" type="text" name="reporter" onChange={() => null} value={`${user.name} - ${user.email}`} disabled />
|
||||
</div>
|
||||
<textarea
|
||||
className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8"
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Write your ticket's description here..."
|
||||
spellCheck
|
||||
/>
|
||||
<div className="mt-2 flex w-full items-center justify-end gap-4">
|
||||
<Button type="button" color="red" className="w-full max-w-[200px]" variant="outline" onClick={onClose} isLoading={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" className="w-full max-w-[200px]" isLoading={isLoading} onClick={submit}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {BsArrowRepeat} from "react-icons/bs";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
color?: "rose" | "purple" | "red" | "green" | "gray";
|
||||
color?: "rose" | "purple" | "red" | "green" | "gray" | "pink";
|
||||
variant?: "outline" | "solid";
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
@@ -49,6 +49,11 @@ export default function Button({
|
||||
outline:
|
||||
"bg-transparent text-mti-rose-light border border-mti-rose-light hover:bg-mti-rose-light disabled:text-mti-rose disabled:bg-mti-rose-ultralight disabled:border-none selection:bg-mti-rose-dark hover:text-white selection:text-white",
|
||||
},
|
||||
pink: {
|
||||
solid: "bg-ielts-speaking text-white border border-ielts-speaking hover:bg-ielts-speaking disabled:text-ielts-speaking disabled:bg-ielts-speaking-transparent selection:bg-ielts-speaking",
|
||||
outline:
|
||||
"bg-transparent text-ielts-speaking border border-ielts-speaking hover:bg-ielts-speaking disabled:text-ielts-speaking disabled:bg-ielts-speaking-transparent disabled:border-none selection:bg-ielts-speaking hover:text-white selection:text-white",
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -42,7 +42,9 @@ export default function CountrySelect({value, disabled = false, onChange}: Props
|
||||
displayValue={(code: string) => {
|
||||
const country = countries[code as unknown as keyof TCountries];
|
||||
|
||||
return `${countryCodes.findOne("countryCode" as any, code).flag} ${country.name} (+${country.phone})`;
|
||||
return `${countryCodes.findOne("countryCode" as any, code)?.flag || ""} ${country?.name || "N/A"} (+${
|
||||
country?.phone || "N/A"
|
||||
})`;
|
||||
}}
|
||||
/>
|
||||
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-8">
|
||||
|
||||
@@ -5,12 +5,14 @@ interface Props {
|
||||
label: string;
|
||||
percentage: number;
|
||||
color: "red" | "rose" | "purple" | Module;
|
||||
mark?: number;
|
||||
markLabel?: string;
|
||||
useColor?: boolean;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
}
|
||||
|
||||
export default function ProgressBar({label, percentage, color, useColor = false, className, textClassName}: Props) {
|
||||
export default function ProgressBar({label, percentage, color, mark, markLabel, useColor = false, className, textClassName}: Props) {
|
||||
const progressColorClass: {[key in typeof color]: string} = {
|
||||
red: "bg-mti-red-light",
|
||||
rose: "bg-mti-rose-light",
|
||||
@@ -30,6 +32,9 @@ export default function ProgressBar({label, percentage, color, useColor = false,
|
||||
!useColor ? "bg-mti-gray-anti-flash" : progressColorClass[color],
|
||||
useColor && "bg-opacity-20",
|
||||
)}>
|
||||
{mark && (
|
||||
<div style={{left: `${mark}%`}} className={clsx("w-3 h-2 bg-mti-gray-davy/60 absolute -translate-x-1/2 top-0 z-20 cursor-pointer")} />
|
||||
)}
|
||||
<div
|
||||
style={{width: `${percentage}%`}}
|
||||
className={clsx("absolute transition-all duration-300 ease-in-out top-0 left-0 h-full overflow-hidden", progressColorClass[color])}
|
||||
|
||||
68
src/components/Low/Select.tsx
Normal file
68
src/components/Low/Select.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import clsx from "clsx";
|
||||
import { ComponentProps } from "react";
|
||||
import ReactSelect from "react-select";
|
||||
|
||||
interface Option {
|
||||
[key: string]: any;
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
defaultValue?: Option;
|
||||
value?: Option | null;
|
||||
options: Option[];
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
onChange: (value: Option | null) => void;
|
||||
isClearable?: boolean;
|
||||
}
|
||||
|
||||
export default function Select({
|
||||
value,
|
||||
defaultValue,
|
||||
options,
|
||||
placeholder,
|
||||
disabled,
|
||||
onChange,
|
||||
isClearable,
|
||||
}: Props) {
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
77
src/components/Medium/InviteCard.tsx
Normal file
77
src/components/Medium/InviteCard.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Invite } from "@/interfaces/invite";
|
||||
import { User } from "@/interfaces/user";
|
||||
import axios from "axios";
|
||||
import { useState } from "react";
|
||||
import { BsArrowRepeat } from "react-icons/bs";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
interface Props {
|
||||
invite: Invite;
|
||||
users: User[];
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
export default function InviteCard({ invite, users, reload }: Props) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const inviter = users.find((u) => u.id === invite.from);
|
||||
const name = !inviter
|
||||
? null
|
||||
: inviter.type === "corporate"
|
||||
? inviter.corporateInformation?.companyInformation?.name || inviter.name
|
||||
: inviter.name;
|
||||
|
||||
const decide = (decision: "accept" | "decline") => {
|
||||
if (!confirm(`Are you sure you want to ${decision} this invite?`)) return;
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get(`/api/invites/${decision}/${invite.id}`)
|
||||
.then(() => {
|
||||
toast.success(
|
||||
`Successfully ${decision === "accept" ? "accepted" : "declined"} the invite!`,
|
||||
{ toastId: "success" },
|
||||
);
|
||||
reload();
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.success(`Something went wrong, please try again later!`, {
|
||||
toastId: "error",
|
||||
});
|
||||
reload();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-mti-gray-anti-flash flex min-w-[200px] flex-col gap-6 rounded-xl border p-4 text-black">
|
||||
<span>Invited by {name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => decide("accept")}
|
||||
disabled={isLoading}
|
||||
className="bg-mti-green-ultralight hover:bg-mti-green-light w-24 rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed"
|
||||
>
|
||||
{!isLoading && "Accept"}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => decide("decline")}
|
||||
disabled={isLoading}
|
||||
className="bg-mti-red-ultralight hover:bg-mti-red-light w-24 rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed"
|
||||
>
|
||||
{!isLoading && "Decline"}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
src/components/Medium/ModuleLevelSelector.tsx
Normal file
121
src/components/Medium/ModuleLevelSelector.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import {Module} from "@/interfaces";
|
||||
import {writingMarking} from "@/utils/score";
|
||||
import {Menu} from "@headlessui/react";
|
||||
import {Dispatch, SetStateAction} from "react";
|
||||
import {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||
|
||||
type Levels = {[key in Module]: number};
|
||||
|
||||
interface Props {
|
||||
levels: Levels;
|
||||
setLevels: Dispatch<SetStateAction<Levels>>;
|
||||
}
|
||||
|
||||
export default function ModuleLevelSelector({levels, setLevels}: Props) {
|
||||
return (
|
||||
<div className="flex flex-col gap-32 w-full">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-16">
|
||||
<div className="w-full flex flex-col gap-3.5 relative">
|
||||
<span className="text-sm text-mti-gray-dim">
|
||||
<span className="font-bold">Reading</span> level
|
||||
</span>
|
||||
<Menu>
|
||||
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
||||
<BsBook className="text-ielts-reading" size={34} />
|
||||
<span className="text-mti-gray-cool text-sm">
|
||||
{levels.reading === -1 ? "Select your reading level" : `Level ${levels.reading}`}
|
||||
</span>
|
||||
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
||||
</Menu.Button>
|
||||
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
|
||||
{Object.values(writingMarking).map((x) => (
|
||||
<Menu.Item key={x}>
|
||||
<span
|
||||
onClick={() => setLevels((prev) => ({...prev, reading: x}))}
|
||||
className="w-full py-4 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
||||
Level {x}
|
||||
</span>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-3.5 relative">
|
||||
<span className="text-sm text-mti-gray-dim">
|
||||
<span className="font-bold">Listening</span> level
|
||||
</span>
|
||||
<Menu>
|
||||
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
||||
<BsHeadphones className="text-ielts-listening" size={34} />
|
||||
<span className="text-mti-gray-cool text-sm">
|
||||
{levels.listening === -1 ? "Select your listening level" : `Level ${levels.listening}`}
|
||||
</span>
|
||||
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
||||
</Menu.Button>
|
||||
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-50 drop-shadow-lg rounded-2xl">
|
||||
{Object.values(writingMarking).map((x) => (
|
||||
<Menu.Item key={x}>
|
||||
<span
|
||||
onClick={() => setLevels((prev) => ({...prev, listening: x}))}
|
||||
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
||||
Level {x}
|
||||
</span>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-3.5 relative">
|
||||
<span className="text-sm text-mti-gray-dim">
|
||||
<span className="font-bold">Writing</span> level
|
||||
</span>
|
||||
<Menu>
|
||||
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
||||
<BsPen className="text-ielts-writing" size={34} />
|
||||
<span className="text-mti-gray-cool text-sm">
|
||||
{levels.writing === -1 ? "Select your writing level" : `Level ${levels.writing}`}
|
||||
</span>
|
||||
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
||||
</Menu.Button>
|
||||
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
|
||||
{Object.values(writingMarking).map((x) => (
|
||||
<Menu.Item key={x}>
|
||||
<span
|
||||
onClick={() => setLevels((prev) => ({...prev, writing: x}))}
|
||||
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
||||
Level {x}
|
||||
</span>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-3.5 relative">
|
||||
<span className="text-sm text-mti-gray-dim">
|
||||
<span className="font-bold">Speaking</span> level
|
||||
</span>
|
||||
<Menu>
|
||||
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
||||
<BsMegaphone className="text-ielts-speaking" size={34} />
|
||||
<span className="text-mti-gray-cool text-sm">
|
||||
{levels.speaking === -1 ? "Select your speaking level" : `Level ${levels.speaking}`}
|
||||
</span>
|
||||
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
||||
</Menu.Button>
|
||||
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
|
||||
{Object.values(writingMarking).map((x) => (
|
||||
<Menu.Item key={x}>
|
||||
<span
|
||||
onClick={() => setLevels((prev) => ({...prev, speaking: x}))}
|
||||
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
||||
Level {x}
|
||||
</span>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
src/components/Medium/SessionCard.tsx
Normal file
101
src/components/Medium/SessionCard.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import {Session} from "@/hooks/useSessions";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {sortByModuleName} from "@/utils/moduleUtils";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize} from "lodash";
|
||||
import moment from "moment";
|
||||
import {useState} from "react";
|
||||
import {BsArrowRepeat, BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
|
||||
export default function SessionCard({
|
||||
session,
|
||||
reload,
|
||||
loadSession,
|
||||
}: {
|
||||
session: Session;
|
||||
reload: () => void;
|
||||
loadSession: (session: Session) => Promise<void>;
|
||||
}) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const deleteSession = async () => {
|
||||
if (!confirm("Are you sure you want to delete this session?")) return;
|
||||
|
||||
setIsLoading(true);
|
||||
await axios
|
||||
.delete(`/api/sessions/${session.sessionId}`)
|
||||
.then(() => {
|
||||
toast.success(`Successfully delete session "${session.sessionId}"`);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
toast.error("Something went wrong, please try again later");
|
||||
})
|
||||
.finally(() => {
|
||||
reload();
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-mti-gray-anti-flash flex w-64 flex-col gap-3 rounded-xl border p-4 text-black">
|
||||
<span className="flex gap-1">
|
||||
<b>ID:</b>
|
||||
{session.sessionId}
|
||||
</span>
|
||||
<span className="flex gap-1">
|
||||
<b>Date:</b>
|
||||
{moment(session.date).format("DD/MM/YYYY - HH:mm")}
|
||||
</span>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-center justify-center gap-2">
|
||||
{session.selectedModules.sort(sortByModuleName).map((module) => (
|
||||
<div
|
||||
key={module}
|
||||
data-tip={capitalize(module)}
|
||||
className={clsx(
|
||||
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||
module === "reading" && "bg-ielts-reading",
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}>
|
||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<button
|
||||
onClick={async () => await loadSession(session)}
|
||||
disabled={isLoading}
|
||||
className="bg-mti-green-ultralight w-full hover:bg-mti-green-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
|
||||
{!isLoading && "Resume"}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={deleteSession}
|
||||
disabled={isLoading}
|
||||
className="bg-mti-red-ultralight w-full hover:bg-mti-red-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
|
||||
{!isLoading && "Delete"}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,149 +1,201 @@
|
||||
import {User} from "@/interfaces/user";
|
||||
import {Dialog, Transition} from "@headlessui/react";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import {useRouter} from "next/router";
|
||||
import {Fragment} from "react";
|
||||
import {BsShield, BsShieldFill, BsXLg} from "react-icons/bs";
|
||||
import { useRouter } from "next/router";
|
||||
import { Fragment } from "react";
|
||||
import { BsXLg } from "react-icons/bs";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
path: string;
|
||||
user: User;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
path: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export default function MobileMenu({isOpen, onClose, path, user}: Props) {
|
||||
const router = useRouter();
|
||||
export default function MobileMenu({ isOpen, onClose, path, user }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const logout = async () => {
|
||||
axios.post("/api/logout").finally(() => {
|
||||
setTimeout(() => router.reload(), 500);
|
||||
});
|
||||
};
|
||||
const logout = async () => {
|
||||
axios.post("/api/logout").finally(() => {
|
||||
setTimeout(() => router.reload(), 500);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||
</Transition.Child>
|
||||
return (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center text-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95">
|
||||
<Dialog.Panel className="w-full h-screen transform overflow-hidden bg-white text-left align-middle shadow-xl transition-all text-black flex flex-col gap-8">
|
||||
<Dialog.Title as="header" className="w-full px-8 py-2 -md:flex justify-between items-center md:hidden shadow-sm">
|
||||
<Link href="/">
|
||||
<Image src="/logo_title.png" alt="EnCoach logo" width={69} height={69} />
|
||||
</Link>
|
||||
<div className="cursor-pointer" onClick={onClose} tabIndex={0}>
|
||||
<BsXLg className="text-2xl text-mti-purple-light" onClick={onClose} />
|
||||
</div>
|
||||
</Dialog.Title>
|
||||
<div className="flex flex-col h-full gap-6 px-8 text-lg">
|
||||
<Link
|
||||
href="/"
|
||||
className={clsx(
|
||||
"transition ease-in-out duration-300 w-fit",
|
||||
path === "/" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
||||
)}>
|
||||
Dashboard
|
||||
</Link>
|
||||
{(user.type === "student" || user.type === "teacher" || user.type === "developer") && (
|
||||
<>
|
||||
<Link
|
||||
href="/exam"
|
||||
className={clsx(
|
||||
"transition ease-in-out duration-300 w-fit",
|
||||
path === "/exam" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
||||
)}>
|
||||
Exams
|
||||
</Link>
|
||||
<Link
|
||||
href="/exercises"
|
||||
className={clsx(
|
||||
"transition ease-in-out duration-300 w-fit",
|
||||
path === "/exercises" &&
|
||||
"text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
||||
)}>
|
||||
Exercises
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<Link
|
||||
href="/stats"
|
||||
className={clsx(
|
||||
"transition ease-in-out duration-300 w-fit",
|
||||
path === "/stats" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
||||
)}>
|
||||
Stats
|
||||
</Link>
|
||||
<Link
|
||||
href="/record"
|
||||
className={clsx(
|
||||
"transition ease-in-out duration-300 w-fit",
|
||||
path === "/record" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
||||
)}>
|
||||
Record
|
||||
</Link>
|
||||
{["admin", "developer", "agent", "corporate"].includes(user.type) && (
|
||||
<Link
|
||||
href="/payment-record"
|
||||
className={clsx(
|
||||
"transition ease-in-out duration-300 w-fit",
|
||||
path === "/payment-record" &&
|
||||
"text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
||||
)}>
|
||||
Payment Record
|
||||
</Link>
|
||||
)}
|
||||
{["admin", "developer", "corporate", "teacher"].includes(user.type) && (
|
||||
<Link
|
||||
href="/settings"
|
||||
className={clsx(
|
||||
"transition ease-in-out duration-300 w-fit",
|
||||
path === "/settings" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
||||
)}>
|
||||
Settings
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href="/profile"
|
||||
className={clsx(
|
||||
"transition ease-in-out duration-300 w-fit",
|
||||
path === "/profile" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
||||
)}>
|
||||
Profile
|
||||
</Link>
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center text-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="flex h-screen w-full transform flex-col gap-8 overflow-hidden bg-white text-left align-middle text-black shadow-xl transition-all">
|
||||
<Dialog.Title
|
||||
as="header"
|
||||
className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden"
|
||||
>
|
||||
<Link href="/">
|
||||
<Image
|
||||
src="/logo_title.png"
|
||||
alt="EnCoach logo"
|
||||
width={69}
|
||||
height={69}
|
||||
/>
|
||||
</Link>
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={onClose}
|
||||
tabIndex={0}
|
||||
>
|
||||
<BsXLg
|
||||
className="text-mti-purple-light text-2xl"
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
</Dialog.Title>
|
||||
<div className="flex h-full flex-col gap-6 px-8 text-lg">
|
||||
<Link
|
||||
href="/"
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
{(user.type === "student" ||
|
||||
user.type === "teacher" ||
|
||||
user.type === "developer") && (
|
||||
<>
|
||||
<Link
|
||||
href="/exam"
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/exam" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}
|
||||
>
|
||||
Exams
|
||||
</Link>
|
||||
<Link
|
||||
href="/exercises"
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/exercises" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}
|
||||
>
|
||||
Exercises
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<Link
|
||||
href="/stats"
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/stats" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}
|
||||
>
|
||||
Stats
|
||||
</Link>
|
||||
<Link
|
||||
href="/record"
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/record" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}
|
||||
>
|
||||
Record
|
||||
</Link>
|
||||
{["admin", "developer", "agent", "corporate"].includes(
|
||||
user.type,
|
||||
) && (
|
||||
<Link
|
||||
href="/payment-record"
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/payment-record" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}
|
||||
>
|
||||
Payment Record
|
||||
</Link>
|
||||
)}
|
||||
{["admin", "developer", "corporate", "teacher"].includes(
|
||||
user.type,
|
||||
) && (
|
||||
<Link
|
||||
href="/settings"
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/settings" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
)}
|
||||
{["admin", "developer", "agent"].includes(user.type) && (
|
||||
<Link
|
||||
href="/tickets"
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/tickets" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}
|
||||
>
|
||||
Tickets
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href="/profile"
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/profile" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}
|
||||
>
|
||||
Profile
|
||||
</Link>
|
||||
|
||||
<span
|
||||
className={clsx("transition ease-in-out duration-300 w-fit justify-self-end cursor-pointer")}
|
||||
onClick={logout}>
|
||||
Logout
|
||||
</span>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
<span
|
||||
className={clsx(
|
||||
"w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out",
|
||||
)}
|
||||
onClick={logout}
|
||||
>
|
||||
Logout
|
||||
</span>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ interface Props {
|
||||
export default function Modal({isOpen, title, onClose, children}: Props) {
|
||||
return (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={onClose}>
|
||||
<Dialog as="div" className="relative z-[200]" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
|
||||
@@ -3,7 +3,7 @@ import Link from "next/link";
|
||||
import FocusLayer from "@/components/FocusLayer";
|
||||
import {preventNavigation} from "@/utils/navigation.disabled";
|
||||
import {useRouter} from "next/router";
|
||||
import {BsList} from "react-icons/bs";
|
||||
import {BsList, BsQuestionCircle, BsQuestionCircleFill} from "react-icons/bs";
|
||||
import clsx from "clsx";
|
||||
import moment from "moment";
|
||||
import MobileMenu from "./MobileMenu";
|
||||
@@ -12,6 +12,10 @@ import {Type} from "@/interfaces/user";
|
||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import {isUserFromCorporate} from "@/utils/groups";
|
||||
import Button from "./Low/Button";
|
||||
import Modal from "./Modal";
|
||||
import Input from "./Low/Input";
|
||||
import TicketSubmission from "./High/TicketSubmission";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
@@ -25,9 +29,9 @@ interface Props {
|
||||
export default function Navbar({user, path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
|
||||
const [isTicketOpen, setIsTicketOpen] = useState(false);
|
||||
|
||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||
const router = useRouter();
|
||||
|
||||
const expirationDateColor = (date: Date) => {
|
||||
const momentDate = moment(date);
|
||||
@@ -48,44 +52,59 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user.type !== "student" && user.type !== "teacher") setDisablePaymentPage(false);
|
||||
if (user.type !== "student" && user.type !== "teacher") return setDisablePaymentPage(false);
|
||||
isUserFromCorporate(user.id).then((result) => setDisablePaymentPage(result));
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={isTicketOpen} onClose={() => setIsTicketOpen(false)} title="Submit a ticket">
|
||||
<TicketSubmission user={user} page={window.location.href} onClose={() => setIsTicketOpen(false)} />
|
||||
</Modal>
|
||||
|
||||
{user && <MobileMenu path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />}
|
||||
<header className="w-full bg-transparent py-2 md:py-4 -md:justify-between md:gap-12 flex items-center relative -md:px-4">
|
||||
<Link href={disableNavigation ? "" : "/"} className=" md:px-8 flex gap-8 items-center">
|
||||
<header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4">
|
||||
<Link href={disableNavigation ? "" : "/"} className=" flex items-center gap-8 md:px-8">
|
||||
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" />
|
||||
<h1 className="font-bold text-2xl w-1/6 -md:hidden">EnCoach</h1>
|
||||
<h1 className="-md:hidden w-1/6 text-2xl font-bold">EnCoach</h1>
|
||||
</Link>
|
||||
<div className="flex justify-end -md:items-center gap-4 md:w-5/6 md:mr-8">
|
||||
<div className="flex items-center justify-end gap-4 md:mr-8 md:w-5/6">
|
||||
{/* OPEN TICKET SYSTEM */}
|
||||
<button
|
||||
className={clsx(
|
||||
"border-mti-purple-light tooltip tooltip-bottom flex h-8 w-8 flex-col items-center justify-center rounded-full border p-1",
|
||||
"hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white",
|
||||
)}
|
||||
data-tip="Submit a help/feedback ticket"
|
||||
onClick={() => setIsTicketOpen(true)}>
|
||||
<BsQuestionCircleFill />
|
||||
</button>
|
||||
|
||||
{showExpirationDate() && (
|
||||
<Link
|
||||
href={disablePaymentPage ? "/payment" : ""}
|
||||
data-tip="Expiry date"
|
||||
className={clsx(
|
||||
"py-2 px-6 w-fit flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"transition duration-300 ease-in-out tooltip tooltip-bottom",
|
||||
"flex w-fit cursor-pointer justify-center rounded-full border px-6 py-2 text-sm font-normal focus:outline-none",
|
||||
"tooltip tooltip-bottom transition duration-300 ease-in-out",
|
||||
!user.subscriptionExpirationDate
|
||||
? "bg-mti-green-ultralight border-mti-green-light"
|
||||
: expirationDateColor(user.subscriptionExpirationDate),
|
||||
"bg-white border-mti-gray-platinum",
|
||||
"border-mti-gray-platinum bg-white",
|
||||
)}>
|
||||
{!user.subscriptionExpirationDate && "Unlimited"}
|
||||
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
|
||||
</Link>
|
||||
)}
|
||||
<Link href={disableNavigation ? "" : "/profile"} className="flex gap-6 items-center justify-end -md:hidden">
|
||||
<img src={user.profilePicture} alt={user.name} className="w-10 h-10 rounded-full object-cover" />
|
||||
<span className="text-right -md:hidden">
|
||||
<Link href={disableNavigation ? "" : "/profile"} className="-md:hidden flex items-center justify-end gap-6">
|
||||
<img src={user.profilePicture} alt={user.name} className="h-10 w-10 rounded-full object-cover" />
|
||||
<span className="-md:hidden text-right">
|
||||
{user.type === "corporate" ? `${user.corporateInformation?.companyInformation.name} |` : ""} {user.name} |{" "}
|
||||
{USER_TYPE_LABELS[user.type]}
|
||||
</span>
|
||||
</Link>
|
||||
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}>
|
||||
<BsList className="text-mti-purple-light w-8 h-8" />
|
||||
<BsList className="text-mti-purple-light h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
||||
|
||||
@@ -11,11 +11,12 @@ interface Props {
|
||||
price: number;
|
||||
duration: number;
|
||||
duration_unit: DurationUnit;
|
||||
loadScript?: boolean;
|
||||
setIsLoading: (isLoading: boolean) => void;
|
||||
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
|
||||
}
|
||||
|
||||
export default function PayPalPayment({clientID, price, currency, duration, duration_unit, setIsLoading, onSuccess}: Props) {
|
||||
export default function PayPalPayment({clientID, price, currency, duration, duration_unit, loadScript, setIsLoading, onSuccess}: Props) {
|
||||
const createOrder = async (data: CreateOrderData, actions: CreateOrderActions): Promise<string> => {
|
||||
setIsLoading(true);
|
||||
|
||||
@@ -45,7 +46,7 @@ export default function PayPalPayment({clientID, price, currency, duration, dura
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
return loadScript ? (
|
||||
<PayPalScriptProvider
|
||||
options={{
|
||||
clientId: clientID,
|
||||
@@ -60,7 +61,17 @@ export default function PayPalPayment({clientID, price, currency, duration, dura
|
||||
createOrder={createOrder}
|
||||
onApprove={onApprove}
|
||||
onCancel={onCancel}
|
||||
onError={onError}></PayPalButtons>
|
||||
onError={onError}
|
||||
/>
|
||||
</PayPalScriptProvider>
|
||||
) : (
|
||||
<PayPalButtons
|
||||
className="w-full"
|
||||
style={{layout: "vertical"}}
|
||||
createOrder={createOrder}
|
||||
onApprove={onApprove}
|
||||
onCancel={onCancel}
|
||||
onError={onError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ interface Props {
|
||||
icon: ReactElement;
|
||||
value: string | number;
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
}[];
|
||||
children?: ReactElement;
|
||||
}
|
||||
@@ -48,7 +49,10 @@ export default function ProfileSummary({user, items}: Props) {
|
||||
<div className="flex justify-between w-full mt-8 -md:hidden">
|
||||
{items.map((item) => (
|
||||
<div className="flex gap-4 items-center" key={item.label}>
|
||||
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
||||
<div
|
||||
className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl relative group tooltip tooltip-bottom"
|
||||
data-tip={item.tooltip}
|
||||
>
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
|
||||
@@ -1,174 +1,295 @@
|
||||
import clsx from "clsx";
|
||||
import {IconType} from "react-icons";
|
||||
import {MdSpaceDashboard} from "react-icons/md";
|
||||
import { IconType } from "react-icons";
|
||||
import { MdSpaceDashboard } from "react-icons/md";
|
||||
import {
|
||||
BsFileEarmarkText,
|
||||
BsClockHistory,
|
||||
BsPencil,
|
||||
BsGraphUp,
|
||||
BsChevronBarRight,
|
||||
BsChevronBarLeft,
|
||||
BsShieldFill,
|
||||
BsCloudFill,
|
||||
BsCurrencyDollar,
|
||||
BsFileEarmarkText,
|
||||
BsClockHistory,
|
||||
BsPencil,
|
||||
BsGraphUp,
|
||||
BsChevronBarRight,
|
||||
BsChevronBarLeft,
|
||||
BsShieldFill,
|
||||
BsCloudFill,
|
||||
BsCurrencyDollar,
|
||||
BsClipboardData,
|
||||
} from "react-icons/bs";
|
||||
import {RiLogoutBoxFill} from "react-icons/ri";
|
||||
import {SlPencil} from "react-icons/sl";
|
||||
import {FaAward} from "react-icons/fa";
|
||||
import { RiLogoutBoxFill } from "react-icons/ri";
|
||||
import { SlPencil } from "react-icons/sl";
|
||||
import { FaAward } from "react-icons/fa";
|
||||
import Link from "next/link";
|
||||
import {useRouter} from "next/router";
|
||||
import { useRouter } from "next/router";
|
||||
import axios from "axios";
|
||||
import FocusLayer from "@/components/FocusLayer";
|
||||
import {preventNavigation} from "@/utils/navigation.disabled";
|
||||
import {useState} from "react";
|
||||
import { preventNavigation } from "@/utils/navigation.disabled";
|
||||
import { useState } from "react";
|
||||
import usePreferencesStore from "@/stores/preferencesStore";
|
||||
import {Type} from "@/interfaces/user";
|
||||
import { Type } from "@/interfaces/user";
|
||||
interface Props {
|
||||
path: string;
|
||||
navDisabled?: boolean;
|
||||
focusMode?: boolean;
|
||||
onFocusLayerMouseEnter?: () => void;
|
||||
className?: string;
|
||||
userType?: Type;
|
||||
path: string;
|
||||
navDisabled?: boolean;
|
||||
focusMode?: boolean;
|
||||
onFocusLayerMouseEnter?: () => void;
|
||||
className?: string;
|
||||
userType?: Type;
|
||||
}
|
||||
|
||||
interface NavProps {
|
||||
Icon: IconType;
|
||||
label: string;
|
||||
path: string;
|
||||
keyPath: string;
|
||||
disabled?: boolean;
|
||||
isMinimized?: boolean;
|
||||
Icon: IconType;
|
||||
label: string;
|
||||
path: string;
|
||||
keyPath: string;
|
||||
disabled?: boolean;
|
||||
isMinimized?: boolean;
|
||||
}
|
||||
|
||||
const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false}: NavProps) => (
|
||||
<Link
|
||||
href={!disabled ? keyPath : ""}
|
||||
className={clsx(
|
||||
"p-4 rounded-full flex gap-4 items-center cursor-pointer text-gray-500 hover:bg-mti-purple-light hover:text-white",
|
||||
"transition-all duration-300 ease-in-out",
|
||||
path === keyPath && "bg-mti-purple-light text-white",
|
||||
isMinimized ? "w-fit" : "w-full min-w-[200px] 2xl:min-w-[220px] px-8",
|
||||
)}>
|
||||
<Icon size={24} />
|
||||
{!isMinimized && <span className="text-lg font-semibold">{label}</span>}
|
||||
</Link>
|
||||
const Nav = ({
|
||||
Icon,
|
||||
label,
|
||||
path,
|
||||
keyPath,
|
||||
disabled = false,
|
||||
isMinimized = false,
|
||||
}: NavProps) => (
|
||||
<Link
|
||||
href={!disabled ? keyPath : ""}
|
||||
className={clsx(
|
||||
"flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white",
|
||||
"transition-all duration-300 ease-in-out",
|
||||
disabled
|
||||
? "hover:bg-mti-gray-dim cursor-not-allowed"
|
||||
: "hover:bg-mti-purple-light cursor-pointer",
|
||||
path === keyPath && "bg-mti-purple-light text-white",
|
||||
isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]",
|
||||
)}
|
||||
>
|
||||
<Icon size={24} />
|
||||
{!isMinimized && <span className="text-lg font-semibold">{label}</span>}
|
||||
</Link>
|
||||
);
|
||||
|
||||
export default function Sidebar({path, navDisabled = false, focusMode = false, userType, onFocusLayerMouseEnter, className}: Props) {
|
||||
const router = useRouter();
|
||||
export default function Sidebar({
|
||||
path,
|
||||
navDisabled = false,
|
||||
focusMode = false,
|
||||
userType,
|
||||
onFocusLayerMouseEnter,
|
||||
className,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
|
||||
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [
|
||||
state.isSidebarMinimized,
|
||||
state.toggleSidebarMinimized,
|
||||
]);
|
||||
|
||||
const logout = async () => {
|
||||
axios.post("/api/logout").finally(() => {
|
||||
setTimeout(() => router.reload(), 500);
|
||||
});
|
||||
};
|
||||
const logout = async () => {
|
||||
axios.post("/api/logout").finally(() => {
|
||||
setTimeout(() => router.reload(), 500);
|
||||
});
|
||||
};
|
||||
|
||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||
|
||||
return (
|
||||
<section
|
||||
className={clsx(
|
||||
"h-full flex bg-transparent flex-col justify-between px-4 py-4 pb-8 relative",
|
||||
isMinimized ? "w-fit" : "w-1/6 -xl:w-fit",
|
||||
className,
|
||||
)}>
|
||||
<div className="xl:flex -xl:hidden flex-col gap-3">
|
||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
|
||||
{(userType === "student" || userType === "teacher" || userType === "developer") && (
|
||||
<>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsFileEarmarkText}
|
||||
label="Exams"
|
||||
path={path}
|
||||
keyPath="/exam"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsPencil}
|
||||
label="Exercises"
|
||||
path={path}
|
||||
keyPath="/exercises"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
|
||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
|
||||
{["admin", "developer", "agent", "corporate"].includes(userType || "") && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCurrencyDollar}
|
||||
label="Payment Record"
|
||||
path={path}
|
||||
keyPath="/payment-record"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{["admin", "developer", "corporate", "teacher"].includes(userType || "") && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsShieldFill}
|
||||
label="Settings"
|
||||
path={path}
|
||||
keyPath="/settings"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{userType === "developer" && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCloudFill}
|
||||
label="Generation"
|
||||
path={path}
|
||||
keyPath="/generation"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="xl:hidden -xl:flex flex-col gap-3">
|
||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} />
|
||||
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} />
|
||||
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
|
||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
|
||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
|
||||
{userType !== "student" && (
|
||||
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized={true} />
|
||||
)}
|
||||
{userType === "developer" && (
|
||||
<Nav disabled={disableNavigation} Icon={BsCloudFill} label="Generation" path={path} keyPath="/generation" isMinimized={true} />
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<section
|
||||
className={clsx(
|
||||
"relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8",
|
||||
isMinimized ? "w-fit" : "-xl:w-fit w-1/6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="-xl:hidden flex-col gap-3 xl:flex">
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={MdSpaceDashboard}
|
||||
label="Dashboard"
|
||||
path={path}
|
||||
keyPath="/"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
{(userType === "student" ||
|
||||
userType === "teacher" ||
|
||||
userType === "developer") && (
|
||||
<>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsFileEarmarkText}
|
||||
label="Exams"
|
||||
path={path}
|
||||
keyPath="/exam"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsPencil}
|
||||
label="Exercises"
|
||||
path={path}
|
||||
keyPath="/exercises"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsGraphUp}
|
||||
label="Stats"
|
||||
path={path}
|
||||
keyPath="/stats"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsClockHistory}
|
||||
label="Record"
|
||||
path={path}
|
||||
keyPath="/record"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
{["admin", "developer", "agent", "corporate"].includes(
|
||||
userType || "",
|
||||
) && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCurrencyDollar}
|
||||
label="Payment Record"
|
||||
path={path}
|
||||
keyPath="/payment-record"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{["admin", "developer", "corporate", "teacher"].includes(
|
||||
userType || "",
|
||||
) && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsShieldFill}
|
||||
label="Settings"
|
||||
path={path}
|
||||
keyPath="/settings"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{["admin", "developer", "agent"].includes(userType || "") && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsClipboardData}
|
||||
label="Tickets"
|
||||
path={path}
|
||||
keyPath="/tickets"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{userType === "developer" && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCloudFill}
|
||||
label="Generation"
|
||||
path={path}
|
||||
keyPath="/generation"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="-xl:flex flex-col gap-3 xl:hidden">
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={MdSpaceDashboard}
|
||||
label="Dashboard"
|
||||
path={path}
|
||||
keyPath="/"
|
||||
isMinimized={true}
|
||||
/>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsFileEarmarkText}
|
||||
label="Exams"
|
||||
path={path}
|
||||
keyPath="/exam"
|
||||
isMinimized={true}
|
||||
/>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsPencil}
|
||||
label="Exercises"
|
||||
path={path}
|
||||
keyPath="/exercises"
|
||||
isMinimized={true}
|
||||
/>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsGraphUp}
|
||||
label="Stats"
|
||||
path={path}
|
||||
keyPath="/stats"
|
||||
isMinimized={true}
|
||||
/>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsClockHistory}
|
||||
label="Record"
|
||||
path={path}
|
||||
keyPath="/record"
|
||||
isMinimized={true}
|
||||
/>
|
||||
{userType !== "student" && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsShieldFill}
|
||||
label="Settings"
|
||||
path={path}
|
||||
keyPath="/settings"
|
||||
isMinimized={true}
|
||||
/>
|
||||
)}
|
||||
{userType === "developer" && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCloudFill}
|
||||
label="Generation"
|
||||
path={path}
|
||||
keyPath="/generation"
|
||||
isMinimized={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-0 bottom-12 fixed">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={1}
|
||||
onClick={toggleMinimize}
|
||||
className={clsx(
|
||||
"p-4 rounded-full flex gap-4 items-center cursor-pointer text-black hover:text-mti-rose -xl:hidden transition duration-300 ease-in-out",
|
||||
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
||||
)}>
|
||||
{isMinimized ? <BsChevronBarRight size={24} /> : <BsChevronBarLeft size={24} />}
|
||||
{!isMinimized && <span className="text-lg font-medium">Minimize</span>}
|
||||
</div>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={1}
|
||||
onClick={focusMode ? () => {} : logout}
|
||||
className={clsx(
|
||||
"p-4 rounded-full flex gap-4 items-center cursor-pointer text-black hover:text-mti-rose transition duration-300 ease-in-out",
|
||||
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
||||
)}>
|
||||
<RiLogoutBoxFill size={24} />
|
||||
{!isMinimized && <span className="text-lg font-medium -xl:hidden">Log Out</span>}
|
||||
</div>
|
||||
</div>
|
||||
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
||||
</section>
|
||||
);
|
||||
<div className="fixed bottom-12 flex flex-col gap-0">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={1}
|
||||
onClick={toggleMinimize}
|
||||
className={clsx(
|
||||
"hover:text-mti-rose -xl:hidden flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
|
||||
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
||||
)}
|
||||
>
|
||||
{isMinimized ? (
|
||||
<BsChevronBarRight size={24} />
|
||||
) : (
|
||||
<BsChevronBarLeft size={24} />
|
||||
)}
|
||||
{!isMinimized && (
|
||||
<span className="text-lg font-medium">Minimize</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={1}
|
||||
onClick={focusMode ? () => {} : logout}
|
||||
className={clsx(
|
||||
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
|
||||
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
||||
)}
|
||||
>
|
||||
<RiLogoutBoxFill size={24} />
|
||||
{!isMinimized && (
|
||||
<span className="-xl:hidden text-lg font-medium">Log Out</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{focusMode && (
|
||||
<FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import axios from "axios";
|
||||
import {speakingReverseMarking} from "@/utils/score";
|
||||
import {Tab} from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import Modal from "../Modal";
|
||||
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
|
||||
|
||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||
|
||||
@@ -22,9 +24,10 @@ export default function InteractiveSpeaking({
|
||||
onBack,
|
||||
}: InteractiveSpeakingExercise & CommonProps) {
|
||||
const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
|
||||
const [diffNumber, setDiffNumber] = useState<0 | 1 | 2 | 3>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (userSolutions && userSolutions.length > 0) {
|
||||
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
|
||||
Promise.all(userSolutions[0].solution.map((x) => axios.post(`/api/speaking`, {path: x.answer}, {responseType: "arraybuffer"}))).then(
|
||||
(values) => {
|
||||
setSolutionsURL(
|
||||
@@ -42,6 +45,44 @@ export default function InteractiveSpeaking({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal title={`Correction (Prompt ${diffNumber})`} isOpen={diffNumber !== 0} onClose={() => setDiffNumber(0)}>
|
||||
<>
|
||||
{userSolutions &&
|
||||
userSolutions.length > 0 &&
|
||||
diffNumber !== 0 &&
|
||||
userSolutions[0].evaluation &&
|
||||
userSolutions[0].evaluation[`transcript_${diffNumber}`] &&
|
||||
userSolutions[0].evaluation[`fixed_text_${diffNumber}`] && (
|
||||
<div className="w-full h-full rounded-xl overflow-hidden flex flex-col mt-4">
|
||||
<div className="w-full grid grid-cols-2 bg-neutral-100">
|
||||
<span className="p-3 font-medium text-lg border-r border-r-neutral-200">Transcript</span>
|
||||
<span className="p-3 font-medium text-lg border-l border-l-neutral-200">Recommended Improvements</span>
|
||||
</div>
|
||||
<ReactDiffViewer
|
||||
styles={{
|
||||
contentText: {
|
||||
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
|
||||
padding: "32px 28px",
|
||||
},
|
||||
marker: {display: "none"},
|
||||
diffRemoved: {padding: "32px 28px"},
|
||||
diffAdded: {padding: "32px 28px"},
|
||||
|
||||
wordRemoved: {padding: "0px", display: "initial"},
|
||||
wordAdded: {padding: "0px", display: "initial"},
|
||||
wordDiff: {padding: "0px", display: "initial"},
|
||||
}}
|
||||
oldValue={userSolutions[0].evaluation[`transcript_${diffNumber}`]?.replaceAll("\\n", "\n")}
|
||||
newValue={userSolutions[0].evaluation[`fixed_text_${diffNumber}`]?.replaceAll("\\n", "\n")}
|
||||
splitView
|
||||
hideLineNumbers
|
||||
showDiffOnly={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</Modal>
|
||||
|
||||
<div className="flex flex-col h-full w-full gap-8 mb-20">
|
||||
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -67,10 +108,23 @@ export default function InteractiveSpeaking({
|
||||
{solutionsURL.map((x, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
||||
className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex flex-col gap-4">
|
||||
<div className="flex gap-8 items-center justify-center py-8">
|
||||
<Waveform audio={x} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||
</div>
|
||||
{userSolutions &&
|
||||
userSolutions.length > 0 &&
|
||||
userSolutions[0].evaluation &&
|
||||
userSolutions[0].evaluation[`transcript_${(index + 1) as 1 | 2 | 3}`] &&
|
||||
userSolutions[0].evaluation[`fixed_text_${(index + 1) as 1 | 2 | 3}`] && (
|
||||
<Button
|
||||
className="w-full max-w-[180px] !py-2 self-center"
|
||||
color="pink"
|
||||
variant="outline"
|
||||
onClick={() => setDiffNumber((index + 1) as 1 | 2 | 3)}>
|
||||
View Correction
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -185,7 +239,11 @@ export default function InteractiveSpeaking({
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: userSolutions,
|
||||
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
||||
score: {
|
||||
total: 100,
|
||||
missing: 0,
|
||||
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
|
||||
},
|
||||
type,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,14 +8,18 @@ import axios from "axios";
|
||||
import {speakingReverseMarking} from "@/utils/score";
|
||||
import {Tab} from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import Modal from "../Modal";
|
||||
import {BsQuestionCircleFill} from "react-icons/bs";
|
||||
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
|
||||
|
||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||
|
||||
export default function Speaking({id, type, title, video_url, text, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
|
||||
const [solutionURL, setSolutionURL] = useState<string>();
|
||||
const [showDiff, setShowDiff] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (userSolutions && userSolutions.length > 0) {
|
||||
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
|
||||
axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => {
|
||||
const blob = new Blob([data], {type: "audio/wav"});
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -27,6 +31,42 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal title="Correction" isOpen={showDiff} onClose={() => setShowDiff(false)}>
|
||||
<>
|
||||
{userSolutions &&
|
||||
userSolutions.length > 0 &&
|
||||
userSolutions[0].evaluation?.transcript_1 &&
|
||||
userSolutions[0].evaluation?.fixed_text_1 && (
|
||||
<div className="w-full h-full rounded-xl overflow-hidden flex flex-col mt-4">
|
||||
<div className="w-full grid grid-cols-2 bg-neutral-100">
|
||||
<span className="p-3 font-medium text-lg border-r border-r-neutral-200">Transcript</span>
|
||||
<span className="p-3 font-medium text-lg border-l border-l-neutral-200">Recommended Improvements</span>
|
||||
</div>
|
||||
<ReactDiffViewer
|
||||
styles={{
|
||||
contentText: {
|
||||
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
|
||||
padding: "32px 28px",
|
||||
},
|
||||
marker: {display: "none"},
|
||||
diffRemoved: {padding: "32px 28px"},
|
||||
diffAdded: {padding: "32px 28px"},
|
||||
|
||||
wordRemoved: {padding: "0px", display: "initial"},
|
||||
wordAdded: {padding: "0px", display: "initial"},
|
||||
wordDiff: {padding: "0px", display: "initial"},
|
||||
}}
|
||||
oldValue={userSolutions[0].evaluation.transcript_1.replaceAll("\\n", "\n")}
|
||||
newValue={userSolutions[0].evaluation.fixed_text_1.replaceAll("\\n", "\n")}
|
||||
splitView
|
||||
hideLineNumbers
|
||||
showDiffOnly={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</Modal>
|
||||
|
||||
<div className="flex flex-col h-full w-full gap-8 mb-20">
|
||||
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -65,10 +105,19 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full h-full flex flex-col gap-8">
|
||||
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
||||
<div className="w-full h-full flex flex-col gap-8 relative">
|
||||
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center relative">
|
||||
<div className="flex gap-8 items-center justify-center py-8">
|
||||
{solutionURL && <Waveform audio={solutionURL} waveColor="#FCDDEC" progressColor="#EF5DA8" />}
|
||||
|
||||
{userSolutions &&
|
||||
userSolutions.length > 0 &&
|
||||
userSolutions[0].evaluation?.transcript_1 &&
|
||||
userSolutions[0].evaluation?.fixed_text_1 && (
|
||||
<Button className="w-full max-w-[180px] !py-2" color="pink" variant="outline" onClick={() => setShowDiff(true)}>
|
||||
View Correction
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||
@@ -152,7 +201,11 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: userSolutions,
|
||||
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
||||
score: {
|
||||
total: 100,
|
||||
missing: 0,
|
||||
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
|
||||
},
|
||||
type,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{showDiff && (
|
||||
{showDiff && userSolutions[0].evaluation && (
|
||||
<>
|
||||
<span>Correction:</span>
|
||||
<div className="w-full h-full max-h-[320px] overflow-y-scroll scrollbar-hide cursor-text border-2 overflow-x-hidden border-mti-gray-platinum bg-white rounded-3xl">
|
||||
@@ -191,7 +191,11 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: userSolutions,
|
||||
score: {total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
||||
score: {
|
||||
total: 100,
|
||||
missing: 0,
|
||||
correct: userSolutions[0]?.evaluation ? writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
|
||||
},
|
||||
type,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -243,7 +243,9 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
options={CURRENCIES_OPTIONS}
|
||||
value={CURRENCIES_OPTIONS.find((c) => c.value === paymentCurrency)}
|
||||
onChange={(value) => setPaymentCurrency(value?.value)}
|
||||
menuPortalTarget={document?.body}
|
||||
styles={{
|
||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
@@ -282,8 +284,10 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
value: referralAgent,
|
||||
label: referralAgentLabel,
|
||||
}}
|
||||
menuPortalTarget={document?.body}
|
||||
onChange={(value) => setReferralAgent(value?.value)}
|
||||
styles={{
|
||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
@@ -314,7 +318,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
type="number"
|
||||
defaultValue={commissionValue || 0}
|
||||
className="col-span-3"
|
||||
disabled={disabled}
|
||||
disabled={disabled || loggedInUser.type === "agent"}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@@ -520,6 +524,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
<Select
|
||||
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||
options={USER_STATUS_OPTIONS}
|
||||
menuPortalTarget={document?.body}
|
||||
value={USER_STATUS_OPTIONS.find((o) => o.value === status)}
|
||||
onChange={(value) => setStatus(value?.value as typeof user.status)}
|
||||
styles={{
|
||||
@@ -532,6 +537,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
@@ -546,6 +552,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
<Select
|
||||
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||
options={USER_TYPE_OPTIONS}
|
||||
menuPortalTarget={document?.body}
|
||||
value={USER_TYPE_OPTIONS.find((o) => o.value === type)}
|
||||
onChange={(value) => setType(value?.value as typeof user.type)}
|
||||
styles={{
|
||||
@@ -558,6 +565,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
@@ -574,17 +582,17 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
|
||||
<div className="flex gap-4 justify-between mt-4 w-full">
|
||||
<div className="self-start flex gap-4 justify-start items-center w-full">
|
||||
{onViewCorporate && (
|
||||
{onViewCorporate && ["student", "teacher"].includes(user.type) && (
|
||||
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewCorporate}>
|
||||
View Corporate
|
||||
</Button>
|
||||
)}
|
||||
{onViewStudents && (
|
||||
{onViewStudents && ["corporate", "teacher"].includes(user.type) && (
|
||||
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewStudents}>
|
||||
View Students
|
||||
</Button>
|
||||
)}
|
||||
{onViewTeachers && (
|
||||
{onViewTeachers && ["student", "corporate"].includes(user.type) && (
|
||||
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewTeachers}>
|
||||
View Teachers
|
||||
</Button>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"type": "service_account",
|
||||
"project_id": "mti-ielts",
|
||||
"private_key_id": "22b783a14c760d1215a8d1f5de0fa40a33a840e7",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDoNkd7s/izUBRb\nlmJYWl0xk4X9wEVJU4LKA4HPeha8RFDse4T4suVP08oCP9ODSXF5A83+IqXNMs/N\na7PtFABBAx433JrB7I4NsAUrDSjI4LeYEIqh6YzHsQvBU53HAmPChX525S4i0IBy\ncNnyXut0nmlHz5ZwCPXgqg4eN44C+m0f7sxzivcnPth/zLupnMiDAHFZrxQolWO2\n6JfozMWGw0TmCkUxngzeGBMVYmsGiKRIxEi3MWeuwjYjGO4nR1krEUlcpjCbx4UX\nxYXicJb17HOs9LTcSh9bpDWZPHKXR48hxd2cMLr+XQzw7Otwu2p8fEUOJ+CiTyNz\nlkN9p7OhAgMBAAECggEAB5DsMZdGu1X4wdazr+AK4RCG2UKkZ0wbqvgkCMX4O2xo\n7BmmtqFCmEAk+P+KJWEVW81wTu9jUl0tWOrBVzBThUrEF2seVkL+SmshsfpI6cmr\npb5lO/sTgZau1L7kGU3GQRpvKVHUl+EODFyJt2xZFOjL8qFsjAw4sbgsw1aJT6a4\nFilm6Gapi1qSKOPSlXVmi0NJ9DUtNbKaQK8/coqEJRizeXs9MORvzyKQaV8PBmWI\noEnkxahKOD48U2kmI7rT9/YsCuaP2BlGdLxvANXLjAKcrDccVZkYEH82tPtCicED\noow3i956HPdWSXQgUOU65MfGccjOmqGaGa4zUTICyQKBgQD6zLMwL9YS+n9EKZaK\nEbzRybN2d+eKbXyDJzkDi6FnSGVre2ndShsimoOtwZDLmOF/XhN79YOLJVbI124p\npAWO+WxAfe9Xy3iFEBmL4kSREA873Sd8EN5OfYS2DsN7IbjZkoaLuM8QlyXL9ZRS\nBJDVGjx+wFKRjnClcBNbVMMXiQKBgQDtBumKZS0ZCtJuBeuwLGJ1ZJtYECykIrsD\nUtQ7zxwXJzPGqZ2c5JLpHdDm/bb9nllpLsh4SpDRqxFa2H2FF8x5KWaS7JQUsS8e\ner6x5wUt6wAJqV/ZvttVrLZCa8VYn+K7bTANnkPNJZHTqBTJbxkXMDTtkwWXUN2z\nQP3N9lodWQKBgFBHiewYw9ubV3WIImnbt6cne0ymoPUMitioi3V5Epcu81fuTzrI\nZ9sxvoi19xVUwIm2oWICerLlptvvKZImsKjNajtSlHRz6wYc2zCNowkULOwqpGLw\nO1jAkOR94VDewH7UikDbTVywJSceWvXOBFZSaZ7hDQ0OnTw3ndqUTUaRAoGAd2BG\n2PPyDa28o7sJpBYGlJdSAb1LrnLre1YJHAJIZITS99hPUEhykUP6BYx80CkjYO01\n/BeZ7m9Y80cbmJ+O1Or8BT1vqyg90f0B8/mlSyYTQ8pxQupz7ydoN/WtU+BawgjQ\n7drqzPSCCHab2YPBwEMANTMZ2sbYkcJG0aekZSkCgYBbnFJm8kUy57isxHyvrci+\nR30KQl2Y9okPytF8PpLH+yNjLDoduTOHL/hZoFC0M4Gklx4wPKpsEhImIrWmG9VC\n0UrQC6TT1WoY6/S3YehVmTXo/nBPD1XTUcbF/xxUrWDjmMjnt1IlXBbIzUPD3U4P\niRXzHnXb7yi+/iRxSDts2w==\n-----END PRIVATE KEY-----\n",
|
||||
"client_email": "firebase-adminsdk-dyg6p@mti-ielts.iam.gserviceaccount.com",
|
||||
"client_id": "104980563453519094431",
|
||||
"project_id": "storied-phalanx-349916",
|
||||
"private_key_id": "c9e05f6fe413b1031a71f981160075ff4b044444",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDdgavFB63nMHyb\n38ncwijTrUmqU9UyzNJ8wlZCWAWuoz25Gng988fkKNDXnHY+ap9esHyNYg9IdSA7\nAuZeHpzTZmKiWZzFWq61KWSTgIn1JwKHGHJJdmVhTYfCe9I51cFLa5q2lTFzJ0ce\nbP7/X/7kw53odgva+M8AhDTbe60akpemgZc+LFwO0Abm7erH2HiNyjoNZzNw525L\n933PCaQwhZan04s1u0oRdVlBIBwMk+J0ojgVEpUiJOzF7gkN+UpDXujalLYdlR4q\nhkGgScXQhDYJkECC3GuvOnEo1YXGNjW9D73S6sSH+Lvqta4wW1+sTn0kB6goiQBI\n7cA1G6x3AgMBAAECggEAZPMwAX/adb7XS4LWUNH8IVyccg/63kgSteErxtiu3kRv\nYOj7W+C6fPVNGLap/RBCybjNSvIh3PfkVICh1MtG1eGXmj4VAKyvaskOmVq/hQbe\nVAuEKo7W7V2UPcKIsOsGSQUlYYjlHIIOG4O5Q1HQrRmp4cPK62Txkl6uaEkZPz4u\nbvIK2BJI8aHRwxE3Phw09blwlLqQQQ8nrhK29x5puaN+ft++IlzIOVsLz+n4kTdB\n6qkG/dhenn3K8o3+NkmSN6eNRbdJd36zXTo4Oatbvqb7r0E8vYn/3Llawo2X75zn\nec7jMHrOmcwtiu9H3PsrTWtzdSjxPHy0UtEn1HWK4QKBgQD+c/V8tAvbaUGVoZf6\ntKtDSKF6IHuY2vUO33v950mVdjrTursqOG2d+SLfSnKpc+sjDlj7/S5u4uRP+qUN\ng1rb2U7oIA7tsDa2ZTSkIx6HkPUzS+fBOxELLrbgMoJ2RLzgkiPhS95YgXJ/rYG5\nWQTehzCT5roes0RvtgM0gl3EhQKBgQDe2m7PRIU4g3RJ8HTx92B4ja8W9FVCYDG5\nPOAdZB8WB6Bvu4BJHBDLr8vDi930pKj+vYObRqBDQuILW4t8wZQJ834dnoq6EpUz\nhbVEURVBP4A/nEHrQHfq0Lp+cxThy2rw7obRQOLPETtC7p3WFgSHT6PRTcpGzCCX\n+76a30yrywKBgC/5JNtyBppDaf4QDVtTHMb+tpMT9LmI7pLzR6lDJfhr5gNtPURk\nhyY1hoGaw6t3E2n0lopL3alCVdFObDfz//lbKylQggAGLQqOYjJf/K2KgvA862Df\nBgOZtxjl7PrnUsT0SJd9elotbazsxXxwcB6UVnBMG+MV4V0+b7RCr/MRAoGBAIfp\nTcVIs7roqOZjKN9dEE/VkR/9uXW2tvyS/NfP9Ql5c0ZRYwazgCbJOwsyZRZLyek6\naWYsp5b91mA435QhdwiuoI6t30tmA+qdNBTLIpxdfvjMcoNoGPpzfBmcU/L1HW58\n+mnqGalRiAPlBQvI99ASKQWAXMnaulIWrYNEhj0LAoGBALi+QZ2pp+hDeC59ezWr\nbP1zbbONceHKGgJcevChP2k1OJyIOIqmBYeTuM4cPc5ofZYQNaMC31cs8SVeSRX1\nNTxQZmvCjMyTe/WYWYNFXdgkVz4egFXbeochCGzMYo57HV1PCkPBrARRZO8OfdDD\n8sDu//ohb7nCzceEI0DnWs13\n-----END PRIVATE KEY-----\n",
|
||||
"client_email": "firebase-adminsdk-3ml0u@storied-phalanx-349916.iam.gserviceaccount.com",
|
||||
"client_id": "114163760341944984396",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-dyg6p%40mti-ielts.iam.gserviceaccount.com",
|
||||
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-3ml0u%40storied-phalanx-349916.iam.gserviceaccount.com",
|
||||
"universe_domain": "googleapis.com"
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export const PERMISSIONS = {
|
||||
developer: ["developer"],
|
||||
},
|
||||
deleteUser: {
|
||||
student: ["teacher", "corporate", "developer", "admin"],
|
||||
student: ["corporate", "developer", "admin"],
|
||||
teacher: ["corporate", "developer", "admin"],
|
||||
corporate: ["admin", "developer"],
|
||||
admin: ["developer", "admin"],
|
||||
@@ -18,8 +18,8 @@ export const PERMISSIONS = {
|
||||
developer: ["developer"],
|
||||
},
|
||||
updateUser: {
|
||||
student: ["teacher", "corporate", "developer", "admin"],
|
||||
teacher: ["corporate", "developer", "admin"],
|
||||
student: ["developer", "admin"],
|
||||
teacher: ["developer", "admin"],
|
||||
corporate: ["admin", "developer"],
|
||||
admin: ["developer", "admin"],
|
||||
agent: ["developer", "admin"],
|
||||
|
||||
@@ -7,13 +7,22 @@ import UserList from "@/pages/(admin)/Lists/UserList";
|
||||
import {dateSorter} from "@/utils";
|
||||
import moment from "moment";
|
||||
import {useEffect, useState} from "react";
|
||||
import {BsArrowLeft, BsBriefcaseFill, BsGlobeCentralSouthAsia, BsPerson, BsPersonFill, BsPencilSquare, BsBank, BsCurrencyDollar} from "react-icons/bs";
|
||||
import {
|
||||
BsArrowLeft,
|
||||
BsBriefcaseFill,
|
||||
BsGlobeCentralSouthAsia,
|
||||
BsPerson,
|
||||
BsPersonFill,
|
||||
BsPencilSquare,
|
||||
BsBank,
|
||||
BsCurrencyDollar,
|
||||
} from "react-icons/bs";
|
||||
import UserCard from "@/components/UserCard";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import IconCard from "./IconCard";
|
||||
import useFilterStore from "@/stores/listFilterStore";
|
||||
import {useRouter} from "next/router";
|
||||
import usePaymentStatusUsers from '@/hooks/usePaymentStatusUsers';
|
||||
import usePaymentStatusUsers from "@/hooks/usePaymentStatusUsers";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
@@ -27,7 +36,7 @@ export default function AdminDashboard({user}: Props) {
|
||||
const {stats} = useStats(user.id);
|
||||
const {users, reload} = useUsers();
|
||||
const {groups} = useGroups();
|
||||
const { pending, done } = usePaymentStatusUsers();
|
||||
const {pending, done} = usePaymentStatusUsers();
|
||||
|
||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||
const router = useRouter();
|
||||
@@ -148,7 +157,7 @@ export default function AdminDashboard({user}: Props) {
|
||||
</>
|
||||
);
|
||||
|
||||
const CorporatePaidStatusList = ({ paid }: {paid: Boolean}) => {
|
||||
const CorporatePaidStatusList = ({paid}: {paid: Boolean}) => {
|
||||
const list = paid ? done : pending;
|
||||
const filter = (x: User) => x.type === "corporate" && list.includes(x.id);
|
||||
|
||||
@@ -161,7 +170,9 @@ export default function AdminDashboard({user}: Props) {
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">{paid ? 'Payment Done' : 'Pending Payment'} ({list.length})</h2>
|
||||
<h2 className="text-2xl font-semibold">
|
||||
{paid ? "Payment Done" : "Pending Payment"} ({list.length})
|
||||
</h2>
|
||||
</div>
|
||||
<UserList user={user} filters={[filter]} />
|
||||
</>
|
||||
@@ -290,13 +301,7 @@ export default function AdminDashboard({user}: Props) {
|
||||
}
|
||||
color="rose"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("paymentdone")}
|
||||
Icon={BsCurrencyDollar}
|
||||
label="Payment Done"
|
||||
value={done.length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard onClick={() => setPage("paymentdone")} Icon={BsCurrencyDollar} label="Payment Done" value={done.length} color="purple" />
|
||||
<IconCard
|
||||
onClick={() => setPage("paymentpending")}
|
||||
Icon={BsCurrencyDollar}
|
||||
@@ -323,7 +328,9 @@ export default function AdminDashboard({user}: Props) {
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter((x) => x.type === "corporate")
|
||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||
.sort((a, b) => {
|
||||
return dateSorter(a, b, "desc", "registrationDate");
|
||||
})
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
|
||||
@@ -1,79 +1,115 @@
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {Module} from "@/interfaces";
|
||||
import {Assignment} from "@/interfaces/results";
|
||||
import {calculateBandScore} from "@/utils/score";
|
||||
import { Module } from "@/interfaces";
|
||||
import { Assignment } from "@/interfaces/results";
|
||||
import { calculateBandScore } from "@/utils/score";
|
||||
import clsx from "clsx";
|
||||
import moment from "moment";
|
||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||
import {
|
||||
BsBook,
|
||||
BsClipboard,
|
||||
BsHeadphones,
|
||||
BsMegaphone,
|
||||
BsPen,
|
||||
} from "react-icons/bs";
|
||||
import { usePDFDownload } from "@/hooks/usePDFDownload";
|
||||
import { uniqBy } from "lodash";
|
||||
|
||||
interface Props {
|
||||
onClick?: () => void;
|
||||
allowDownload?: boolean;
|
||||
onClick?: () => void;
|
||||
allowDownload?: boolean;
|
||||
}
|
||||
|
||||
export default function AssignmentCard({id, name, assigner, startDate, endDate, assignees, results, exams, onClick, allowDownload}: Assignment & Props) {
|
||||
const {users} = useUsers();
|
||||
const renderPdfIcon = usePDFDownload("assignments");
|
||||
export default function AssignmentCard({
|
||||
id,
|
||||
name,
|
||||
assigner,
|
||||
startDate,
|
||||
endDate,
|
||||
assignees,
|
||||
results,
|
||||
exams,
|
||||
onClick,
|
||||
allowDownload,
|
||||
}: Assignment & Props) {
|
||||
const { users } = useUsers();
|
||||
const renderPdfIcon = usePDFDownload("assignments");
|
||||
|
||||
const calculateAverageModuleScore = (module: Module) => {
|
||||
const resultModuleBandScores = results.map((r) => {
|
||||
const moduleStats = r.stats.filter((s) => s.module === module);
|
||||
const calculateAverageModuleScore = (module: Module) => {
|
||||
const resultModuleBandScores = results.map((r) => {
|
||||
const moduleStats = r.stats.filter((s) => s.module === module);
|
||||
|
||||
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
|
||||
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
|
||||
return calculateBandScore(correct, total, module, r.type);
|
||||
});
|
||||
const correct = moduleStats.reduce(
|
||||
(acc, curr) => acc + curr.score.correct,
|
||||
0,
|
||||
);
|
||||
const total = moduleStats.reduce(
|
||||
(acc, curr) => acc + curr.score.total,
|
||||
0,
|
||||
);
|
||||
return calculateBandScore(correct, total, module, r.type);
|
||||
});
|
||||
|
||||
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / results.length;
|
||||
};
|
||||
return resultModuleBandScores.length === 0
|
||||
? -1
|
||||
: resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
|
||||
results.length;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="w-[350px] h-fit flex flex-col gap-6 bg-white border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-row justify-between">
|
||||
<h3 className="font-semibold text-xl">{name}</h3>
|
||||
{allowDownload && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
|
||||
</div>
|
||||
<ProgressBar
|
||||
color={results.length / assignees.length < 0.5 ? "red" : "purple"}
|
||||
percentage={(results.length / assignees.length) * 100}
|
||||
label={`${results.length}/${assignees.length}`}
|
||||
className="h-5"
|
||||
textClassName={results.length / assignees.length < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"}
|
||||
/>
|
||||
</div>
|
||||
<span className="flex gap-1 justify-between">
|
||||
<span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
<span>-</span>
|
||||
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
</span>
|
||||
<div className="grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2">
|
||||
{exams.map(({module}) => (
|
||||
<div
|
||||
key={module}
|
||||
className={clsx(
|
||||
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl",
|
||||
module === "reading" && "bg-ielts-reading",
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}>
|
||||
{module === "reading" && <BsBook className="w-4 h-4" />}
|
||||
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
||||
{module === "writing" && <BsPen className="w-4 h-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
||||
{module === "level" && <BsClipboard className="w-4 h-4" />}
|
||||
{calculateAverageModuleScore(module) > -1 && (
|
||||
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="border-mti-gray-platinum flex h-fit w-[350px] cursor-pointer flex-col gap-6 rounded-xl border bg-white p-4 transition duration-300 ease-in-out hover:drop-shadow"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-row justify-between">
|
||||
<h3 className="text-xl font-semibold">{name}</h3>
|
||||
{allowDownload &&
|
||||
renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
|
||||
</div>
|
||||
<ProgressBar
|
||||
color={results.length / assignees.length < 0.5 ? "red" : "purple"}
|
||||
percentage={(results.length / assignees.length) * 100}
|
||||
label={`${results.length}/${assignees.length}`}
|
||||
className="h-5"
|
||||
textClassName={
|
||||
results.length / assignees.length < 0.5
|
||||
? "!text-mti-gray-dim font-light"
|
||||
: "text-white"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<span className="flex justify-between gap-1">
|
||||
<span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
<span>-</span>
|
||||
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
</span>
|
||||
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
||||
{uniqBy(exams, (x) => x.module).map(({ module }) => (
|
||||
<div
|
||||
key={module}
|
||||
className={clsx(
|
||||
"-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||
module === "reading" && "bg-ielts-reading",
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}
|
||||
>
|
||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||
{calculateAverageModuleScore(module) > -1 && (
|
||||
<span className="text-sm">
|
||||
{calculateAverageModuleScore(module).toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ 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";
|
||||
|
||||
interface Props {
|
||||
isCreating: boolean;
|
||||
@@ -34,12 +35,11 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
const [assignees, setAssignees] = useState<string[]>(assignment?.assignees || []);
|
||||
const [name, setName] = useState(assignment?.name || generate({minLength: 6, maxLength: 8, min: 2, max: 3, join: " ", formatter: capitalize}));
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [startDate, setStartDate] = useState<Date | null>(
|
||||
assignment ? moment(assignment.startDate).toDate() : moment().hours(0).minutes(0).add(1, "day").toDate(),
|
||||
);
|
||||
const [startDate, setStartDate] = useState<Date | null>(assignment ? moment(assignment.startDate).toDate() : new Date());
|
||||
const [endDate, setEndDate] = useState<Date | null>(
|
||||
assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(),
|
||||
);
|
||||
const [variant, setVariant] = useState<Variant>("full");
|
||||
// creates a new exam for each assignee or just one exam for all assignees
|
||||
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
|
||||
|
||||
@@ -62,6 +62,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
endDate,
|
||||
selectedModules,
|
||||
generateMultiple,
|
||||
variant,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`);
|
||||
@@ -200,7 +201,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
"transition duration-300 ease-in-out",
|
||||
)}
|
||||
popperClassName="!z-20"
|
||||
filterDate={(date) => moment(date).isAfter(new Date())}
|
||||
filterTime={(date) => moment(date).isSameOrAfter(new Date())}
|
||||
dateFormat="dd/MM/yyyy HH:mm"
|
||||
selected={startDate}
|
||||
showTimeSelect
|
||||
@@ -216,7 +217,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
"transition duration-300 ease-in-out",
|
||||
)}
|
||||
popperClassName="!z-20"
|
||||
filterDate={(date) => moment(date).isAfter(startDate)}
|
||||
filterTime={(date) => moment(date).isAfter(startDate)}
|
||||
dateFormat="dd/MM/yyyy HH:mm"
|
||||
selected={endDate}
|
||||
showTimeSelect
|
||||
@@ -281,7 +282,10 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<div className="flex gap-4 w-full justify-end">
|
||||
<div className="flex flex-col gap-4 w-full items-end">
|
||||
<Checkbox isChecked={variant === "full"} onChange={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
|
||||
Full length exams
|
||||
</Checkbox>
|
||||
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}>
|
||||
Generate different exams
|
||||
</Checkbox>
|
||||
|
||||
@@ -1,280 +1,354 @@
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import Modal from "@/components/Modal";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {Module} from "@/interfaces";
|
||||
import {Assignment} from "@/interfaces/results";
|
||||
import {Stat, User} from "@/interfaces/user";
|
||||
import { Module } from "@/interfaces";
|
||||
import { Assignment } from "@/interfaces/results";
|
||||
import { Stat, User } from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {sortByModule} from "@/utils/moduleUtils";
|
||||
import {calculateBandScore} from "@/utils/score";
|
||||
import {convertToUserSolutions} from "@/utils/stats";
|
||||
import { getExamById } from "@/utils/exams";
|
||||
import { sortByModule } from "@/utils/moduleUtils";
|
||||
import { calculateBandScore } from "@/utils/score";
|
||||
import { convertToUserSolutions } from "@/utils/stats";
|
||||
import clsx from "clsx";
|
||||
import {capitalize, uniqBy} from "lodash";
|
||||
import { capitalize, uniqBy } from "lodash";
|
||||
import moment from "moment";
|
||||
import {useRouter} from "next/router";
|
||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
BsBook,
|
||||
BsClipboard,
|
||||
BsHeadphones,
|
||||
BsMegaphone,
|
||||
BsPen,
|
||||
} from "react-icons/bs";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
assignment?: Assignment;
|
||||
onClose: () => void;
|
||||
isOpen: boolean;
|
||||
assignment?: Assignment;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
||||
const {users} = useUsers();
|
||||
const router = useRouter();
|
||||
export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
||||
const { users } = useUsers();
|
||||
const router = useRouter();
|
||||
|
||||
const setExams = useExamStore((state) => state.setExams);
|
||||
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
|
||||
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||
const setExams = useExamStore((state) => state.setExams);
|
||||
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
|
||||
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
const date = moment(parseInt(timestamp));
|
||||
const formatter = "YYYY/MM/DD - HH:mm";
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
const date = moment(parseInt(timestamp));
|
||||
const formatter = "YYYY/MM/DD - HH:mm";
|
||||
|
||||
return date.format(formatter);
|
||||
};
|
||||
return date.format(formatter);
|
||||
};
|
||||
|
||||
const calculateAverageModuleScore = (module: Module) => {
|
||||
if (!assignment) return -1;
|
||||
const calculateAverageModuleScore = (module: Module) => {
|
||||
if (!assignment) return -1;
|
||||
|
||||
const resultModuleBandScores = assignment.results.map((r) => {
|
||||
const moduleStats = r.stats.filter((s) => s.module === module);
|
||||
const resultModuleBandScores = assignment.results.map((r) => {
|
||||
const moduleStats = r.stats.filter((s) => s.module === module);
|
||||
|
||||
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
|
||||
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
|
||||
return calculateBandScore(correct, total, module, r.type);
|
||||
});
|
||||
const correct = moduleStats.reduce(
|
||||
(acc, curr) => acc + curr.score.correct,
|
||||
0,
|
||||
);
|
||||
const total = moduleStats.reduce(
|
||||
(acc, curr) => acc + curr.score.total,
|
||||
0,
|
||||
);
|
||||
return calculateBandScore(correct, total, module, r.type);
|
||||
});
|
||||
|
||||
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length;
|
||||
};
|
||||
return resultModuleBandScores.length === 0
|
||||
? -1
|
||||
: resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
|
||||
assignment.results.length;
|
||||
};
|
||||
|
||||
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => {
|
||||
const scores: {[key in Module]: {total: number; missing: number; correct: number}} = {
|
||||
reading: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
listening: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
writing: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
speaking: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
level: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
};
|
||||
const aggregateScoresByModule = (
|
||||
stats: Stat[],
|
||||
): { module: Module; total: number; missing: number; correct: number }[] => {
|
||||
const scores: {
|
||||
[key in Module]: { total: number; missing: number; correct: number };
|
||||
} = {
|
||||
reading: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
listening: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
writing: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
speaking: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
level: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
};
|
||||
|
||||
stats.forEach((x) => {
|
||||
scores[x.module!] = {
|
||||
total: scores[x.module!].total + x.score.total,
|
||||
correct: scores[x.module!].correct + x.score.correct,
|
||||
missing: scores[x.module!].missing + x.score.missing,
|
||||
};
|
||||
});
|
||||
stats.forEach((x) => {
|
||||
scores[x.module!] = {
|
||||
total: scores[x.module!].total + x.score.total,
|
||||
correct: scores[x.module!].correct + x.score.correct,
|
||||
missing: scores[x.module!].missing + x.score.missing,
|
||||
};
|
||||
});
|
||||
|
||||
return Object.keys(scores)
|
||||
.filter((x) => scores[x as Module].total > 0)
|
||||
.map((x) => ({module: x as Module, ...scores[x as Module]}));
|
||||
};
|
||||
return Object.keys(scores)
|
||||
.filter((x) => scores[x as Module].total > 0)
|
||||
.map((x) => ({ module: x as Module, ...scores[x as Module] }));
|
||||
};
|
||||
|
||||
const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => {
|
||||
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
|
||||
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
|
||||
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
|
||||
const customContent = (
|
||||
stats: Stat[],
|
||||
user: string,
|
||||
focus: "academic" | "general",
|
||||
) => {
|
||||
const correct = stats.reduce(
|
||||
(accumulator, current) => accumulator + current.score.correct,
|
||||
0,
|
||||
);
|
||||
const total = stats.reduce(
|
||||
(accumulator, current) => accumulator + current.score.total,
|
||||
0,
|
||||
);
|
||||
const aggregatedScores = aggregateScoresByModule(stats).filter(
|
||||
(x) => x.total > 0,
|
||||
);
|
||||
|
||||
const aggregatedLevels = aggregatedScores.map((x) => ({
|
||||
module: x.module,
|
||||
level: calculateBandScore(x.correct, x.total, x.module, focus),
|
||||
}));
|
||||
const aggregatedLevels = aggregatedScores.map((x) => ({
|
||||
module: x.module,
|
||||
level: calculateBandScore(x.correct, x.total, x.module, focus),
|
||||
}));
|
||||
|
||||
const timeSpent = stats[0].timeSpent;
|
||||
const timeSpent = stats[0].timeSpent;
|
||||
|
||||
const selectExam = () => {
|
||||
const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam));
|
||||
const selectExam = () => {
|
||||
const examPromises = uniqBy(stats, "exam").map((stat) =>
|
||||
getExamById(stat.module, stat.exam),
|
||||
);
|
||||
|
||||
Promise.all(examPromises).then((exams) => {
|
||||
if (exams.every((x) => !!x)) {
|
||||
setUserSolutions(convertToUserSolutions(stats));
|
||||
setShowSolutions(true);
|
||||
setExams(exams.map((x) => x!).sort(sortByModule));
|
||||
setSelectedModules(
|
||||
exams
|
||||
.map((x) => x!)
|
||||
.sort(sortByModule)
|
||||
.map((x) => x!.module),
|
||||
);
|
||||
router.push("/exercises");
|
||||
}
|
||||
});
|
||||
};
|
||||
Promise.all(examPromises).then((exams) => {
|
||||
if (exams.every((x) => !!x)) {
|
||||
setUserSolutions(convertToUserSolutions(stats));
|
||||
setShowSolutions(true);
|
||||
setExams(exams.map((x) => x!).sort(sortByModule));
|
||||
setSelectedModules(
|
||||
exams
|
||||
.map((x) => x!)
|
||||
.sort(sortByModule)
|
||||
.map((x) => x!.module),
|
||||
);
|
||||
router.push("/exercises");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<div className="w-full flex justify-between -md:items-center 2xl:items-center">
|
||||
<div className="flex md:flex-col 2xl:flex-row md:gap-1 -md:gap-2 2xl:gap-2 -md:items-center 2xl:items-center">
|
||||
<span className="font-medium">{formatTimestamp(stats[0].date.toString())}</span>
|
||||
{timeSpent && (
|
||||
<>
|
||||
<span className="md:hidden 2xl:flex">• </span>
|
||||
<span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={clsx(
|
||||
correct / total >= 0.7 && "text-mti-purple",
|
||||
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
||||
correct / total < 0.3 && "text-mti-rose",
|
||||
)}>
|
||||
Level{" "}
|
||||
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
const content = (
|
||||
<>
|
||||
<div className="-md:items-center flex w-full justify-between 2xl:items-center">
|
||||
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
|
||||
<span className="font-medium">
|
||||
{formatTimestamp(stats[0].date.toString())}
|
||||
</span>
|
||||
{timeSpent && (
|
||||
<>
|
||||
<span className="md:hidden 2xl:flex">• </span>
|
||||
<span className="text-sm">
|
||||
{Math.floor(timeSpent / 60)} minutes
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={clsx(
|
||||
correct / total >= 0.7 && "text-mti-purple",
|
||||
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
||||
correct / total < 0.3 && "text-mti-rose",
|
||||
)}
|
||||
>
|
||||
Level{" "}
|
||||
{(
|
||||
aggregatedLevels.reduce(
|
||||
(accumulator, current) => accumulator + current.level,
|
||||
0,
|
||||
) / aggregatedLevels.length
|
||||
).toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col gap-1">
|
||||
<div className="grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2">
|
||||
{aggregatedLevels.map(({module, level}) => (
|
||||
<div
|
||||
key={module}
|
||||
className={clsx(
|
||||
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl",
|
||||
module === "reading" && "bg-ielts-reading",
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}>
|
||||
{module === "reading" && <BsBook className="w-4 h-4" />}
|
||||
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
||||
{module === "writing" && <BsPen className="w-4 h-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
||||
{module === "level" && <BsClipboard className="w-4 h-4" />}
|
||||
<span className="text-sm">{level.toFixed(1)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
||||
{aggregatedLevels.map(({ module, level }) => (
|
||||
<div
|
||||
key={module}
|
||||
className={clsx(
|
||||
"-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||
module === "reading" && "bg-ielts-reading",
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}
|
||||
>
|
||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||
<span className="text-sm">{level.toFixed(1)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span>
|
||||
{(() => {
|
||||
const student = users.find((u) => u.id === user);
|
||||
return `${student?.name} (${student?.email})`;
|
||||
})()}
|
||||
</span>
|
||||
<div
|
||||
key={user}
|
||||
className={clsx(
|
||||
"flex flex-col gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:hidden",
|
||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
||||
correct / total < 0.3 && "hover:border-mti-rose",
|
||||
)}
|
||||
onClick={selectExam}
|
||||
role="button">
|
||||
{content}
|
||||
</div>
|
||||
<div
|
||||
key={user}
|
||||
className={clsx(
|
||||
"flex flex-col gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:tooltip md:hidden",
|
||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
||||
correct / total < 0.3 && "hover:border-mti-rose",
|
||||
)}
|
||||
data-tip="Your screen size is too small to view previous exams."
|
||||
role="button">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span>
|
||||
{(() => {
|
||||
const student = users.find((u) => u.id === user);
|
||||
return `${student?.name} (${student?.email})`;
|
||||
})()}
|
||||
</span>
|
||||
<div
|
||||
key={user}
|
||||
className={clsx(
|
||||
"border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
|
||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||
correct / total >= 0.3 &&
|
||||
correct / total < 0.7 &&
|
||||
"hover:border-mti-red",
|
||||
correct / total < 0.3 && "hover:border-mti-rose",
|
||||
)}
|
||||
onClick={selectExam}
|
||||
role="button"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
<div
|
||||
key={user}
|
||||
className={clsx(
|
||||
"border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden",
|
||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||
correct / total >= 0.3 &&
|
||||
correct / total < 0.7 &&
|
||||
"hover:border-mti-red",
|
||||
correct / total < 0.3 && "hover:border-mti-rose",
|
||||
)}
|
||||
data-tip="Your screen size is too small to view previous exams."
|
||||
role="button"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}>
|
||||
<div className="mt-4 flex flex-col w-full gap-4">
|
||||
<ProgressBar
|
||||
color="purple"
|
||||
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
|
||||
className="h-6"
|
||||
textClassName={
|
||||
(assignment?.results.length || 0) / (assignment?.assignees.length || 1) < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"
|
||||
}
|
||||
percentage={((assignment?.results.length || 0) / (assignment?.assignees.length || 1)) * 100}
|
||||
/>
|
||||
<div className="flex gap-8 items-start">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
<span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
</div>
|
||||
<span>
|
||||
Assignees:{" "}
|
||||
{users
|
||||
.filter((u) => assignment?.assignees.includes(u.id))
|
||||
.map((u) => `${u.name} (${u.email})`)
|
||||
.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xl font-bold">Average Scores</span>
|
||||
<div className="grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2">
|
||||
{assignment?.exams.map(({module}) => (
|
||||
<div
|
||||
data-tip={capitalize(module)}
|
||||
key={module}
|
||||
className={clsx(
|
||||
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl tooltip",
|
||||
module === "reading" && "bg-ielts-reading",
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}>
|
||||
{module === "reading" && <BsBook className="w-4 h-4" />}
|
||||
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
||||
{module === "writing" && <BsPen className="w-4 h-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
||||
{module === "level" && <BsClipboard className="w-4 h-4" />}
|
||||
{calculateAverageModuleScore(module) > -1 && (
|
||||
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xl font-bold">
|
||||
Results ({assignment?.results.length}/{assignment?.assignees.length})
|
||||
</span>
|
||||
<div>
|
||||
{assignment && assignment?.results.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 w-full gap-4 xl:gap-6">
|
||||
{assignment.results.map((r) => customContent(r.stats, r.user, r.type))}
|
||||
</div>
|
||||
)}
|
||||
{assignment && assignment?.results.length === 0 && <span className="font-semibold ml-1">No results yet...</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}>
|
||||
<div className="mt-4 flex w-full flex-col gap-4">
|
||||
<ProgressBar
|
||||
color="purple"
|
||||
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
|
||||
className="h-6"
|
||||
textClassName={
|
||||
(assignment?.results.length || 0) /
|
||||
(assignment?.assignees.length || 1) <
|
||||
0.5
|
||||
? "!text-mti-gray-dim font-light"
|
||||
: "text-white"
|
||||
}
|
||||
percentage={
|
||||
((assignment?.results.length || 0) /
|
||||
(assignment?.assignees.length || 1)) *
|
||||
100
|
||||
}
|
||||
/>
|
||||
<div className="flex items-start gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span>
|
||||
Start Date:{" "}
|
||||
{moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}
|
||||
</span>
|
||||
<span>
|
||||
End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}
|
||||
</span>
|
||||
</div>
|
||||
<span>
|
||||
Assignees:{" "}
|
||||
{users
|
||||
.filter((u) => assignment?.assignees.includes(u.id))
|
||||
.map((u) => `${u.name} (${u.email})`)
|
||||
.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xl font-bold">Average Scores</span>
|
||||
<div className="-md:mt-2 flex w-full items-center gap-4">
|
||||
{assignment &&
|
||||
uniqBy(assignment.exams, (x) => x.module).map(({ module }) => (
|
||||
<div
|
||||
data-tip={capitalize(module)}
|
||||
key={module}
|
||||
className={clsx(
|
||||
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||
module === "reading" && "bg-ielts-reading",
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}
|
||||
>
|
||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||
{module === "listening" && (
|
||||
<BsHeadphones className="h-4 w-4" />
|
||||
)}
|
||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||
{calculateAverageModuleScore(module) > -1 && (
|
||||
<span className="text-sm">
|
||||
{calculateAverageModuleScore(module).toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xl font-bold">
|
||||
Results ({assignment?.results.length}/{assignment?.assignees.length}
|
||||
)
|
||||
</span>
|
||||
<div>
|
||||
{assignment && assignment?.results.length > 0 && (
|
||||
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
|
||||
{assignment.results.map((r) =>
|
||||
customContent(r.stats, r.user, r.type),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{assignment && assignment?.results.length === 0 && (
|
||||
<span className="ml-1 font-semibold">No results yet...</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import Modal from "@/components/Modal";
|
||||
import useStats from "@/hooks/useStats";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {Group, Stat, User} from "@/interfaces/user";
|
||||
import {CorporateUser, Group, Stat, User} from "@/interfaces/user";
|
||||
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||
import {dateSorter} from "@/utils";
|
||||
import moment from "moment";
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
BsPersonFillGear,
|
||||
BsPersonGear,
|
||||
BsPencilSquare,
|
||||
BsPersonBadge,
|
||||
BsPersonCheck,
|
||||
BsPeople,
|
||||
} from "react-icons/bs";
|
||||
import UserCard from "@/components/UserCard";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
@@ -31,9 +34,10 @@ import IconCard from "./IconCard";
|
||||
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
||||
import useFilterStore from "@/stores/listFilterStore";
|
||||
import {useRouter} from "next/router";
|
||||
import useCodes from "@/hooks/useCodes";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
user: CorporateUser;
|
||||
}
|
||||
|
||||
export default function CorporateDashboard({user}: Props) {
|
||||
@@ -43,6 +47,7 @@ export default function CorporateDashboard({user}: Props) {
|
||||
|
||||
const {stats} = useStats();
|
||||
const {users, reload} = useUsers();
|
||||
const {codes} = useCodes(user.id);
|
||||
const {groups} = useGroups(user.id);
|
||||
|
||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||
@@ -187,7 +192,13 @@ export default function CorporateDashboard({user}: Props) {
|
||||
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard onClick={() => setPage("groups")} Icon={BsPersonAdd} label="Groups" value={groups.length} color="purple" />
|
||||
<IconCard onClick={() => setPage("groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
|
||||
<IconCard
|
||||
Icon={BsPersonCheck}
|
||||
label="User Balance"
|
||||
value={`${codes.length}/${user.corporateInformation?.companyInformation?.userAmount || 0}`}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsClock}
|
||||
label="Expiration Date"
|
||||
|
||||
@@ -6,10 +6,11 @@ interface Props {
|
||||
label: string;
|
||||
value: string | number;
|
||||
color: "purple" | "rose" | "red";
|
||||
tooltip?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function IconCard({Icon, label, value, color, onClick}: Props) {
|
||||
export default function IconCard({Icon, label, value, color, tooltip, onClick}: Props) {
|
||||
const colorClasses: {[key in typeof color]: string} = {
|
||||
purple: "text-mti-purple-light",
|
||||
red: "text-mti-red-light",
|
||||
@@ -19,7 +20,11 @@ export default function IconCard({Icon, label, value, color, onClick}: Props) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center text-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
||||
className={clsx(
|
||||
"bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center text-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300",
|
||||
tooltip && "tooltip tooltip-bottom",
|
||||
)}
|
||||
data-tip={tooltip}>
|
||||
<Icon className={clsx("text-6xl", colorClasses[color])} />
|
||||
<span className="flex flex-col gap-1 items-center text-xl">
|
||||
<span className="text-lg">{label}</span>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import InviteCard from "@/components/Medium/InviteCard";
|
||||
import PayPalPayment from "@/components/PayPalPayment";
|
||||
import ProfileSummary from "@/components/ProfileSummary";
|
||||
import useAssignments from "@/hooks/useAssignments";
|
||||
import useInvites from "@/hooks/useInvites";
|
||||
import useStats from "@/hooks/useStats";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {Invite} from "@/interfaces/invite";
|
||||
import {Assignment} from "@/interfaces/results";
|
||||
import {CorporateUser, User} from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
@@ -31,7 +35,9 @@ export default function StudentDashboard({user}: Props) {
|
||||
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
|
||||
|
||||
const {stats} = useStats(user.id);
|
||||
const {users} = useUsers();
|
||||
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id});
|
||||
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user.id});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -42,7 +48,7 @@ export default function StudentDashboard({user}: Props) {
|
||||
const setAssignment = useExamStore((state) => state.setAssignment);
|
||||
|
||||
useEffect(() => {
|
||||
getUserCorporate("IXdh9EQziAVXXh0jOiC5cPVlgS82").then(setCorporateUserToShow);
|
||||
getUserCorporate(user.id).then(setCorporateUserToShow);
|
||||
}, [user]);
|
||||
|
||||
const startAssignment = (assignment: Assignment) => {
|
||||
@@ -69,7 +75,7 @@ export default function StudentDashboard({user}: Props) {
|
||||
return (
|
||||
<>
|
||||
{corporateUserToShow && (
|
||||
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
|
||||
<div className="absolute right-4 top-4 rounded-lg bg-neutral-200 px-2 py-1">
|
||||
Linked to: <b>{corporateUserToShow?.corporateInformation?.companyInformation.name || corporateUserToShow.name}</b>
|
||||
</div>
|
||||
)}
|
||||
@@ -77,40 +83,42 @@ export default function StudentDashboard({user}: Props) {
|
||||
user={user}
|
||||
items={[
|
||||
{
|
||||
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||
icon: <BsFileEarmarkText className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
|
||||
value: Object.keys(groupBySession(stats)).length,
|
||||
label: "Exams",
|
||||
},
|
||||
{
|
||||
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||
icon: <BsPencil className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
|
||||
value: stats.length,
|
||||
label: "Exercises",
|
||||
},
|
||||
{
|
||||
icon: <BsStar className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||
icon: <BsStar className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
|
||||
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
|
||||
label: "Average Score",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Bio */}
|
||||
<section className="flex flex-col gap-1 md:gap-3">
|
||||
<span className="font-bold text-lg">Bio</span>
|
||||
<span className="text-lg font-bold">Bio</span>
|
||||
<span className="text-mti-gray-taupe">
|
||||
{user.bio || "Your bio will appear here, you can change it by clicking on your name in the top right corner."}
|
||||
</span>
|
||||
</section>
|
||||
|
||||
{/* Assignments */}
|
||||
<section className="flex flex-col gap-1 md:gap-3">
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
onClick={reloadAssignments}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||
<span className="font-bold text-lg text-mti-black">Assignments</span>
|
||||
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out">
|
||||
<span className="text-mti-black text-lg font-bold">Assignments</span>
|
||||
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-mti-gray-taupe flex gap-8 overflow-x-scroll scrollbar-hide">
|
||||
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
||||
{assignments.filter((a) => moment(a.endDate).isSameOrAfter(moment())).length === 0 &&
|
||||
"Assignments will appear here. It seems that for now there are no assignments for you."}
|
||||
{assignments
|
||||
@@ -119,20 +127,20 @@ export default function StudentDashboard({user}: Props) {
|
||||
.map((assignment) => (
|
||||
<div
|
||||
className={clsx(
|
||||
"border border-mti-gray-anti-flash rounded-xl flex flex-col gap-6 p-4 min-w-[300px]",
|
||||
"border-mti-gray-anti-flash flex min-w-[300px] flex-col gap-6 rounded-xl border p-4",
|
||||
assignment.results.map((r) => r.user).includes(user.id) && "border-mti-green-light",
|
||||
)}
|
||||
key={assignment.id}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="font-semibold text-xl text-mti-black/90">{assignment.name}</h3>
|
||||
<span className="flex gap-1 justify-between">
|
||||
<h3 className="text-mti-black/90 text-xl font-semibold">{assignment.name}</h3>
|
||||
<span className="flex justify-between gap-1">
|
||||
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
<span>-</span>
|
||||
<span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between w-full items-center">
|
||||
<div className="grid grid-cols-2 gap-2 place-items-center justify-center w-fit min-w-[104px] -md:mt-2">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="-md:mt-2 grid w-fit min-w-[104px] grid-cols-2 place-items-center justify-center gap-2">
|
||||
{assignment.exams
|
||||
.filter((e) => e.assignee === user.id)
|
||||
.map((e) => e.module)
|
||||
@@ -142,36 +150,36 @@ export default function StudentDashboard({user}: Props) {
|
||||
key={module}
|
||||
data-tip={capitalize(module)}
|
||||
className={clsx(
|
||||
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl tooltip",
|
||||
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||
module === "reading" && "bg-ielts-reading",
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}>
|
||||
{module === "reading" && <BsBook className="w-4 h-4" />}
|
||||
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
||||
{module === "writing" && <BsPen className="w-4 h-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
||||
{module === "level" && <BsClipboard className="w-4 h-4" />}
|
||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!assignment.results.map((r) => r.user).includes(user.id) && (
|
||||
<>
|
||||
<div
|
||||
className="tooltip w-full md:hidden h-full flex items-center justify-end pl-8"
|
||||
className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden"
|
||||
data-tip="Your screen size is too small to perform an assignment">
|
||||
<Button
|
||||
disabled={moment(assignment.startDate).isAfter(moment())}
|
||||
className="w-full h-full !rounded-xl"
|
||||
className="h-full w-full !rounded-xl"
|
||||
variant="outline">
|
||||
Start
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
disabled={moment(assignment.startDate).isAfter(moment())}
|
||||
className="w-full max-w-[50%] h-full !rounded-xl -md:hidden"
|
||||
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
|
||||
onClick={() => startAssignment(assignment)}
|
||||
variant="outline">
|
||||
Start
|
||||
@@ -182,7 +190,7 @@ export default function StudentDashboard({user}: Props) {
|
||||
<Button
|
||||
onClick={() => router.push("/record")}
|
||||
color="green"
|
||||
className="w-full max-w-[50%] h-full !rounded-xl -md:hidden"
|
||||
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
|
||||
variant="outline">
|
||||
Submitted
|
||||
</Button>
|
||||
@@ -193,23 +201,43 @@ export default function StudentDashboard({user}: Props) {
|
||||
</span>
|
||||
</section>
|
||||
|
||||
{/* Invites */}
|
||||
{invites.length > 0 && (
|
||||
<section className="flex flex-col gap-1 md:gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
onClick={reloadInvites}
|
||||
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out">
|
||||
<span className="text-mti-black text-lg font-bold">Invites</span>
|
||||
<BsArrowRepeat className={clsx("text-xl", isInvitesLoading && "animate-spin")} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
||||
{invites.map((invite) => (
|
||||
<InviteCard key={invite.id} invite={invite} users={users} reload={reloadInvites} />
|
||||
))}
|
||||
</span>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Score History */}
|
||||
<section className="flex flex-col gap-3">
|
||||
<span className="font-bold text-lg">Score History</span>
|
||||
<div className="grid -md:grid-rows-4 md:grid-cols-2 gap-6">
|
||||
<span className="text-lg font-bold">Score History</span>
|
||||
<div className="-md:grid-rows-4 grid gap-6 md:grid-cols-2">
|
||||
{MODULE_ARRAY.map((module) => (
|
||||
<div className="border border-mti-gray-anti-flash rounded-xl flex flex-col gap-2 p-4" key={module}>
|
||||
<div className="flex gap-2 md:gap-3 items-center">
|
||||
<div className="w-8 h-8 md:w-12 md:h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg md:rounded-xl">
|
||||
{module === "reading" && <BsBook className="text-ielts-reading w-4 h-4 md:w-5 md:h-5" />}
|
||||
{module === "listening" && <BsHeadphones className="text-ielts-listening w-4 h-4 md:w-5 md:h-5" />}
|
||||
{module === "writing" && <BsPen className="text-ielts-writing w-4 h-4 md:w-5 md:h-5" />}
|
||||
{module === "speaking" && <BsMegaphone className="text-ielts-speaking w-4 h-4 md:w-5 md:h-5" />}
|
||||
{module === "level" && <BsClipboard className="text-ielts-level w-4 h-4 md:w-5 md:h-5" />}
|
||||
<div className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4" key={module}>
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
<div className="bg-mti-gray-smoke flex h-8 w-8 items-center justify-center rounded-lg md:h-12 md:w-12 md:rounded-xl">
|
||||
{module === "reading" && <BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" />}
|
||||
{module === "listening" && <BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />}
|
||||
{module === "writing" && <BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" />}
|
||||
{module === "speaking" && <BsMegaphone className="text-ielts-speaking h-4 w-4 md:h-5 md:w-5" />}
|
||||
{module === "level" && <BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" />}
|
||||
</div>
|
||||
<div className="flex justify-between w-full">
|
||||
<span className="font-bold md:font-extrabold text-sm">{capitalize(module)}</span>
|
||||
<span className="text-sm font-normal text-mti-gray-dim">
|
||||
Level {user.levels[module] || 0} / Level {user.desiredLevels[module] || 9}
|
||||
<div className="flex w-full justify-between">
|
||||
<span className="text-sm font-bold md:font-extrabold">{capitalize(module)}</span>
|
||||
<span className="text-mti-gray-dim text-sm font-normal">
|
||||
Level {user.levels[module] || 0} / Level 9 (Desired Level: {user.desiredLevels[module] || 9})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,8 +245,10 @@ export default function StudentDashboard({user}: Props) {
|
||||
<ProgressBar
|
||||
color={module}
|
||||
label=""
|
||||
percentage={Math.round((user.levels[module] * 100) / user.desiredLevels[module])}
|
||||
className="w-full h-2"
|
||||
mark={Math.round((user.desiredLevels[module] * 100) / 9)}
|
||||
markLabel={`Desired Level: ${user.desiredLevels[module]}`}
|
||||
percentage={Math.round((user.levels[module] * 100) / 9)}
|
||||
className="h-2 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
BsEnvelopePaper,
|
||||
BsGlobeCentralSouthAsia,
|
||||
BsPaperclip,
|
||||
BsPeople,
|
||||
BsPerson,
|
||||
BsPersonAdd,
|
||||
BsPersonFill,
|
||||
@@ -150,8 +151,9 @@ export default function TeacherDashboard({user}: Props) {
|
||||
};
|
||||
|
||||
const AssignmentsPage = () => {
|
||||
const activeFilter = (a: Assignment) => moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment());
|
||||
const pastFilter = (a: Assignment) => moment(a.endDate).isBefore(moment());
|
||||
const activeFilter = (a: Assignment) =>
|
||||
moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()) && a.assignees.length > a.results.length;
|
||||
const pastFilter = (a: Assignment) => moment(a.endDate).isBefore(moment()) || a.assignees.length === a.results.length;
|
||||
const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
|
||||
|
||||
return (
|
||||
@@ -271,7 +273,7 @@ export default function TeacherDashboard({user}: Props) {
|
||||
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard Icon={BsPersonAdd} label="Groups" value={groups.length} color="purple" onClick={() => setPage("groups")} />
|
||||
<IconCard Icon={BsPeople} label="Groups" value={groups.length} color="purple" onClick={() => setPage("groups")} />
|
||||
<div
|
||||
onClick={() => setPage("assignments")}
|
||||
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
||||
|
||||
28
src/email/templates/receivedInvite.handlebars
Normal file
28
src/email/templates/receivedInvite.handlebars
Normal file
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<div style="background-color: #ffffff; color: #353338;"
|
||||
class="h-full min-h-screen w-full flex flex-col p-8 gap-16 text-base">
|
||||
<img src="/logo_title.png" class="w-48 h-48 self-center" />
|
||||
<div>
|
||||
<span>Hello {{name}},</span>
|
||||
<br/>
|
||||
<br/>
|
||||
<span>You have been invited to join {{corporateName}}'s group!</span>
|
||||
<br />
|
||||
<br/>
|
||||
<span>Please access the platform to accept or decline the invite.</span>
|
||||
</div>
|
||||
<br />
|
||||
<br />
|
||||
<div>
|
||||
<span>Thanks, <br /> Your EnCoach team</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</html>
|
||||
25
src/email/templates/respondedInvite.handlebars
Normal file
25
src/email/templates/respondedInvite.handlebars
Normal file
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<div style="background-color: #ffffff; color: #353338;"
|
||||
class="h-full min-h-screen w-full flex flex-col p-8 gap-16 text-base">
|
||||
<img src="/logo_title.png" class="w-48 h-48 self-center" />
|
||||
<div>
|
||||
<span>Hello {{corporateName}},</span>
|
||||
<br />
|
||||
<br />
|
||||
<span>{{name}} has decided to {{decision}} your invite!</span>
|
||||
</div>
|
||||
<br />
|
||||
<br />
|
||||
<div>
|
||||
<span>Thanks, <br /> Your EnCoach team</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</html>
|
||||
35
src/email/templates/submittedFeedback.handlebars
Normal file
35
src/email/templates/submittedFeedback.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>Thank you for your ticket submission!</span>
|
||||
<br/>
|
||||
<span>Here is the ticket's information:</span>
|
||||
<br/>
|
||||
<br/>
|
||||
<span><b>ID:</b> {{id}}</span><br/>
|
||||
<span><b>Subject:</b> {{subject}}</span><br/>
|
||||
<span><b>Reporter:</b> {{reporter.name}} - {{reporter.email}}</span><br/>
|
||||
<span><b>Date:</b> {{date}}</span><br/>
|
||||
<span><b>Type:</b> {{type}}</span><br/>
|
||||
<span><b>Page:</b> {{reportedFrom}}</span>
|
||||
<br/>
|
||||
<br/>
|
||||
<span><b>Description:</b> {{description}}</span><br/>
|
||||
</div>
|
||||
<br />
|
||||
<br />
|
||||
<div>
|
||||
<span>Thanks, <br /> Your EnCoach team</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</html>
|
||||
@@ -76,7 +76,6 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-1">
|
||||
<span className="text-xl font-bold">{levelStr}</span>
|
||||
<span className="text-xl">{grade}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -86,7 +85,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full min-h-full h-fit flex flex-col items-center justify-between gap-8">
|
||||
<div className="flex h-fit min-h-full w-full flex-col items-center justify-between gap-8">
|
||||
<ModuleTitle
|
||||
module={selectedModule}
|
||||
totalExercises={getTotalExercises()}
|
||||
@@ -99,10 +98,10 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
<div
|
||||
onClick={() => setSelectedModule("reading")}
|
||||
className={clsx(
|
||||
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-reading hover:text-white",
|
||||
"hover:bg-ielts-reading flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
|
||||
selectedModule === "reading" ? "bg-ielts-reading text-white" : "bg-mti-gray-smoke text-ielts-reading",
|
||||
)}>
|
||||
<BsBook className="w-6 h-6" />
|
||||
<BsBook className="h-6 w-6" />
|
||||
<span className="font-semibold">Reading</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -110,10 +109,10 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
<div
|
||||
onClick={() => setSelectedModule("listening")}
|
||||
className={clsx(
|
||||
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-listening hover:text-white",
|
||||
"hover:bg-ielts-listening flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
|
||||
selectedModule === "listening" ? "bg-ielts-listening text-white" : "bg-mti-gray-smoke text-ielts-listening",
|
||||
)}>
|
||||
<BsHeadphones className="w-6 h-6" />
|
||||
<BsHeadphones className="h-6 w-6" />
|
||||
<span className="font-semibold">Listening</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -121,10 +120,10 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
<div
|
||||
onClick={() => setSelectedModule("writing")}
|
||||
className={clsx(
|
||||
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-writing hover:text-white",
|
||||
"hover:bg-ielts-writing flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
|
||||
selectedModule === "writing" ? "bg-ielts-writing text-white" : "bg-mti-gray-smoke text-ielts-writing",
|
||||
)}>
|
||||
<BsPen className="w-6 h-6" />
|
||||
<BsPen className="h-6 w-6" />
|
||||
<span className="font-semibold">Writing</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -132,10 +131,10 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
<div
|
||||
onClick={() => setSelectedModule("speaking")}
|
||||
className={clsx(
|
||||
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-speaking hover:text-white",
|
||||
"hover:bg-ielts-speaking flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
|
||||
selectedModule === "speaking" ? "bg-ielts-speaking text-white" : "bg-mti-gray-smoke text-ielts-speaking",
|
||||
)}>
|
||||
<BsMegaphone className="w-6 h-6" />
|
||||
<BsMegaphone className="h-6 w-6" />
|
||||
<span className="font-semibold">Speaking</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -143,18 +142,18 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
<div
|
||||
onClick={() => setSelectedModule("level")}
|
||||
className={clsx(
|
||||
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-level hover:text-white",
|
||||
"hover:bg-ielts-level flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
|
||||
selectedModule === "level" ? "bg-ielts-level text-white" : "bg-mti-gray-smoke text-ielts-level",
|
||||
)}>
|
||||
<BsClipboard className="w-6 h-6" />
|
||||
<BsClipboard className="h-6 w-6" />
|
||||
<span className="font-semibold">Level</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className="w-fit h-fit absolute top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 animate-pulse flex flex-col gap-12 items-center">
|
||||
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
|
||||
<span className={clsx("loading loading-infinity w-32", moduleColors[selectedModule].progress)} />
|
||||
<span className={clsx("font-bold text-2xl text-center", moduleColors[selectedModule].progress)}>
|
||||
<span className={clsx("text-center text-2xl font-bold", moduleColors[selectedModule].progress)}>
|
||||
Evaluating your answers, please be patient...
|
||||
<br />
|
||||
You can also check it later on your records page!
|
||||
@@ -162,17 +161,21 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && (
|
||||
<div className="w-full flex gap-9 mt-32 items-center justify-between mb-20">
|
||||
<div className="mb-20 mt-32 flex w-full items-center justify-between gap-9">
|
||||
<span className="max-w-3xl">{moduleResultText(selectedModule, bandScore)}</span>
|
||||
<div className="flex gap-9 px-16">
|
||||
<div
|
||||
className={clsx("radial-progress overflow-hidden", moduleColors[selectedModule].progress)}
|
||||
style={
|
||||
{"--value": (selectedScore.correct / selectedScore.total) * 100, "--thickness": "12px", "--size": "13rem"} as any
|
||||
{
|
||||
"--value": (selectedScore.correct / selectedScore.total) * 100,
|
||||
"--thickness": "12px",
|
||||
"--size": "13rem",
|
||||
} as any
|
||||
}>
|
||||
<div
|
||||
className={clsx(
|
||||
"w-48 h-48 rounded-full flex flex-col items-center justify-center",
|
||||
"flex h-48 w-48 flex-col items-center justify-center rounded-full",
|
||||
moduleColors[selectedModule].inner,
|
||||
)}>
|
||||
<span className="text-xl">Level</span>
|
||||
@@ -181,7 +184,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex gap-2">
|
||||
<div className="w-3 h-3 bg-mti-red-light rounded-full mt-1" />
|
||||
<div className="bg-mti-red-light mt-1 h-3 w-3 rounded-full" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-mti-red-light">
|
||||
{(((selectedScore.total - selectedScore.missing) / selectedScore.total) * 100).toFixed(0)}%
|
||||
@@ -190,14 +193,14 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="w-3 h-3 bg-mti-purple-light rounded-full mt-1" />
|
||||
<div className="bg-mti-purple-light mt-1 h-3 w-3 rounded-full" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-mti-purple-light">{selectedScore.correct.toString().padStart(2, "0")}</span>
|
||||
<span className="text-lg">Correct</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="w-3 h-3 bg-mti-rose-light rounded-full mt-1" />
|
||||
<div className="bg-mti-rose-light mt-1 h-3 w-3 rounded-full" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-mti-rose-light">
|
||||
{(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")}
|
||||
@@ -212,28 +215,28 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
</div>
|
||||
|
||||
{!isLoading && (
|
||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||
<div className="absolute bottom-8 left-0 flex w-full justify-between gap-8 self-end px-8">
|
||||
<div className="flex gap-8">
|
||||
<div className="w-fit flex flex-col items-center gap-1 cursor-pointer">
|
||||
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="w-11 h-11 rounded-full bg-mti-purple-light hover:bg-mti-purple flex items-center justify-center transition duration-300 ease-in-out">
|
||||
<BsArrowCounterclockwise className="text-white w-7 h-7" />
|
||||
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
||||
<BsArrowCounterclockwise className="h-7 w-7 text-white" />
|
||||
</button>
|
||||
<span>Play Again</span>
|
||||
</div>
|
||||
<div className="w-fit flex flex-col items-center gap-1 cursor-pointer">
|
||||
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
||||
<button
|
||||
onClick={onViewResults}
|
||||
className="w-11 h-11 rounded-full bg-mti-purple-light hover:bg-mti-purple flex items-center justify-center transition duration-300 ease-in-out">
|
||||
<BsEyeFill className="text-white w-7 h-7" />
|
||||
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
||||
<BsEyeFill className="h-7 w-7 text-white" />
|
||||
</button>
|
||||
<span>Review Answers</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link href="/" className="max-w-[200px] w-full self-end">
|
||||
<Button color="purple" className="max-w-[200px] self-end w-full">
|
||||
<Link href="/" className="w-full max-w-[200px] self-end">
|
||||
<Button color="purple" className="w-full max-w-[200px] self-end">
|
||||
Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
@@ -22,9 +22,9 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
|
||||
const [questionIndex, setQuestionIndex] = useState(0);
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [exerciseIndex, setExerciseIndex] = useState(0);
|
||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
|
||||
|
||||
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
|
||||
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentQuestionIndex(0);
|
||||
@@ -38,7 +38,7 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
}
|
||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
|
||||
|
||||
const previousExercise = (solution?: UserSolution) => {
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
}
|
||||
|
||||
if (exerciseIndex > 0) {
|
||||
@@ -91,7 +91,7 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
showSolutions &&
|
||||
|
||||
@@ -7,7 +7,6 @@ import AudioPlayer from "@/components/Low/AudioPlayer";
|
||||
import Button from "@/components/Low/Button";
|
||||
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {defaultUserSolutions} from "@/utils/exams";
|
||||
import {countExercises} from "@/utils/moduleUtils";
|
||||
|
||||
interface Props {
|
||||
@@ -16,24 +15,35 @@ interface Props {
|
||||
onFinish: (userSolutions: UserSolution[]) => void;
|
||||
}
|
||||
|
||||
const INSTRUCTIONS_AUDIO_SRC =
|
||||
"https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_recordings%2Fgeneric_intro.mp3?alt=media&token=9b9cfdb8-e90d-40d1-854b-51c4378a5c4b";
|
||||
|
||||
export default function Listening({exam, showSolutions = false, onFinish}: Props) {
|
||||
const [questionIndex, setQuestionIndex] = useState(0);
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [exerciseIndex, setExerciseIndex] = useState(showSolutions ? 0 : -1);
|
||||
const [partIndex, setPartIndex] = useState(0);
|
||||
const [timesListened, setTimesListened] = useState(0);
|
||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(
|
||||
exam.parts.flatMap((x) => x.exercises).map((x) => defaultUserSolutions(x, exam)),
|
||||
);
|
||||
const [showBlankModal, setShowBlankModal] = useState(false);
|
||||
|
||||
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
|
||||
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
||||
const {partIndex, setPartIndex} = useExamStore((state) => state);
|
||||
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
||||
|
||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||
|
||||
useEffect(() => {
|
||||
if (showSolutions) return setExerciseIndex(-1);
|
||||
}, [setExerciseIndex, showSolutions]);
|
||||
|
||||
// useEffect(() => {
|
||||
// if (exam.variant !== "partial") setPartIndex(-1);
|
||||
// }, [exam.variant, setPartIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded && exerciseIndex === -1) {
|
||||
setExerciseIndex((prev) => prev + 1);
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
}
|
||||
}, [hasExamEnded, exerciseIndex]);
|
||||
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentQuestionIndex(0);
|
||||
@@ -49,18 +59,19 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
};
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
}
|
||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
||||
|
||||
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
||||
setExerciseIndex((prev) => prev + 1);
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
|
||||
setPartIndex((prev) => prev + 1);
|
||||
setPartIndex(partIndex + 1);
|
||||
setExerciseIndex(showSolutions ? 0 : -1);
|
||||
return;
|
||||
}
|
||||
@@ -89,11 +100,12 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
};
|
||||
|
||||
const previousExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
}
|
||||
|
||||
setExerciseIndex((prev) => prev - 1);
|
||||
setExerciseIndex(exerciseIndex - 1);
|
||||
};
|
||||
|
||||
const getExercise = () => {
|
||||
@@ -104,6 +116,17 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
};
|
||||
};
|
||||
|
||||
const renderAudioInstructionsPlayer = () => (
|
||||
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
|
||||
<div className="flex flex-col w-full gap-2">
|
||||
<h4 className="text-xl font-semibold">Please listen to the instructions audio attentively.</h4>
|
||||
</div>
|
||||
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
|
||||
<AudioPlayer key={partIndex} src={INSTRUCTIONS_AUDIO_SRC} color="listening" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderAudioPlayer = () => (
|
||||
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
|
||||
<div className="flex flex-col w-full gap-2">
|
||||
@@ -133,38 +156,53 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
<div className="flex flex-col h-full w-full gap-8 justify-between">
|
||||
<ModuleTitle
|
||||
exerciseIndex={
|
||||
(exam.parts
|
||||
.flatMap((x) => x.exercises)
|
||||
.findIndex(
|
||||
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
|
||||
) || 0) +
|
||||
(exerciseIndex === -1 ? 0 : 1) +
|
||||
questionIndex +
|
||||
currentQuestionIndex
|
||||
partIndex === -1
|
||||
? 0
|
||||
: (exam.parts
|
||||
.flatMap((x) => x.exercises)
|
||||
.findIndex(
|
||||
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
|
||||
) || 0) +
|
||||
(exerciseIndex === -1 ? 0 : 1) +
|
||||
questionIndex +
|
||||
currentQuestionIndex
|
||||
}
|
||||
minTimer={exam.minTimer}
|
||||
module="listening"
|
||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||
disableTimer={showSolutions}
|
||||
/>
|
||||
{renderAudioPlayer()}
|
||||
{/* Audio Player for the Instructions */}
|
||||
{partIndex === -1 && renderAudioInstructionsPlayer()}
|
||||
|
||||
{/* Part's audio player */}
|
||||
{partIndex > -1 && renderAudioPlayer()}
|
||||
|
||||
{/* Exercise renderer */}
|
||||
{exerciseIndex > -1 &&
|
||||
partIndex > -1 &&
|
||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
|
||||
{/* Solution renderer */}
|
||||
{exerciseIndex > -1 &&
|
||||
partIndex > -1 &&
|
||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||
showSolutions &&
|
||||
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
</div>
|
||||
{exerciseIndex === -1 && partIndex > 0 && (
|
||||
|
||||
{exerciseIndex === -1 && partIndex > -1 && exam.variant !== "partial" && (
|
||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (partIndex === 0) return setPartIndex(-1);
|
||||
|
||||
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
|
||||
setPartIndex((prev) => prev - 1);
|
||||
setPartIndex(partIndex - 1);
|
||||
}}
|
||||
className="max-w-[200px] w-full">
|
||||
Back
|
||||
@@ -175,7 +213,13 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{exerciseIndex === -1 && partIndex === 0 && (
|
||||
|
||||
{partIndex === -1 && exam.variant !== "partial" && (
|
||||
<Button color="purple" onClick={() => setPartIndex(0)} className="max-w-[200px] self-end w-full justify-self-end">
|
||||
Start now
|
||||
</Button>
|
||||
)}
|
||||
{exerciseIndex === -1 && partIndex === 0 && exam.variant === "partial" && (
|
||||
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end">
|
||||
Start now
|
||||
</Button>
|
||||
|
||||
@@ -83,15 +83,20 @@ function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: s
|
||||
export default function Reading({exam, showSolutions = false, onFinish}: Props) {
|
||||
const [questionIndex, setQuestionIndex] = useState(0);
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [exerciseIndex, setExerciseIndex] = useState(showSolutions ? 0 : -1);
|
||||
const [partIndex, setPartIndex] = useState(0);
|
||||
const [showTextModal, setShowTextModal] = useState(false);
|
||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(
|
||||
exam.parts.flatMap((x) => x.exercises).map((x) => defaultUserSolutions(x, exam)),
|
||||
);
|
||||
const [showBlankModal, setShowBlankModal] = useState(false);
|
||||
|
||||
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
|
||||
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
||||
const {partIndex, setPartIndex} = useExamStore((state) => state);
|
||||
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
||||
const setStoreQuestionIndex = useExamStore((state) => state.setQuestionIndex);
|
||||
|
||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||
|
||||
useEffect(() => {
|
||||
if (showSolutions) setExerciseIndex(-1);
|
||||
}, [setExerciseIndex, showSolutions]);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (e: KeyboardEvent) => {
|
||||
@@ -113,9 +118,9 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded && exerciseIndex === -1) {
|
||||
setExerciseIndex((prev) => prev + 1);
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
}
|
||||
}, [hasExamEnded, exerciseIndex]);
|
||||
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
||||
|
||||
const confirmFinishModule = (keepGoing?: boolean) => {
|
||||
if (!keepGoing) {
|
||||
@@ -127,18 +132,20 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
||||
};
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
}
|
||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
||||
setStoreQuestionIndex(0);
|
||||
|
||||
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
||||
setExerciseIndex((prev) => prev + 1);
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
|
||||
setPartIndex((prev) => prev + 1);
|
||||
setPartIndex(partIndex + 1);
|
||||
setExerciseIndex(showSolutions ? 0 : -1);
|
||||
return;
|
||||
}
|
||||
@@ -167,11 +174,13 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
||||
};
|
||||
|
||||
const previousExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
}
|
||||
setStoreQuestionIndex(0);
|
||||
|
||||
setExerciseIndex((prev) => prev - 1);
|
||||
setExerciseIndex(exerciseIndex - 1);
|
||||
};
|
||||
|
||||
const getExercise = () => {
|
||||
@@ -206,14 +215,17 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
||||
<>
|
||||
<div className="flex flex-col h-full w-full gap-8">
|
||||
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
||||
<TextModal {...exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />
|
||||
{partIndex > -1 && <TextModal {...exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />}
|
||||
<ModuleTitle
|
||||
minTimer={exam.minTimer}
|
||||
exerciseIndex={
|
||||
(exam.parts
|
||||
.flatMap((x) => x.exercises)
|
||||
.findIndex(
|
||||
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
|
||||
(x) =>
|
||||
x.id ===
|
||||
exam.parts[partIndex > -1 ? partIndex : 0].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]
|
||||
?.id,
|
||||
) || 0) +
|
||||
(exerciseIndex === -1 ? 0 : 1) +
|
||||
questionIndex +
|
||||
@@ -225,17 +237,21 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
||||
label={exerciseIndex === -1 ? undefined : convertCamelCaseToReadable(exam.parts[partIndex].exercises[exerciseIndex].type)}
|
||||
/>
|
||||
<div className={clsx("mb-20 w-full", exerciseIndex > -1 && "grid grid-cols-2 gap-4")}>
|
||||
{renderText()}
|
||||
{partIndex > -1 && renderText()}
|
||||
|
||||
{exerciseIndex > -1 &&
|
||||
partIndex > -1 &&
|
||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
|
||||
{exerciseIndex > -1 &&
|
||||
partIndex > -1 &&
|
||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||
showSolutions &&
|
||||
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
</div>
|
||||
{exerciseIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
|
||||
{exerciseIndex > -1 && partIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
@@ -252,7 +268,7 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
|
||||
setPartIndex((prev) => prev - 1);
|
||||
setPartIndex(partIndex - 1);
|
||||
}}
|
||||
className="max-w-[200px] w-full">
|
||||
Back
|
||||
|
||||
@@ -4,7 +4,7 @@ import {Module} from "@/interfaces";
|
||||
import clsx from "clsx";
|
||||
import {User} from "@/interfaces/user";
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import {BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
||||
import {BsArrowRepeat, BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
||||
import {totalExamsByModule} from "@/utils/stats";
|
||||
import useStats from "@/hooks/useStats";
|
||||
import Button from "@/components/Low/Button";
|
||||
@@ -12,53 +12,78 @@ import {calculateAverageLevel} from "@/utils/score";
|
||||
import {sortByModuleName} from "@/utils/moduleUtils";
|
||||
import {capitalize} from "lodash";
|
||||
import ProfileSummary from "@/components/ProfileSummary";
|
||||
import {Variant} from "@/interfaces/exam";
|
||||
import useSessions, {Session} from "@/hooks/useSessions";
|
||||
import SessionCard from "@/components/Medium/SessionCard";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import moment from "moment";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
page: "exercises" | "exams";
|
||||
onStart: (modules: Module[], avoidRepeated: boolean) => void;
|
||||
onStart: (modules: Module[], avoidRepeated: boolean, variant: Variant) => void;
|
||||
disableSelection?: boolean;
|
||||
}
|
||||
|
||||
export default function Selection({user, page, onStart, disableSelection = false}: Props) {
|
||||
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
|
||||
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
|
||||
const [variant, setVariant] = useState<Variant>("full");
|
||||
|
||||
const {stats} = useStats(user?.id);
|
||||
const {sessions, isLoading, reload} = useSessions(user.id);
|
||||
|
||||
const state = useExamStore((state) => state);
|
||||
|
||||
const toggleModule = (module: Module) => {
|
||||
const modules = selectedModules.filter((x) => x !== module);
|
||||
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module]));
|
||||
};
|
||||
|
||||
const loadSession = async (session: Session) => {
|
||||
state.setSelectedModules(session.selectedModules);
|
||||
state.setExam(session.exam);
|
||||
state.setExams(session.exams);
|
||||
state.setSessionId(session.sessionId);
|
||||
state.setAssignment(session.assignment);
|
||||
state.setExerciseIndex(session.exerciseIndex);
|
||||
state.setPartIndex(session.partIndex);
|
||||
state.setModuleIndex(session.moduleIndex);
|
||||
state.setTimeSpent(session.timeSpent);
|
||||
state.setUserSolutions(session.userSolutions);
|
||||
state.setShowSolutions(false);
|
||||
state.setQuestionIndex(session.questionIndex);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full h-full relative flex flex-col gap-8 md:gap-16">
|
||||
<div className="relative flex h-full w-full flex-col gap-8 md:gap-16">
|
||||
{user && (
|
||||
<ProfileSummary
|
||||
user={user}
|
||||
items={[
|
||||
{
|
||||
icon: <BsBook className="text-ielts-reading w-6 h-6 md:w-8 md:h-8" />,
|
||||
icon: <BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />,
|
||||
label: "Reading",
|
||||
value: totalExamsByModule(stats, "reading"),
|
||||
},
|
||||
{
|
||||
icon: <BsHeadphones className="text-ielts-listening w-6 h-6 md:w-8 md:h-8" />,
|
||||
icon: <BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />,
|
||||
label: "Listening",
|
||||
value: totalExamsByModule(stats, "listening"),
|
||||
},
|
||||
{
|
||||
icon: <BsPen className="text-ielts-writing w-6 h-6 md:w-8 md:h-8" />,
|
||||
icon: <BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />,
|
||||
label: "Writing",
|
||||
value: totalExamsByModule(stats, "writing"),
|
||||
},
|
||||
{
|
||||
icon: <BsMegaphone className="text-ielts-speaking w-6 h-6 md:w-8 md:h-8" />,
|
||||
icon: <BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />,
|
||||
label: "Speaking",
|
||||
value: totalExamsByModule(stats, "speaking"),
|
||||
},
|
||||
{
|
||||
icon: <BsClipboard className="text-ielts-level w-6 h-6 md:w-8 md:h-8" />,
|
||||
icon: <BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />,
|
||||
label: "Level",
|
||||
value: totalExamsByModule(stats, "level"),
|
||||
},
|
||||
@@ -67,7 +92,7 @@ export default function Selection({user, page, onStart, disableSelection = false
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<span className="font-bold text-lg">About {capitalize(page)}</span>
|
||||
<span className="text-lg font-bold">About {capitalize(page)}</span>
|
||||
<span className="text-mti-gray-taupe">
|
||||
{page === "exercises" && (
|
||||
<>
|
||||
@@ -92,133 +117,171 @@ export default function Selection({user, page, onStart, disableSelection = false
|
||||
)}
|
||||
</span>
|
||||
</section>
|
||||
<section className="w-full flex -lg:flex-col -lg:items-center -lg:gap-12 justify-between gap-8 mt-8">
|
||||
|
||||
{sessions.length > 0 && (
|
||||
<section className="flex flex-col gap-3 md:gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
onClick={reload}
|
||||
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out">
|
||||
<span className="text-mti-black text-lg font-bold">Unfinished Sessions</span>
|
||||
<BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
||||
{sessions
|
||||
.sort((a, b) => moment(b.date).diff(moment(a.date)))
|
||||
.map((session) => (
|
||||
<SessionCard session={session} key={session.sessionId} reload={reload} loadSession={loadSession} />
|
||||
))}
|
||||
</span>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8">
|
||||
<div
|
||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
|
||||
className={clsx(
|
||||
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
||||
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||
selectedModules.includes("reading") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||
)}>
|
||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-0 -translate-y-1/2">
|
||||
<BsBook className="text-white w-7 h-7" />
|
||||
<div className="bg-ielts-reading absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||
<BsBook className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Reading:</span>
|
||||
<p className="text-center text-xs">
|
||||
<p className="text-left text-xs">
|
||||
Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
|
||||
</p>
|
||||
{!selectedModules.includes("reading") && !selectedModules.includes("level") && !disableSelection && (
|
||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("reading") || disableSelection) && (
|
||||
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
|
||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
||||
</div>
|
||||
<div
|
||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
|
||||
className={clsx(
|
||||
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
||||
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||
selectedModules.includes("listening") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||
)}>
|
||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-0 -translate-y-1/2">
|
||||
<BsHeadphones className="text-white w-7 h-7" />
|
||||
<div className="bg-ielts-listening absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||
<BsHeadphones className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Listening:</span>
|
||||
<p className="text-center text-xs">
|
||||
<p className="text-left text-xs">
|
||||
Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
|
||||
</p>
|
||||
{!selectedModules.includes("listening") && !selectedModules.includes("level") && !disableSelection && (
|
||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("listening") || disableSelection) && (
|
||||
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
|
||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
||||
</div>
|
||||
<div
|
||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
|
||||
className={clsx(
|
||||
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
||||
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||
selectedModules.includes("writing") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||
)}>
|
||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-0 -translate-y-1/2">
|
||||
<BsPen className="text-white w-7 h-7" />
|
||||
<div className="bg-ielts-writing absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||
<BsPen className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Writing:</span>
|
||||
<p className="text-center text-xs">
|
||||
<p className="text-left text-xs">
|
||||
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
|
||||
</p>
|
||||
{!selectedModules.includes("writing") && !selectedModules.includes("level") && !disableSelection && (
|
||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("writing") || disableSelection) && (
|
||||
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
|
||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
||||
</div>
|
||||
<div
|
||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
|
||||
className={clsx(
|
||||
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
||||
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||
selectedModules.includes("speaking") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||
)}>
|
||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-0 -translate-y-1/2">
|
||||
<BsMegaphone className="text-white w-7 h-7" />
|
||||
<div className="bg-ielts-speaking absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||
<BsMegaphone className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Speaking:</span>
|
||||
<p className="text-center text-xs">
|
||||
<p className="text-left text-xs">
|
||||
You'll have access to interactive dialogs, pronunciation exercises and speech recordings.
|
||||
</p>
|
||||
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && !disableSelection && (
|
||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("speaking") || disableSelection) && (
|
||||
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
|
||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
||||
</div>
|
||||
{!disableSelection && (
|
||||
<div
|
||||
onClick={selectedModules.length === 0 || selectedModules.includes("level") ? () => toggleModule("level") : undefined}
|
||||
className={clsx(
|
||||
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
||||
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||
selectedModules.includes("level") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||
)}>
|
||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-0 -translate-y-1/2">
|
||||
<BsClipboard className="text-white w-7 h-7" />
|
||||
<div className="bg-ielts-level absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||
<BsClipboard className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Level:</span>
|
||||
<p className="text-center text-xs">You'll be able to test your english level with multiple choice questions.</p>
|
||||
<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 border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("level") || disableSelection) && (
|
||||
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{!selectedModules.includes("level") && selectedModules.length > 0 && (
|
||||
<BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />
|
||||
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<div className="flex w-full -md:flex-col -md:gap-4 -md:justify-center md:justify-between items-center">
|
||||
<div
|
||||
className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer tooltip w-full -md:justify-center"
|
||||
data-tip="If possible, the platform will choose exams not yet done"
|
||||
onClick={() => setAvoidRepeatedExams((prev) => !prev)}>
|
||||
<input type="checkbox" className="hidden" />
|
||||
<div className="-md:flex-col -md:gap-4 -md:justify-center flex w-full items-center md:justify-between">
|
||||
<div className="flex w-full flex-col items-center gap-3">
|
||||
<div
|
||||
className={clsx(
|
||||
"w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
|
||||
"transition duration-300 ease-in-out",
|
||||
avoidRepeatedExams && "!bg-mti-purple-light ",
|
||||
)}>
|
||||
<BsCheck color="white" className="w-full h-full" />
|
||||
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
|
||||
onClick={() => setAvoidRepeatedExams((prev) => !prev)}>
|
||||
<input type="checkbox" className="hidden" />
|
||||
<div
|
||||
className={clsx(
|
||||
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
|
||||
"transition duration-300 ease-in-out",
|
||||
avoidRepeatedExams && "!bg-mti-purple-light ",
|
||||
)}>
|
||||
<BsCheck color="white" className="h-full w-full" />
|
||||
</div>
|
||||
<span className="tooltip" data-tip="If possible, the platform will choose exams not yet done.">
|
||||
Avoid Repeated Questions
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
|
||||
onClick={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
|
||||
<input type="checkbox" className="hidden" />
|
||||
<div
|
||||
className={clsx(
|
||||
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
|
||||
"transition duration-300 ease-in-out",
|
||||
variant === "full" && "!bg-mti-purple-light ",
|
||||
)}>
|
||||
<BsCheck color="white" className="h-full w-full" />
|
||||
</div>
|
||||
<span>Full length exams</span>
|
||||
</div>
|
||||
<span>Avoid Repeated Questions</span>
|
||||
</div>
|
||||
<div className="tooltip w-full" data-tip={`Your screen size is too small to do ${page}`}>
|
||||
<Button color="purple" className="px-12 w-full max-w-xs md:hidden" disabled>
|
||||
<Button color="purple" className="w-full max-w-xs px-12 md:hidden" disabled>
|
||||
Start Exam
|
||||
</Button>
|
||||
</div>
|
||||
@@ -227,10 +290,11 @@ export default function Selection({user, page, onStart, disableSelection = false
|
||||
onStart(
|
||||
!disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"],
|
||||
avoidRepeatedExams,
|
||||
variant,
|
||||
)
|
||||
}
|
||||
color="purple"
|
||||
className="px-12 w-full max-w-xs md:self-end -md:hidden"
|
||||
className="-md:hidden w-full max-w-xs px-12 md:self-end"
|
||||
disabled={selectedModules.length === 0 && !disableSelection}>
|
||||
Start Exam
|
||||
</Button>
|
||||
|
||||
@@ -22,22 +22,26 @@ interface Props {
|
||||
export default function Speaking({exam, showSolutions = false, onFinish}: Props) {
|
||||
const [questionIndex, setQuestionIndex] = useState(0);
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [exerciseIndex, setExerciseIndex] = useState(0);
|
||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
|
||||
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
|
||||
|
||||
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
||||
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
||||
|
||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentQuestionIndex(0);
|
||||
}, [questionIndex]);
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
}
|
||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
||||
|
||||
if (exerciseIndex + 1 < exam.exercises.length) {
|
||||
setExerciseIndex((prev) => prev + 1);
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -55,12 +59,13 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
||||
};
|
||||
|
||||
const previousExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
}
|
||||
|
||||
if (exerciseIndex > 0) {
|
||||
setExerciseIndex((prev) => prev - 1);
|
||||
setExerciseIndex(exerciseIndex - 1);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -86,7 +91,7 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
showSolutions &&
|
||||
|
||||
@@ -19,18 +19,20 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function Writing({exam, showSolutions = false, onFinish}: Props) {
|
||||
const [exerciseIndex, setExerciseIndex] = useState(0);
|
||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
|
||||
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
||||
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
||||
|
||||
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
|
||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
}
|
||||
|
||||
if (exerciseIndex + 1 < exam.exercises.length) {
|
||||
setExerciseIndex((prev) => prev + 1);
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -48,12 +50,13 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
|
||||
};
|
||||
|
||||
const previousExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
}
|
||||
|
||||
if (exerciseIndex > 0) {
|
||||
setExerciseIndex((prev) => prev - 1);
|
||||
setExerciseIndex(exerciseIndex - 1);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -78,7 +81,7 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), nextExercise, previousExercise)}
|
||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
showSolutions &&
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
/* eslint-disable jsx-a11y/alt-text */
|
||||
import React from "react";
|
||||
|
||||
import { View, Text, Image } from "@react-pdf/renderer";
|
||||
import { styles } from "../styles";
|
||||
import { ModuleScore } from "@/interfaces/module.scores";
|
||||
import {View, Text, Image} from "@react-pdf/renderer";
|
||||
import {styles} from "../styles";
|
||||
import {ModuleScore} from "@/interfaces/module.scores";
|
||||
|
||||
export const RadialResult = ({
|
||||
module,
|
||||
score,
|
||||
total,
|
||||
png,
|
||||
}: ModuleScore) => (
|
||||
<View style={[styles.textFont, styles.radialContainer]}>
|
||||
<Text style={[styles.textColor, styles.textBold, { fontSize: 10 }]}>
|
||||
{module}
|
||||
</Text>
|
||||
<Image src={png} style={styles.image64}></Image>
|
||||
<View style={[styles.textColor, styles.radialResultContainer]}>
|
||||
<Text style={styles.textBold}>{score}</Text>
|
||||
<Text style={{ fontSize: 8 }}>out of {total}</Text>
|
||||
</View>
|
||||
</View>
|
||||
export const RadialResult = ({module, score, total, png}: ModuleScore) => (
|
||||
<View style={[styles.textFont, styles.radialContainer]}>
|
||||
<Text style={[styles.textColor, styles.textBold, {fontSize: 10}]}>{module}</Text>
|
||||
<Image src={png} style={styles.image64}></Image>
|
||||
<View style={[styles.textColor, styles.radialResultContainer]}>
|
||||
<Text style={styles.textBold}>{score.toFixed(2)}</Text>
|
||||
<Text style={{fontSize: 8}}>out of {total}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import React from "react";
|
||||
import { View, StyleSheet } from "@react-pdf/renderer";
|
||||
import { ModuleScore } from "@/interfaces/module.scores";
|
||||
import { RadialResult } from "./radial.result";
|
||||
import {View, StyleSheet} from "@react-pdf/renderer";
|
||||
import {ModuleScore} from "@/interfaces/module.scores";
|
||||
import {RadialResult} from "./radial.result";
|
||||
interface Props {
|
||||
testDetails: ModuleScore[];
|
||||
testDetails: ModuleScore[];
|
||||
}
|
||||
|
||||
const customStyles = StyleSheet.create({
|
||||
container: { display: "flex", flexDirection: "row", gap: 30 },
|
||||
container: {display: "flex", flexDirection: "row", gap: 30},
|
||||
});
|
||||
|
||||
export const SkillExamDetails = ({ testDetails }: Props) => (
|
||||
<View style={customStyles.container}>
|
||||
{testDetails.map((detail) => {
|
||||
const { module } = detail;
|
||||
return <RadialResult key={module} {...detail} />;
|
||||
})}
|
||||
</View>
|
||||
export const SkillExamDetails = ({testDetails}: Props) => (
|
||||
<View style={customStyles.container}>
|
||||
{testDetails.map((detail) => {
|
||||
const {module} = detail;
|
||||
return <RadialResult key={module} {...detail} />;
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -112,11 +112,6 @@ const GroupTestReport = ({
|
||||
Candidate Information:
|
||||
</Text>
|
||||
<View style={styles.textMargin}>
|
||||
<Text style={defaultTextStyle}>Name: {name}</Text>
|
||||
<Text style={defaultTextStyle}>ID: {id}</Text>
|
||||
<Text style={defaultTextStyle}>Email: {email}</Text>
|
||||
<Text style={defaultTextStyle}>Gender: {gender}</Text>
|
||||
<Text style={defaultTextStyle}>Passport ID: {passportId}</Text>
|
||||
<Text style={defaultTextStyle}>
|
||||
Total Number of Students: {numberOfStudents}
|
||||
</Text>
|
||||
@@ -242,10 +237,10 @@ const GroupTestReport = ({
|
||||
Sr
|
||||
</Text>
|
||||
<Text style={customStyles.tableCell}>Candidate Name</Text>
|
||||
<Text style={customStyles.tableCell}>Email ID</Text>
|
||||
<Text style={[customStyles.tableCell, { maxWidth: "48px" }]}>
|
||||
Gender
|
||||
<Text style={customStyles.tableCell}>
|
||||
Passport ID
|
||||
</Text>
|
||||
<Text style={customStyles.tableCell}>Email ID</Text>
|
||||
<Text style={[customStyles.tableCell, { maxWidth: "64px" }]}>
|
||||
Date of test
|
||||
</Text>
|
||||
@@ -255,7 +250,19 @@ const GroupTestReport = ({
|
||||
{showLevel && <Text style={customStyles.tableCell}>Level</Text>}
|
||||
</View>
|
||||
{studentsData.map(
|
||||
({ id, name, email, gender, date, result, level }, index) => (
|
||||
(
|
||||
{
|
||||
id,
|
||||
name,
|
||||
email,
|
||||
gender,
|
||||
date,
|
||||
result,
|
||||
level,
|
||||
passportId: studentPassportId,
|
||||
},
|
||||
index
|
||||
) => (
|
||||
<View
|
||||
style={[
|
||||
customStyles.tableRow,
|
||||
@@ -273,10 +280,8 @@ const GroupTestReport = ({
|
||||
{index + 1}
|
||||
</Text>
|
||||
<Text style={customStyles.tableCell}>{name}</Text>
|
||||
<Text style={customStyles.tableCell}>{studentPassportId}</Text>
|
||||
<Text style={customStyles.tableCell}>{email}</Text>
|
||||
<Text style={[customStyles.tableCell, { maxWidth: "48px" }]}>
|
||||
{gender}
|
||||
</Text>
|
||||
<Text style={[customStyles.tableCell, { maxWidth: "64px" }]}>
|
||||
{date}
|
||||
</Text>
|
||||
|
||||
@@ -2,7 +2,11 @@ import React from "react";
|
||||
import { styles } from "./styles";
|
||||
import { View, Text } from "@react-pdf/renderer";
|
||||
|
||||
const TestReportFooter = () => (
|
||||
interface Props {
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
const TestReportFooter = ({ userId }: Props) => (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
@@ -25,10 +29,15 @@ const TestReportFooter = () => (
|
||||
</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.textBold}>Confidential – <Text style={[styles.textFont, styles.textNormal]}>circulated for concern people</Text></Text>
|
||||
<Text style={styles.textBold}>
|
||||
Confidential –{" "}
|
||||
<Text style={[styles.textFont, styles.textNormal]}>
|
||||
circulated for concern people
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={{ paddingTop: 10 }}>
|
||||
<View>
|
||||
<Text style={styles.textBold}>Declaration</Text>
|
||||
<Text style={{ paddingTop: 5 }}>
|
||||
We hereby declare that exam results on our platform, assessed by AI, are
|
||||
@@ -40,6 +49,22 @@ const TestReportFooter = () => (
|
||||
continuously enhance our system to ensure accuracy and reliability.
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ paddingTop: 4 }}>
|
||||
<Text style={styles.textBold}>
|
||||
PDF Version:{" "}
|
||||
<Text style={[styles.textFont, styles.textNormal]}>
|
||||
{process.env.PDF_VERSION}
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
{userId && (
|
||||
<View>
|
||||
<Text style={styles.textBold}>
|
||||
User ID:{" "}
|
||||
<Text style={[styles.textFont, styles.textNormal]}>{userId}</Text>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={[styles.textColor, { paddingTop: 5 }]}>
|
||||
<Text style={styles.textUnderline}>info@encoach.com</Text>
|
||||
<Text>https://encoach.com</Text>
|
||||
|
||||
@@ -124,7 +124,7 @@ const TestReport = ({
|
||||
</View>
|
||||
</View>
|
||||
<View style={[{ paddingTop: 30 }, styles.separator]}></View>
|
||||
<TestReportFooter />
|
||||
<TestReportFooter userId={id}/>
|
||||
</Page>
|
||||
<Page style={styles.body}>
|
||||
<View>
|
||||
@@ -165,7 +165,7 @@ const TestReport = ({
|
||||
</View>
|
||||
<View style={[{ paddingBottom: 30 }, styles.separator]}></View>
|
||||
<View style={{ flexGrow: 1 }}></View>
|
||||
<TestReportFooter />
|
||||
<TestReportFooter userId={id}/>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
|
||||
21
src/hooks/useCodes.tsx
Normal file
21
src/hooks/useCodes.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import {Code, Group, User} from "@/interfaces/user";
|
||||
import axios from "axios";
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
export default function useCodes(creator?: string) {
|
||||
const [codes, setCodes] = useState<Code[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const getData = () => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<Code[]>(`/api/code${creator ? `?creator=${creator}` : ""}`)
|
||||
.then((response) => setCodes(response.data))
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
useEffect(getData, [creator]);
|
||||
|
||||
return {codes, isLoading, isError, reload: getData};
|
||||
}
|
||||
35
src/hooks/useInvites.tsx
Normal file
35
src/hooks/useInvites.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Invite } from "@/interfaces/invite";
|
||||
import { Ticket } from "@/interfaces/ticket";
|
||||
import { Code, Group, User } from "@/interfaces/user";
|
||||
import axios from "axios";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function useInvites({
|
||||
from,
|
||||
to,
|
||||
}: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
}) {
|
||||
const [invites, setInvites] = useState<Invite[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const getData = () => {
|
||||
const filters: ((i: Invite) => boolean)[] = [];
|
||||
if (from) filters.push((i: Invite) => i.from === from);
|
||||
if (to) filters.push((i: Invite) => i.to === to);
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<Invite[]>(`/api/invites`)
|
||||
.then((response) =>
|
||||
setInvites(filters.reduce((d, f) => d.filter(f), response.data)),
|
||||
)
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
useEffect(getData, [to, from]);
|
||||
|
||||
return { invites, isLoading, isError, reload: getData };
|
||||
}
|
||||
24
src/hooks/useSessions.tsx
Normal file
24
src/hooks/useSessions.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import {Exam} from "@/interfaces/exam";
|
||||
import {ExamState} from "@/stores/examStore";
|
||||
import axios from "axios";
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
export type Session = ExamState & {user: string; id: string; date: string};
|
||||
|
||||
export default function useSessions(user?: string) {
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const getData = () => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<Session[]>(`/api/sessions${user ? `?user=${user}` : ""}`)
|
||||
.then((response) => setSessions(response.data))
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
useEffect(getData, [user]);
|
||||
|
||||
return {sessions, isLoading, isError, reload: getData};
|
||||
}
|
||||
22
src/hooks/useTickets.tsx
Normal file
22
src/hooks/useTickets.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Ticket } from "@/interfaces/ticket";
|
||||
import { Code, Group, User } from "@/interfaces/user";
|
||||
import axios from "axios";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function useTickets() {
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const getData = () => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<Ticket[]>(`/api/tickets`)
|
||||
.then((response) => setTickets(response.data))
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
useEffect(getData, []);
|
||||
|
||||
return { tickets, isLoading, isError, reload: getData };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Module} from ".";
|
||||
|
||||
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
|
||||
export type Variant = "diagnostic" | "partial";
|
||||
export type Variant = "full" | "partial";
|
||||
|
||||
export interface ReadingExam {
|
||||
parts: ReadingPart[];
|
||||
@@ -103,13 +103,24 @@ export interface Evaluation {
|
||||
|
||||
interface InteractiveSpeakingEvaluation extends Evaluation {
|
||||
perfect_answer_1?: string;
|
||||
transcript_1?: string;
|
||||
fixed_text_1?: string;
|
||||
perfect_answer_2?: string;
|
||||
transcript_2?: string;
|
||||
fixed_text_2?: string;
|
||||
perfect_answer_3?: string;
|
||||
transcript_3?: string;
|
||||
fixed_text_3?: string;
|
||||
}
|
||||
|
||||
interface SpeakingEvaluation extends CommonEvaluation {
|
||||
perfect_answer_1?: string;
|
||||
transcript_1?: string;
|
||||
fixed_text_1?: string;
|
||||
}
|
||||
|
||||
interface CommonEvaluation extends Evaluation {
|
||||
perfect_answer?: string;
|
||||
perfect_answer_1?: string;
|
||||
fixed_text?: string;
|
||||
}
|
||||
|
||||
@@ -141,7 +152,7 @@ export interface SpeakingExercise {
|
||||
userSolutions: {
|
||||
id: string;
|
||||
solution: string;
|
||||
evaluation?: CommonEvaluation;
|
||||
evaluation?: SpeakingEvaluation;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -153,7 +164,7 @@ export interface InteractiveSpeakingExercise {
|
||||
prompts: {text: string; video_url: string}[];
|
||||
userSolutions: {
|
||||
id: string;
|
||||
solution: {question: string; answer: string}[];
|
||||
solution: {questionIndex: number; question: string; answer: string}[];
|
||||
evaluation?: InteractiveSpeakingEvaluation;
|
||||
}[];
|
||||
}
|
||||
|
||||
5
src/interfaces/invite.ts
Normal file
5
src/interfaces/invite.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface Invite {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
@@ -1,22 +1,23 @@
|
||||
import {Module} from "@/interfaces";
|
||||
|
||||
export interface ModuleScore {
|
||||
score: number;
|
||||
total: number;
|
||||
code: Module;
|
||||
module: Module | 'Overall';
|
||||
png?: string,
|
||||
evaluation?: string,
|
||||
suggestions?: string,
|
||||
}
|
||||
score: number;
|
||||
total: number;
|
||||
code: Module;
|
||||
module: Module | "Overall";
|
||||
png?: string;
|
||||
evaluation?: string;
|
||||
suggestions?: string;
|
||||
}
|
||||
|
||||
export interface StudentData {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
gender: string;
|
||||
date: string;
|
||||
result: string;
|
||||
level?: string;
|
||||
bandScore: number;
|
||||
}
|
||||
export interface StudentData {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
gender: string;
|
||||
date: string;
|
||||
result: string;
|
||||
level?: string;
|
||||
bandScore: number;
|
||||
passportId?: string;
|
||||
}
|
||||
|
||||
34
src/interfaces/ticket.ts
Normal file
34
src/interfaces/ticket.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Type } from "./user";
|
||||
|
||||
export interface Ticket {
|
||||
id: string;
|
||||
date: string;
|
||||
status: TicketStatus;
|
||||
type: TicketType;
|
||||
reporter: TicketReporter;
|
||||
reportedFrom: string;
|
||||
description: string;
|
||||
subject: string;
|
||||
assignedTo?: string;
|
||||
}
|
||||
|
||||
export interface TicketReporter {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
type: Type;
|
||||
}
|
||||
|
||||
export type TicketType = "feedback" | "bug" | "help";
|
||||
export const TicketTypeLabel: { [key in TicketType]: string } = {
|
||||
feedback: "Feedback",
|
||||
bug: "Bug",
|
||||
help: "Help",
|
||||
};
|
||||
|
||||
export type TicketStatus = "submitted" | "in-progress" | "completed";
|
||||
export const TicketStatusLabel: { [key in TicketStatus]: string } = {
|
||||
submitted: "Submitted",
|
||||
"in-progress": "In Progress",
|
||||
completed: "Completed",
|
||||
};
|
||||
@@ -127,5 +127,16 @@ export interface Group {
|
||||
disableEditing?: boolean;
|
||||
}
|
||||
|
||||
export interface Code {
|
||||
code: string;
|
||||
creator: string;
|
||||
expiryDate: Date;
|
||||
type: Type;
|
||||
userId?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
passport_id?: string;
|
||||
}
|
||||
|
||||
export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent";
|
||||
export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent"];
|
||||
|
||||
@@ -1,200 +1,319 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import Checkbox from "@/components/Low/Checkbox";
|
||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {Type, User} from "@/interfaces/user";
|
||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||
import { Type, User } from "@/interfaces/user";
|
||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize, uniqBy} from "lodash";
|
||||
import { capitalize, uniqBy } from "lodash";
|
||||
import moment from "moment";
|
||||
import {useEffect, useState} from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import ReactDatePicker from "react-datepicker";
|
||||
import {toast} from "react-toastify";
|
||||
import { toast } from "react-toastify";
|
||||
import ShortUniqueId from "short-unique-id";
|
||||
import {useFilePicker} from "use-file-picker";
|
||||
import { useFilePicker } from "use-file-picker";
|
||||
import readXlsxFile from "read-excel-file";
|
||||
import Modal from "@/components/Modal";
|
||||
import {BsQuestionCircleFill} from "react-icons/bs";
|
||||
import { BsFileEarmarkEaselFill, BsQuestionCircleFill } from "react-icons/bs";
|
||||
|
||||
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
||||
const EMAIL_REGEX = new RegExp(
|
||||
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/,
|
||||
);
|
||||
|
||||
const USER_TYPE_PERMISSIONS: {[key in Type]: Type[]} = {
|
||||
student: [],
|
||||
teacher: [],
|
||||
agent: [],
|
||||
corporate: ["student", "teacher"],
|
||||
admin: ["student", "teacher", "agent", "corporate", "admin"],
|
||||
developer: ["student", "teacher", "agent", "corporate", "admin", "developer"],
|
||||
const USER_TYPE_PERMISSIONS: { [key in Type]: Type[] } = {
|
||||
student: [],
|
||||
teacher: [],
|
||||
agent: [],
|
||||
corporate: ["student", "teacher"],
|
||||
admin: ["student", "teacher", "agent", "corporate", "admin"],
|
||||
developer: ["student", "teacher", "agent", "corporate", "admin", "developer"],
|
||||
};
|
||||
|
||||
export default function BatchCodeGenerator({user}: {user: User}) {
|
||||
const [infos, setInfos] = useState<{email: string; name: string; passport_id: string}[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
|
||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||
const [type, setType] = useState<Type>("student");
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
export default function BatchCodeGenerator({ user }: { user: User }) {
|
||||
const [infos, setInfos] = useState<
|
||||
{ email: string; name: string; passport_id: string }[]
|
||||
>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
|
||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||
const [type, setType] = useState<Type>("student");
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
|
||||
const {users} = useUsers();
|
||||
const { users } = useUsers();
|
||||
|
||||
const {openFilePicker, filesContent, clear} = useFilePicker({
|
||||
accept: ".xlsx",
|
||||
multiple: false,
|
||||
readAs: "ArrayBuffer",
|
||||
});
|
||||
const { openFilePicker, filesContent, clear } = useFilePicker({
|
||||
accept: ".xlsx",
|
||||
multiple: false,
|
||||
readAs: "ArrayBuffer",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (user && (user.type === "corporate" || user.type === "teacher")) {
|
||||
setExpiryDate(user.subscriptionExpirationDate || null);
|
||||
}
|
||||
}, [user]);
|
||||
useEffect(() => {
|
||||
if (user && (user.type === "corporate" || user.type === "teacher")) {
|
||||
setExpiryDate(user.subscriptionExpirationDate || null);
|
||||
setIsExpiryDateEnabled(!!user.subscriptionExpirationDate);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||
}, [isExpiryDateEnabled]);
|
||||
useEffect(() => {
|
||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||
}, [isExpiryDateEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filesContent.length > 0) {
|
||||
const file = filesContent[0];
|
||||
readXlsxFile(file.content).then((rows) => {
|
||||
const information = uniqBy(
|
||||
rows
|
||||
.map((row) => {
|
||||
const [firstName, lastName, country, passport_id, email, phone] = row as string[];
|
||||
return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email)
|
||||
? {
|
||||
email: email.toString(),
|
||||
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
|
||||
passport_id: passport_id.toString(),
|
||||
}
|
||||
: undefined;
|
||||
})
|
||||
.filter((x) => !!x) as typeof infos,
|
||||
(x) => x.email,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (filesContent.length > 0) {
|
||||
const file = filesContent[0];
|
||||
readXlsxFile(file.content).then((rows) => {
|
||||
try {
|
||||
const information = uniqBy(
|
||||
rows
|
||||
.map((row) => {
|
||||
const [
|
||||
firstName,
|
||||
lastName,
|
||||
country,
|
||||
passport_id,
|
||||
email,
|
||||
...phone
|
||||
] = row as string[];
|
||||
return EMAIL_REGEX.test(email.toString().trim())
|
||||
? {
|
||||
email: email.toString().trim().toLowerCase(),
|
||||
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
|
||||
passport_id: passport_id?.toString().trim() || undefined,
|
||||
}
|
||||
: undefined;
|
||||
})
|
||||
.filter((x) => !!x) as typeof infos,
|
||||
(x) => x.email,
|
||||
);
|
||||
|
||||
if (information.length === 0) {
|
||||
toast.error(
|
||||
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
|
||||
);
|
||||
return clear();
|
||||
}
|
||||
if (information.length === 0) {
|
||||
toast.error(
|
||||
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
|
||||
);
|
||||
return clear();
|
||||
}
|
||||
|
||||
setInfos(information);
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filesContent]);
|
||||
setInfos(information);
|
||||
} catch {
|
||||
toast.error(
|
||||
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
|
||||
);
|
||||
return clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filesContent]);
|
||||
|
||||
const generateCode = (type: Type) => {
|
||||
const uid = new ShortUniqueId();
|
||||
const codes = infos.map(() => uid.randomUUID(6));
|
||||
const generateAndInvite = async () => {
|
||||
const newUsers = infos.filter(
|
||||
(x) => !users.map((u) => u.email).includes(x.email),
|
||||
);
|
||||
const existingUsers = infos
|
||||
.filter((x) => users.map((u) => u.email).includes(x.email))
|
||||
.map((i) => users.find((u) => u.email === i.email))
|
||||
.filter((x) => !!x && x.type === "student") as User[];
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.post("/api/code", {type, codes, infos: infos, expiryDate})
|
||||
.then(({data, status}) => {
|
||||
if (data.ok) {
|
||||
toast.success(`Successfully generated ${capitalize(type)} codes and they have been notified by e-mail!`, {toastId: "success"});
|
||||
return;
|
||||
}
|
||||
const newUsersSentence =
|
||||
newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined;
|
||||
const existingUsersSentence =
|
||||
existingUsers.length > 0
|
||||
? `invite ${existingUsers.length} registered student(s)`
|
||||
: undefined;
|
||||
if (
|
||||
!confirm(
|
||||
`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, {toastId: "forbidden"});
|
||||
}
|
||||
})
|
||||
.catch(({response: {status, data}}) => {
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, {toastId: "forbidden"});
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
Promise.all(
|
||||
existingUsers.map(
|
||||
async (u) =>
|
||||
await axios.post(`/api/invites`, { to: u.id, from: user.id }),
|
||||
),
|
||||
)
|
||||
.then(() =>
|
||||
toast.success(
|
||||
`Successfully invited ${existingUsers.length} registered student(s)!`,
|
||||
),
|
||||
)
|
||||
.finally(() => {
|
||||
if (newUsers.length === 0) setIsLoading(false);
|
||||
});
|
||||
|
||||
toast.error(`Something went wrong, please try again later!`, {toastId: "error"});
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
if (newUsers.length > 0) generateCode(type, newUsers);
|
||||
setInfos([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={showHelp} onClose={() => setShowHelp(false)} title="Excel File Format">
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<span>Please upload an Excel file with the following format:</span>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border border-neutral-200 px-2 py-1">First Name</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">Last Name</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">Country</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">Passport/National ID</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">E-mail</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">Phone Number</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
<span className="mt-4">
|
||||
<b>Notes:</b>
|
||||
<ul>
|
||||
<li>- All incorrect e-mails will be ignored;</li>
|
||||
<li>- All already registered e-mails will be ignored;</li>
|
||||
<li>- You may have a header row with the format above, however, it is not necessary;</li>
|
||||
<li>- All of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below.</li>
|
||||
</ul>
|
||||
</span>
|
||||
</div>
|
||||
</Modal>
|
||||
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
||||
<div className="flex justify-between items-end">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Choose an Excel file</label>
|
||||
<div className="cursor-pointer tooltip" data-tip="Excel File Format" onClick={() => setShowHelp(true)}>
|
||||
<BsQuestionCircleFill />
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
|
||||
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
||||
</Button>
|
||||
{user && (user.type === "developer" || user.type === "admin") && (
|
||||
<>
|
||||
<div className="flex -md:flex-row md:flex-col -md:items-center 2xl:flex-row 2xl:items-center justify-between gap-2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
|
||||
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled}>
|
||||
Enabled
|
||||
</Checkbox>
|
||||
</div>
|
||||
{isExpiryDateEnabled && (
|
||||
<ReactDatePicker
|
||||
className={clsx(
|
||||
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"hover:border-mti-purple tooltip",
|
||||
"transition duration-300 ease-in-out",
|
||||
)}
|
||||
filterDate={(date) => moment(date).isAfter(new Date())}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={expiryDate}
|
||||
onChange={(date) => setExpiryDate(date)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<label className="font-normal text-base text-mti-gray-dim">Select the type of user they should be</label>
|
||||
{user && (
|
||||
<select
|
||||
defaultValue="student"
|
||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
||||
{Object.keys(USER_TYPE_LABELS)
|
||||
.filter((x) => USER_TYPE_PERMISSIONS[user.type].includes(x as Type))
|
||||
.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Button onClick={() => generateCode(type)} disabled={infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}>
|
||||
Generate & Send
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
const generateCode = (type: Type, informations: typeof infos) => {
|
||||
const uid = new ShortUniqueId();
|
||||
const codes = informations.map(() => uid.randomUUID(6));
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.post<{ ok: boolean; valid?: number; reason?: string }>("/api/code", {
|
||||
type,
|
||||
codes,
|
||||
infos: informations,
|
||||
expiryDate,
|
||||
})
|
||||
.then(({ data, status }) => {
|
||||
if (data.ok) {
|
||||
toast.success(
|
||||
`Successfully generated${data.valid ? ` ${data.valid}/${informations.length}` : ""} ${capitalize(
|
||||
type,
|
||||
)} codes and they have been notified by e-mail!`,
|
||||
{ toastId: "success" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, { toastId: "forbidden" });
|
||||
}
|
||||
})
|
||||
.catch(({ response: { status, data } }) => {
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, { toastId: "forbidden" });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(`Something went wrong, please try again later!`, {
|
||||
toastId: "error",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
return clear();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={showHelp}
|
||||
onClose={() => setShowHelp(false)}
|
||||
title="Excel File Format"
|
||||
>
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<span>Please upload an Excel file with the following format:</span>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border border-neutral-200 px-2 py-1">
|
||||
First Name
|
||||
</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">
|
||||
Last Name
|
||||
</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">Country</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">
|
||||
Passport/National ID
|
||||
</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">E-mail</th>
|
||||
<th className="border border-neutral-200 px-2 py-1">
|
||||
Phone Number
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
<span className="mt-4">
|
||||
<b>Notes:</b>
|
||||
<ul>
|
||||
<li>- All incorrect e-mails will be ignored;</li>
|
||||
<li>- All already registered e-mails will be ignored;</li>
|
||||
<li>
|
||||
- You may have a header row with the format above, however, it
|
||||
is not necessary;
|
||||
</li>
|
||||
<li>
|
||||
- All of the e-mails in the file will receive an e-mail to join
|
||||
EnCoach with the role selected below.
|
||||
</li>
|
||||
</ul>
|
||||
</span>
|
||||
</div>
|
||||
</Modal>
|
||||
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
|
||||
<div className="flex items-end justify-between">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Choose an Excel file
|
||||
</label>
|
||||
<div
|
||||
className="tooltip cursor-pointer"
|
||||
data-tip="Excel File Format"
|
||||
onClick={() => setShowHelp(true)}
|
||||
>
|
||||
<BsQuestionCircleFill />
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={openFilePicker}
|
||||
isLoading={isLoading}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
||||
</Button>
|
||||
{user && (user.type === "developer" || user.type === "admin") && (
|
||||
<>
|
||||
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Expiry Date
|
||||
</label>
|
||||
<Checkbox
|
||||
isChecked={isExpiryDateEnabled}
|
||||
onChange={setIsExpiryDateEnabled}
|
||||
>
|
||||
Enabled
|
||||
</Checkbox>
|
||||
</div>
|
||||
{isExpiryDateEnabled && (
|
||||
<ReactDatePicker
|
||||
className={clsx(
|
||||
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||
"hover:border-mti-purple tooltip",
|
||||
"transition duration-300 ease-in-out",
|
||||
)}
|
||||
filterDate={(date) => moment(date).isAfter(new Date())}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={expiryDate}
|
||||
onChange={(date) => setExpiryDate(date)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Select the type of user they should be
|
||||
</label>
|
||||
{user && (
|
||||
<select
|
||||
defaultValue="student"
|
||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none"
|
||||
>
|
||||
{Object.keys(USER_TYPE_LABELS)
|
||||
.filter((x) =>
|
||||
USER_TYPE_PERMISSIONS[user.type].includes(x as Type),
|
||||
)
|
||||
.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Button
|
||||
onClick={generateAndInvite}
|
||||
disabled={
|
||||
infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)
|
||||
}
|
||||
>
|
||||
Generate & Send
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,299 +1,402 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import Checkbox from "@/components/Low/Checkbox";
|
||||
import Input from "@/components/Low/Input";
|
||||
import Modal from "@/components/Modal";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {Module} from "@/interfaces";
|
||||
import {Group, User} from "@/interfaces/user";
|
||||
import {Disclosure, Transition} from "@headlessui/react";
|
||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
||||
import { Group, User } from "@/interfaces/user";
|
||||
import {
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize, uniq, uniqBy} from "lodash";
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import {BsCheck, BsDash, BsPencil, BsPlus, BsQuestionCircleFill, BsTrash} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import { capitalize, uniq } from "lodash";
|
||||
import { useEffect, useState } from "react";
|
||||
import { BsPencil, BsQuestionCircleFill, BsTrash } from "react-icons/bs";
|
||||
import Select from "react-select";
|
||||
import {uuidv4} from "@firebase/util";
|
||||
import {useFilePicker} from "use-file-picker";
|
||||
import Modal from "@/components/Modal";
|
||||
import { toast } from "react-toastify";
|
||||
import readXlsxFile from "read-excel-file";
|
||||
import { useFilePicker } from "use-file-picker";
|
||||
|
||||
const columnHelper = createColumnHelper<Group>();
|
||||
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
||||
const EMAIL_REGEX = new RegExp(
|
||||
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/,
|
||||
);
|
||||
|
||||
interface CreateDialogProps {
|
||||
user: User;
|
||||
users: User[];
|
||||
group?: Group;
|
||||
onClose: () => void;
|
||||
user: User;
|
||||
users: User[];
|
||||
group?: Group;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
||||
const [name, setName] = useState<string | undefined>(group?.name || undefined);
|
||||
const [admin, setAdmin] = useState<string>(group?.admin || user.id);
|
||||
const [participants, setParticipants] = useState<string[]>(group?.participants || []);
|
||||
const {openFilePicker, filesContent, clear} = useFilePicker({
|
||||
accept: ".xlsx",
|
||||
multiple: false,
|
||||
readAs: "ArrayBuffer",
|
||||
});
|
||||
const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
||||
const [name, setName] = useState<string | undefined>(
|
||||
group?.name || undefined,
|
||||
);
|
||||
const [admin, setAdmin] = useState<string>(group?.admin || user.id);
|
||||
const [participants, setParticipants] = useState<string[]>(
|
||||
group?.participants || [],
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (filesContent.length > 0) {
|
||||
const file = filesContent[0];
|
||||
readXlsxFile(file.content).then((rows) => {
|
||||
const emails = uniq(
|
||||
rows
|
||||
.map((row) => {
|
||||
const [email] = row as string[];
|
||||
return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email) ? email.toString().trim() : undefined;
|
||||
})
|
||||
.filter((x) => !!x),
|
||||
);
|
||||
const { openFilePicker, filesContent, clear } = useFilePicker({
|
||||
accept: ".xlsx",
|
||||
multiple: false,
|
||||
readAs: "ArrayBuffer",
|
||||
});
|
||||
|
||||
if (emails.length === 0) {
|
||||
toast.error("Please upload an Excel file containing e-mails!");
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (filesContent.length > 0) {
|
||||
setIsLoading(true);
|
||||
|
||||
const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined);
|
||||
const filteredUsers = emailUsers.filter(
|
||||
(x) =>
|
||||
((user.type === "developer" || user.type === "admin" || user.type === "corporate") &&
|
||||
(x?.type === "student" || x?.type === "teacher")) ||
|
||||
(user.type === "teacher" && x?.type === "student"),
|
||||
);
|
||||
const file = filesContent[0];
|
||||
readXlsxFile(file.content).then((rows) => {
|
||||
const emails = uniq(
|
||||
rows
|
||||
.map((row) => {
|
||||
const [email] = row as string[];
|
||||
return EMAIL_REGEX.test(email) &&
|
||||
!users.map((u) => u.email).includes(email)
|
||||
? email.toString().trim()
|
||||
: undefined;
|
||||
})
|
||||
.filter((x) => !!x),
|
||||
);
|
||||
|
||||
setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id));
|
||||
toast.success(
|
||||
user.type !== "teacher"
|
||||
? "Added all teachers and students found in the file you've provided!"
|
||||
: "Added all students found in the file you've provided!",
|
||||
{toastId: "upload-success"},
|
||||
);
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filesContent, user.type, users]);
|
||||
if (emails.length === 0) {
|
||||
toast.error("Please upload an Excel file containing e-mails!");
|
||||
clear();
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
if (name !== group?.name && (name === "Students" || name === "Teachers")) {
|
||||
toast.error("That group name is reserved and cannot be used, please enter another one.");
|
||||
return;
|
||||
}
|
||||
const emailUsers = [...new Set(emails)]
|
||||
.map((x) => users.find((y) => y.email.toLowerCase() === x))
|
||||
.filter((x) => x !== undefined);
|
||||
const filteredUsers = emailUsers.filter(
|
||||
(x) =>
|
||||
((user.type === "developer" ||
|
||||
user.type === "admin" ||
|
||||
user.type === "corporate") &&
|
||||
(x?.type === "student" || x?.type === "teacher")) ||
|
||||
(user.type === "teacher" && x?.type === "student"),
|
||||
);
|
||||
|
||||
(group ? axios.patch : axios.post)(group ? `/api/groups/${group.id}` : "/api/groups", {name, admin, participants})
|
||||
.then(() => {
|
||||
toast.success(`Group "${name}" ${group ? "edited" : "created"} successfully`);
|
||||
return true;
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
return false;
|
||||
})
|
||||
.finally(onClose);
|
||||
};
|
||||
setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id));
|
||||
toast.success(
|
||||
user.type !== "teacher"
|
||||
? "Added all teachers and students found in the file you've provided!"
|
||||
: "Added all students found in the file you've provided!",
|
||||
{ toastId: "upload-success" },
|
||||
);
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filesContent, user.type, users]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-12 mt-4 w-full px-4 py-2">
|
||||
<div className="flex flex-col gap-8">
|
||||
<Input name="name" type="text" label="Name" defaultValue={name} onChange={setName} required disabled={group?.disableEditing} />
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<div className="flex gap-2 items-center">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Participants</label>
|
||||
<div className="tooltip" data-tip="The Excel file should only include a column with the desired e-mails.">
|
||||
<BsQuestionCircleFill />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-8 w-full">
|
||||
<Select
|
||||
className="w-full"
|
||||
value={participants.map((x) => ({
|
||||
value: x,
|
||||
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
|
||||
}))}
|
||||
placeholder="Participants..."
|
||||
defaultValue={participants.map((x) => ({
|
||||
value: x,
|
||||
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
|
||||
}))}
|
||||
options={users
|
||||
.filter((x) => (user.type === "teacher" ? x.type === "student" : x.type === "student" || x.type === "teacher"))
|
||||
.map((x) => ({value: x.id, label: `${x.email} - ${x.name}`}))}
|
||||
onChange={(value) => setParticipants(value.map((x) => x.value))}
|
||||
isMulti
|
||||
isSearchable
|
||||
styles={{
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
backgroundColor: "white",
|
||||
borderRadius: "999px",
|
||||
padding: "1rem 1.5rem",
|
||||
zIndex: "40",
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
{user.type !== "teacher" && (
|
||||
<Button className="w-full max-w-[300px]" onClick={openFilePicker} variant="outline">
|
||||
{filesContent.length === 0 ? "Upload participants Excel file" : filesContent[0].name}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full justify-end items-center gap-8 mt-8">
|
||||
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full max-w-[200px]" onClick={submit} disabled={!name}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const submit = () => {
|
||||
setIsLoading(true);
|
||||
|
||||
if (name !== group?.name && (name === "Students" || name === "Teachers")) {
|
||||
toast.error(
|
||||
"That group name is reserved and cannot be used, please enter another one.",
|
||||
);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
(group ? axios.patch : axios.post)(
|
||||
group ? `/api/groups/${group.id}` : "/api/groups",
|
||||
{ name, admin, participants },
|
||||
)
|
||||
.then(() => {
|
||||
toast.success(
|
||||
`Group "${name}" ${group ? "edited" : "created"} successfully`,
|
||||
);
|
||||
return true;
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
return false;
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
onClose();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex w-full flex-col gap-12 px-4 py-2">
|
||||
<div className="flex flex-col gap-8">
|
||||
<Input
|
||||
name="name"
|
||||
type="text"
|
||||
label="Name"
|
||||
defaultValue={name}
|
||||
onChange={setName}
|
||||
required
|
||||
disabled={group?.disableEditing}
|
||||
/>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Participants
|
||||
</label>
|
||||
<div
|
||||
className="tooltip"
|
||||
data-tip="The Excel file should only include a column with the desired e-mails."
|
||||
>
|
||||
<BsQuestionCircleFill />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full gap-8">
|
||||
<Select
|
||||
className="w-full"
|
||||
value={participants.map((x) => ({
|
||||
value: x,
|
||||
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
|
||||
}))}
|
||||
placeholder="Participants..."
|
||||
defaultValue={participants.map((x) => ({
|
||||
value: x,
|
||||
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
|
||||
}))}
|
||||
options={users
|
||||
.filter((x) =>
|
||||
user.type === "teacher"
|
||||
? x.type === "student"
|
||||
: x.type === "student" || x.type === "teacher",
|
||||
)
|
||||
.map((x) => ({ value: x.id, label: `${x.email} - ${x.name}` }))}
|
||||
onChange={(value) => setParticipants(value.map((x) => x.value))}
|
||||
isMulti
|
||||
isSearchable
|
||||
menuPortalTarget={document?.body}
|
||||
styles={{
|
||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
backgroundColor: "white",
|
||||
borderRadius: "999px",
|
||||
padding: "1rem 1.5rem",
|
||||
zIndex: "40",
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
{user.type !== "teacher" && (
|
||||
<Button
|
||||
className="w-full max-w-[300px]"
|
||||
onClick={openFilePicker}
|
||||
isLoading={isLoading}
|
||||
variant="outline"
|
||||
>
|
||||
{filesContent.length === 0
|
||||
? "Upload participants Excel file"
|
||||
: filesContent[0].name}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 flex w-full items-center justify-end gap-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
className="w-full max-w-[200px]"
|
||||
isLoading={isLoading}
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full max-w-[200px]"
|
||||
onClick={submit}
|
||||
isLoading={isLoading}
|
||||
disabled={!name}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const filterTypes = ["corporate", "teacher"];
|
||||
|
||||
export default function GroupList({user}: {user: User}) {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [editingGroup, setEditingGroup] = useState<Group>();
|
||||
const [filterByUser, setFilterByUser] = useState(false);
|
||||
export default function GroupList({ user }: { user: User }) {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [editingGroup, setEditingGroup] = useState<Group>();
|
||||
const [filterByUser, setFilterByUser] = useState(false);
|
||||
|
||||
const {users} = useUsers();
|
||||
const {groups, reload} = useGroups(user && filterTypes.includes(user?.type) ? user.id : undefined);
|
||||
const { users } = useUsers();
|
||||
const { groups, reload } = useGroups(
|
||||
user && filterTypes.includes(user?.type) ? user.id : undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (user && (user.type === "corporate" || user.type === "teacher")) {
|
||||
setFilterByUser(true);
|
||||
}
|
||||
}, [user]);
|
||||
useEffect(() => {
|
||||
if (user && (user.type === "corporate" || user.type === "teacher")) {
|
||||
setFilterByUser(true);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const deleteGroup = (group: Group) => {
|
||||
if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return;
|
||||
const deleteGroup = (group: Group) => {
|
||||
if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return;
|
||||
|
||||
axios
|
||||
.delete<{ok: boolean}>(`/api/groups/${group.id}`)
|
||||
.then(() => toast.success(`Group "${group.name}" deleted successfully`))
|
||||
.catch(() => toast.error("Something went wrong, please try again later!"))
|
||||
.finally(reload);
|
||||
};
|
||||
axios
|
||||
.delete<{ ok: boolean }>(`/api/groups/${group.id}`)
|
||||
.then(() => toast.success(`Group "${group.name}" deleted successfully`))
|
||||
.catch(() => toast.error("Something went wrong, please try again later!"))
|
||||
.finally(reload);
|
||||
};
|
||||
|
||||
const defaultColumns = [
|
||||
columnHelper.accessor("id", {
|
||||
header: "ID",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("name", {
|
||||
header: "Name",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("admin", {
|
||||
header: "Admin",
|
||||
cell: (info) => (
|
||||
<div className="tooltip" data-tip={capitalize(users.find((x) => x.id === info.getValue())?.type)}>
|
||||
{users.find((x) => x.id === info.getValue())?.name}
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("participants", {
|
||||
header: "Participants",
|
||||
cell: (info) =>
|
||||
info
|
||||
.getValue()
|
||||
.map((x) => users.find((y) => y.id === x)?.name)
|
||||
.join(", "),
|
||||
}),
|
||||
{
|
||||
header: "",
|
||||
id: "actions",
|
||||
cell: ({row}: {row: {original: Group}}) => {
|
||||
return (
|
||||
<>
|
||||
{user && (user.type === "developer" || user.type === "admin" || user.id === row.original.admin) && (
|
||||
<div className="flex gap-2">
|
||||
{!row.original.disableEditing && (
|
||||
<div data-tip="Edit" className="cursor-pointer tooltip" onClick={() => setEditingGroup(row.original)}>
|
||||
<BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
)}
|
||||
{!row.original.disableEditing && (
|
||||
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteGroup(row.original)}>
|
||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
const defaultColumns = [
|
||||
columnHelper.accessor("id", {
|
||||
header: "ID",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("name", {
|
||||
header: "Name",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("admin", {
|
||||
header: "Admin",
|
||||
cell: (info) => (
|
||||
<div
|
||||
className="tooltip"
|
||||
data-tip={capitalize(
|
||||
users.find((x) => x.id === info.getValue())?.type,
|
||||
)}
|
||||
>
|
||||
{users.find((x) => x.id === info.getValue())?.name}
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("participants", {
|
||||
header: "Participants",
|
||||
cell: (info) =>
|
||||
info
|
||||
.getValue()
|
||||
.map((x) => users.find((y) => y.id === x)?.name)
|
||||
.join(", "),
|
||||
}),
|
||||
{
|
||||
header: "",
|
||||
id: "actions",
|
||||
cell: ({ row }: { row: { original: Group } }) => {
|
||||
return (
|
||||
<>
|
||||
{user &&
|
||||
(user.type === "developer" ||
|
||||
user.type === "admin" ||
|
||||
user.id === row.original.admin) && (
|
||||
<div className="flex gap-2">
|
||||
{(!row.original.disableEditing ||
|
||||
["developer", "admin"].includes(user.type)) && (
|
||||
<div
|
||||
data-tip="Edit"
|
||||
className="tooltip cursor-pointer"
|
||||
onClick={() => setEditingGroup(row.original)}
|
||||
>
|
||||
<BsPencil className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
||||
</div>
|
||||
)}
|
||||
{(!row.original.disableEditing ||
|
||||
["developer", "admin"].includes(user.type)) && (
|
||||
<div
|
||||
data-tip="Delete"
|
||||
className="tooltip cursor-pointer"
|
||||
onClick={() => deleteGroup(row.original)}
|
||||
>
|
||||
<BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: groups,
|
||||
columns: defaultColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
const table = useReactTable({
|
||||
data: groups,
|
||||
columns: defaultColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const closeModal = () => {
|
||||
setIsCreating(false);
|
||||
setEditingGroup(undefined);
|
||||
reload();
|
||||
};
|
||||
const closeModal = () => {
|
||||
setIsCreating(false);
|
||||
setEditingGroup(undefined);
|
||||
reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full rounded-xl">
|
||||
<Modal isOpen={isCreating || !!editingGroup} onClose={closeModal} title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}>
|
||||
<CreatePanel
|
||||
group={editingGroup}
|
||||
user={user}
|
||||
onClose={closeModal}
|
||||
users={
|
||||
user?.type === "corporate" || user?.type === "teacher"
|
||||
? users.filter(
|
||||
(u) =>
|
||||
groups
|
||||
.filter((g) => g.admin === user.id)
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(u.id) || groups.flatMap((g) => g.participants).includes(u.id),
|
||||
)
|
||||
: users
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th className="py-4" key={header.id}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="px-2">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td className="px-4 py-2" key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
return (
|
||||
<div className="h-full w-full rounded-xl">
|
||||
<Modal
|
||||
isOpen={isCreating || !!editingGroup}
|
||||
onClose={closeModal}
|
||||
title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}
|
||||
>
|
||||
<CreatePanel
|
||||
group={editingGroup}
|
||||
user={user}
|
||||
onClose={closeModal}
|
||||
users={
|
||||
user?.type === "corporate" || user?.type === "teacher"
|
||||
? users.filter(
|
||||
(u) =>
|
||||
groups
|
||||
.filter((g) => g.admin === user.id)
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(u.id) ||
|
||||
groups.flatMap((g) => g.participants).includes(u.id),
|
||||
)
|
||||
: users
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
<table className="bg-mti-purple-ultralight/40 w-full rounded-xl">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th className="py-4" key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="px-2">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr
|
||||
className="even:bg-mti-purple-ultralight/40 rounded-lg py-2 odd:bg-white"
|
||||
key={row.id}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td className="px-4 py-2" key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white">
|
||||
New Group
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out"
|
||||
>
|
||||
New Group
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,7 +71,9 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
|
||||
defaultValue={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
|
||||
onChange={(value) => setCurrency(value?.value || "EUR")}
|
||||
value={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
|
||||
menuPortalTarget={document?.body}
|
||||
styles={{
|
||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
@@ -105,7 +107,9 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
|
||||
defaultValue={{value: "months", label: "Months"}}
|
||||
onChange={(value) => setUnit((value?.value as DurationUnit) || "months")}
|
||||
value={{value: unit, label: capitalize(unit)}}
|
||||
menuPortalTarget={document?.body}
|
||||
styles={{
|
||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
|
||||
@@ -1,60 +1,133 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Head from "next/head";
|
||||
import {useEffect, useState} from "react";
|
||||
import {Module} from "@/interfaces";
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
import Selection from "@/exams/Selection";
|
||||
import Reading from "@/exams/Reading";
|
||||
import {Exam, InteractiveSpeakingExercise, SpeakingExercise, UserSolution, WritingExercise} from "@/interfaces/exam";
|
||||
import Listening from "@/exams/Listening";
|
||||
import Writing from "@/exams/Writing";
|
||||
import {ToastContainer, toast} from "react-toastify";
|
||||
import Finish from "@/exams/Finish";
|
||||
import axios from "axios";
|
||||
import {Stat} from "@/interfaces/user";
|
||||
import Speaking from "@/exams/Speaking";
|
||||
import {v4 as uuidv4} from "uuid";
|
||||
import useUser from "@/hooks/useUser";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import Layout from "@/components/High/Layout";
|
||||
import AbandonPopup from "@/components/AbandonPopup";
|
||||
import {evaluateSpeakingAnswer, evaluateWritingAnswer} from "@/utils/evaluation";
|
||||
import {useRouter} from "next/router";
|
||||
import {getExam} from "@/utils/exams";
|
||||
import {capitalize} from "lodash";
|
||||
import Layout from "@/components/High/Layout";
|
||||
import Finish from "@/exams/Finish";
|
||||
import Level from "@/exams/Level";
|
||||
import Listening from "@/exams/Listening";
|
||||
import Reading from "@/exams/Reading";
|
||||
import Selection from "@/exams/Selection";
|
||||
import Speaking from "@/exams/Speaking";
|
||||
import Writing from "@/exams/Writing";
|
||||
import useUser from "@/hooks/useUser";
|
||||
import {Exam, UserSolution, Variant} from "@/interfaces/exam";
|
||||
import {Stat} from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {evaluateSpeakingAnswer, evaluateWritingAnswer} from "@/utils/evaluation";
|
||||
import {defaultExamUserSolutions, getExam} from "@/utils/exams";
|
||||
import axios from "axios";
|
||||
import {useRouter} from "next/router";
|
||||
import {toast, ToastContainer} from "react-toastify";
|
||||
import {v4 as uuidv4} from "uuid";
|
||||
import useSessions from "@/hooks/useSessions";
|
||||
import ShortUniqueId from "short-unique-id";
|
||||
|
||||
interface Props {
|
||||
page: "exams" | "exercises";
|
||||
}
|
||||
|
||||
export default function ExamPage({page}: Props) {
|
||||
const [hasBeenUploaded, setHasBeenUploaded] = useState(false);
|
||||
const [moduleIndex, setModuleIndex] = useState(0);
|
||||
const [sessionId, setSessionId] = useState("");
|
||||
const [exam, setExam] = useState<Exam>();
|
||||
const [isEvaluationLoading, setIsEvaluationLoading] = useState(false);
|
||||
const [showAbandonPopup, setShowAbandonPopup] = useState(false);
|
||||
const [variant, setVariant] = useState<Variant>("full");
|
||||
const [avoidRepeated, setAvoidRepeated] = useState(false);
|
||||
const [timeSpent, setTimeSpent] = useState(0);
|
||||
const [hasBeenUploaded, setHasBeenUploaded] = useState(false);
|
||||
const [showAbandonPopup, setShowAbandonPopup] = useState(false);
|
||||
const [isEvaluationLoading, setIsEvaluationLoading] = useState(false);
|
||||
const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<string[]>([]);
|
||||
const [timeSpent, setTimeSpent] = useState(0);
|
||||
|
||||
const [exams, setExams] = useExamStore((state) => [state.exams, state.setExams]);
|
||||
const [userSolutions, setUserSolutions] = useExamStore((state) => [state.userSolutions, state.setUserSolutions]);
|
||||
const [showSolutions, setShowSolutions] = useExamStore((state) => [state.showSolutions, state.setShowSolutions]);
|
||||
const [selectedModules, setSelectedModules] = useExamStore((state) => [state.selectedModules, state.setSelectedModules]);
|
||||
const resetStore = useExamStore((state) => state.reset);
|
||||
const assignment = useExamStore((state) => state.assignment);
|
||||
const initialTimeSpent = useExamStore((state) => state.timeSpent);
|
||||
|
||||
const {exam, setExam} = useExamStore((state) => state);
|
||||
const {exams, setExams} = useExamStore((state) => state);
|
||||
const {sessionId, setSessionId} = useExamStore((state) => state);
|
||||
const {partIndex, setPartIndex} = useExamStore((state) => state);
|
||||
const {moduleIndex, setModuleIndex} = useExamStore((state) => state);
|
||||
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
|
||||
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
||||
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||
const {showSolutions, setShowSolutions} = useExamStore((state) => state);
|
||||
const {selectedModules, setSelectedModules} = useExamStore((state) => state);
|
||||
|
||||
const {user} = useUser({redirectTo: "/login"});
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => setSessionId(uuidv4()), []);
|
||||
const reset = () => {
|
||||
resetStore();
|
||||
setVariant("full");
|
||||
setAvoidRepeated(false);
|
||||
setHasBeenUploaded(false);
|
||||
setShowAbandonPopup(false);
|
||||
setIsEvaluationLoading(false);
|
||||
setStatsAwaitingEvaluation([]);
|
||||
setTimeSpent(0);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const saveSession = async () => {
|
||||
console.log("Saving your session...");
|
||||
|
||||
await axios.post("/api/sessions", {
|
||||
id: sessionId,
|
||||
sessionId,
|
||||
date: new Date().toISOString(),
|
||||
userSolutions,
|
||||
moduleIndex,
|
||||
selectedModules,
|
||||
assignment,
|
||||
timeSpent,
|
||||
exams,
|
||||
exam,
|
||||
partIndex,
|
||||
exerciseIndex,
|
||||
questionIndex,
|
||||
user: user?.id,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => setTimeSpent((prev) => prev + initialTimeSpent), [initialTimeSpent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userSolutions.length === 0 && exams.length > 0) {
|
||||
const defaultSolutions = exams.map(defaultExamUserSolutions).flat();
|
||||
setUserSolutions(defaultSolutions);
|
||||
}
|
||||
}, [exams, setUserSolutions, userSolutions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
sessionId.length > 0 &&
|
||||
userSolutions.length > 0 &&
|
||||
selectedModules.length > 0 &&
|
||||
exams.length > 0 &&
|
||||
!!exam &&
|
||||
timeSpent > 0 &&
|
||||
!showSolutions &&
|
||||
moduleIndex < selectedModules.length
|
||||
)
|
||||
saveSession();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [assignment, exam, exams, moduleIndex, selectedModules, sessionId, userSolutions, user, exerciseIndex, partIndex, questionIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (timeSpent % 20 === 0 && timeSpent > 0 && moduleIndex < selectedModules.length && !showSolutions) saveSession();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [timeSpent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedModules.length > 0 && sessionId.length === 0) {
|
||||
const shortUID = new ShortUniqueId();
|
||||
setSessionId(shortUID.randomUUID(8));
|
||||
}
|
||||
}, [setSessionId, selectedModules, sessionId]);
|
||||
useEffect(() => {
|
||||
if (user?.type === "developer") console.log(exam);
|
||||
}, [exam, user]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedModules.length > 0 && timeSpent === 0 && !showSolutions;
|
||||
if (selectedModules.length > 0 && timeSpent === 0 && !showSolutions) {
|
||||
const timerInterval = setInterval(() => {
|
||||
setTimeSpent((prev) => prev + 1);
|
||||
@@ -69,12 +142,15 @@ export default function ExamPage({page}: Props) {
|
||||
|
||||
useEffect(() => {
|
||||
if (showSolutions) setModuleIndex(-1);
|
||||
}, [showSolutions]);
|
||||
}, [setModuleIndex, showSolutions]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) {
|
||||
const nextExam = exams[moduleIndex];
|
||||
|
||||
if (partIndex === -1 && nextExam.module !== "listening") setPartIndex(0);
|
||||
if (exerciseIndex === -1 && !["reading", "listening"].includes(nextExam.module)) setExerciseIndex(0);
|
||||
setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined);
|
||||
}
|
||||
})();
|
||||
@@ -84,7 +160,7 @@ export default function ExamPage({page}: Props) {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (selectedModules.length > 0 && exams.length === 0) {
|
||||
const examPromises = selectedModules.map((module) => getExam(module, avoidRepeated));
|
||||
const examPromises = selectedModules.map((module) => getExam(module, avoidRepeated, variant));
|
||||
Promise.all(examPromises).then((values) => {
|
||||
if (values.every((x) => !!x)) {
|
||||
setExams(values.map((x) => x!));
|
||||
@@ -105,8 +181,8 @@ export default function ExamPage({page}: Props) {
|
||||
id: solution.id || uuidv4(),
|
||||
timeSpent,
|
||||
session: sessionId,
|
||||
exam: solution.exam!,
|
||||
module: solution.module!,
|
||||
exam: exam!.id,
|
||||
module: exam!.module,
|
||||
user: user?.id || "",
|
||||
date: new Date().getTime(),
|
||||
...(assignment ? {assignment: assignment.id} : {}),
|
||||
@@ -121,37 +197,41 @@ export default function ExamPage({page}: Props) {
|
||||
}, [selectedModules, moduleIndex, hasBeenUploaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (statsAwaitingEvaluation.length === 0) return setIsEvaluationLoading(false);
|
||||
return setIsEvaluationLoading(true);
|
||||
setIsEvaluationLoading(statsAwaitingEvaluation.length !== 0);
|
||||
}, [statsAwaitingEvaluation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (statsAwaitingEvaluation.length > 0) {
|
||||
statsAwaitingEvaluation.forEach(checkIfStatHasBeenEvaluated);
|
||||
checkIfStatsHaveBeenEvaluated(statsAwaitingEvaluation);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [statsAwaitingEvaluation]);
|
||||
|
||||
const checkIfStatHasBeenEvaluated = (id: string) => {
|
||||
const checkIfStatsHaveBeenEvaluated = (ids: string[]) => {
|
||||
setTimeout(async () => {
|
||||
const statRequest = await axios.get<Stat>(`/api/stats/${id}`);
|
||||
const stat = statRequest.data;
|
||||
if (stat.solutions.every((x) => x.evaluation !== null)) {
|
||||
const userSolution: UserSolution = {
|
||||
id,
|
||||
const awaitedStats = await Promise.all(ids.map(async (id) => (await axios.get<Stat>(`/api/stats/${id}`)).data));
|
||||
const solutionsEvaluated = awaitedStats.every((stat) => stat.solutions.every((x) => x.evaluation !== null));
|
||||
if (solutionsEvaluated) {
|
||||
const statsUserSolutions: UserSolution[] = awaitedStats.map((stat) => ({
|
||||
id: stat.id,
|
||||
exercise: stat.exercise,
|
||||
score: stat.score,
|
||||
solutions: stat.solutions,
|
||||
type: stat.type,
|
||||
exam: stat.exam,
|
||||
module: stat.module,
|
||||
};
|
||||
}));
|
||||
|
||||
setUserSolutions(userSolutions.map((x) => (x.exercise === userSolution.exercise ? userSolution : x)));
|
||||
return setStatsAwaitingEvaluation((prev) => prev.filter((x) => x !== id));
|
||||
const updatedUserSolutions = userSolutions.map((x) => {
|
||||
const respectiveSolution = statsUserSolutions.find((y) => y.exercise === x.exercise);
|
||||
return respectiveSolution ? respectiveSolution : x;
|
||||
});
|
||||
|
||||
setUserSolutions(updatedUserSolutions);
|
||||
return setStatsAwaitingEvaluation((prev) => prev.filter((x) => !ids.includes(x)));
|
||||
}
|
||||
|
||||
return checkIfStatHasBeenEvaluated(id);
|
||||
return checkIfStatsHaveBeenEvaluated(ids);
|
||||
}, 5 * 1000);
|
||||
};
|
||||
|
||||
@@ -159,13 +239,21 @@ export default function ExamPage({page}: Props) {
|
||||
if (exam.module === "reading" || exam.module === "listening") {
|
||||
const parts = exam.parts.map((p) =>
|
||||
Object.assign(p, {
|
||||
exercises: p.exercises.map((x) => Object.assign(x, {userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions})),
|
||||
exercises: p.exercises.map((x) =>
|
||||
Object.assign(x, {
|
||||
userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
return Object.assign(exam, {parts});
|
||||
}
|
||||
|
||||
const exercises = exam.exercises.map((x) => Object.assign(x, {userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions}));
|
||||
const exercises = exam.exercises.map((x) =>
|
||||
Object.assign(x, {
|
||||
userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions,
|
||||
}),
|
||||
);
|
||||
return Object.assign(exam, {exercises});
|
||||
};
|
||||
|
||||
@@ -201,11 +289,17 @@ export default function ExamPage({page}: Props) {
|
||||
axios.get("/api/stats/update");
|
||||
|
||||
setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...solutions]);
|
||||
setModuleIndex((prev) => prev + 1);
|
||||
setModuleIndex(moduleIndex + 1);
|
||||
|
||||
setPartIndex(-1);
|
||||
setExerciseIndex(-1);
|
||||
setQuestionIndex(0);
|
||||
};
|
||||
|
||||
const aggregateScoresByModule = (answers: UserSolution[]): {module: Module; total: number; missing: number; correct: number}[] => {
|
||||
const scores: {[key in Module]: {total: number; missing: number; correct: number}} = {
|
||||
const scores: {
|
||||
[key in Module]: {total: number; missing: number; correct: number};
|
||||
} = {
|
||||
reading: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
@@ -234,6 +328,8 @@ export default function ExamPage({page}: Props) {
|
||||
};
|
||||
|
||||
answers.forEach((x) => {
|
||||
console.log({x});
|
||||
|
||||
scores[x.module!] = {
|
||||
total: scores[x.module!].total + x.score.total,
|
||||
correct: scores[x.module!].correct + x.score.correct,
|
||||
@@ -253,10 +349,11 @@ export default function ExamPage({page}: Props) {
|
||||
page={page}
|
||||
user={user!}
|
||||
disableSelection={page === "exams"}
|
||||
onStart={(modules, avoid) => {
|
||||
onStart={(modules: Module[], avoid: boolean, variant: Variant) => {
|
||||
setModuleIndex(0);
|
||||
setAvoidRepeated(avoid);
|
||||
setSelectedModules(modules);
|
||||
setVariant(variant);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -271,6 +368,8 @@ export default function ExamPage({page}: Props) {
|
||||
onViewResults={() => {
|
||||
setShowSolutions(true);
|
||||
setModuleIndex(0);
|
||||
setExerciseIndex(["reading", "listening"].includes(exams[0].module) ? -1 : 0);
|
||||
setPartIndex(exams[0].module === "listening" ? -1 : 0);
|
||||
setExam(exams[0]);
|
||||
}}
|
||||
scores={aggregateScoresByModule(userSolutions)}
|
||||
@@ -318,7 +417,9 @@ export default function ExamPage({page}: Props) {
|
||||
abandonPopupTitle="Leave Exercise"
|
||||
abandonPopupDescription="Are you sure you want to leave the exercise? You will lose all your progress."
|
||||
abandonConfirmButtonText="Confirm"
|
||||
onAbandon={() => router.reload()}
|
||||
onAbandon={() => {
|
||||
reset();
|
||||
}}
|
||||
onCancel={() => setShowAbandonPopup(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -19,8 +19,8 @@ const TaskTab = ({exam, setExam}: {exam?: LevelExam; setExam: (exam: LevelExam)
|
||||
axios
|
||||
.get(`/api/exam/level/generate/level`)
|
||||
.then((result) => {
|
||||
playSound("check");
|
||||
console.log(result.data);
|
||||
playSound(typeof result.data === "string" ? "error" : "check");
|
||||
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
||||
setExam(result.data);
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -77,10 +77,20 @@ const TaskTab = ({exam, setExam}: {exam?: LevelExam; setExam: (exam: LevelExam)
|
||||
<span className="font-semibold">
|
||||
{question.id}. {question.prompt}
|
||||
</span>
|
||||
<span>
|
||||
<span className="font-semibold text-ielts-level">({question.solution})</span>{" "}
|
||||
{question.options.find((o) => o.id === question.solution)?.text}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
{question.options.map((option) => (
|
||||
<span key={option.id} className={clsx(question.solution === option.id && "font-bold")}>
|
||||
<span
|
||||
className={clsx(
|
||||
"font-semibold",
|
||||
question.solution === option.id ? "text-mti-green-light" : "text-ielts-level",
|
||||
)}>
|
||||
({option.id})
|
||||
</span>{" "}
|
||||
{option.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,8 @@ import {Tab} from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {useRouter} from "next/router";
|
||||
import {useState} from "react";
|
||||
import {BsArrowRepeat} from "react-icons/bs";
|
||||
import {useEffect, useState} from "react";
|
||||
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
|
||||
const PartTab = ({part, types, index, setPart}: {part?: ListeningPart; types: string[]; index: number; setPart: (part?: ListeningPart) => void}) => {
|
||||
@@ -26,7 +26,8 @@ const PartTab = ({part, types, index, setPart}: {part?: ListeningPart; types: st
|
||||
axios
|
||||
.get(`/api/exam/listening/generate/listening_section_${index}${topic || types ? `?${url.toString()}` : ""}`)
|
||||
.then((result) => {
|
||||
playSound("check");
|
||||
playSound(typeof result.data === "string" ? "error" : "check");
|
||||
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
||||
setPart(result.data);
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -42,7 +43,7 @@ const PartTab = ({part, types, index, setPart}: {part?: ListeningPart; types: st
|
||||
<Input type="text" placeholder="Grand Canyon..." name="topic" label="Topic" onChange={setTopic} roundness="xl" defaultValue={topic} />
|
||||
<button
|
||||
onClick={generate}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || types.length === 0}
|
||||
data-tip="The passage is currently being generated"
|
||||
className={clsx(
|
||||
"bg-ielts-listening/70 border border-ielts-listening text-white w-full max-w-[200px] rounded-xl h-[70px]",
|
||||
@@ -110,10 +111,21 @@ const ListeningGeneration = () => {
|
||||
const [part2, setPart2] = useState<ListeningPart>();
|
||||
const [part3, setPart3] = useState<ListeningPart>();
|
||||
const [part4, setPart4] = useState<ListeningPart>();
|
||||
const [minTimer, setMinTimer] = useState(30);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [resultingExam, setResultingExam] = useState<ListeningExam>();
|
||||
const [types, setTypes] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const part1Timer = part1 ? 5 : 0;
|
||||
const part2Timer = part2 ? 8 : 0;
|
||||
const part3Timer = part3 ? 8 : 0;
|
||||
const part4Timer = part4 ? 9 : 0;
|
||||
|
||||
const sum = part1Timer + part2Timer + part3Timer + part4Timer;
|
||||
setMinTimer(sum > 0 ? sum : 5);
|
||||
}, [part1, part2, part3, part4]);
|
||||
|
||||
const availableTypes = [
|
||||
{type: "multipleChoice", label: "Multiple Choice"},
|
||||
{type: "writeBlanksQuestions", label: "Write the Blanks: Questions"},
|
||||
@@ -129,12 +141,14 @@ const ListeningGeneration = () => {
|
||||
const toggleType = (type: string) => setTypes((prev) => (prev.includes(type) ? [...prev.filter((x) => x !== type)] : [...prev, type]));
|
||||
|
||||
const submitExam = () => {
|
||||
if (!part1 || !part2 || !part3 || !part4) return toast.error("Please generate all for sections!");
|
||||
const parts = [part1, part2, part3, part4].filter((x) => !!x);
|
||||
console.log({parts});
|
||||
if (parts.length === 0) return toast.error("Please generate at least one section!");
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
axios
|
||||
.post(`/api/exam/listening/generate/listening`, {parts: [part1, part2, part3, part4]})
|
||||
.post(`/api/exam/listening/generate/listening`, {parts, minTimer})
|
||||
.then((result) => {
|
||||
playSound("sent");
|
||||
console.log(`Generated Exam ID: ${result.data.id}`);
|
||||
@@ -172,6 +186,17 @@ const ListeningGeneration = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="minTimer"
|
||||
onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))}
|
||||
value={minTimer}
|
||||
className="max-w-[300px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Exercises</label>
|
||||
<div className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
|
||||
@@ -197,46 +222,46 @@ const ListeningGeneration = () => {
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70",
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70 flex gap-2 items-center justify-center",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-listening focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-listening",
|
||||
)
|
||||
}>
|
||||
Section 1
|
||||
Section 1 {part1 && <BsCheck />}
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70",
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70 flex gap-2 items-center justify-center",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-listening focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-listening",
|
||||
)
|
||||
}>
|
||||
Section 2
|
||||
Section 2 {part2 && <BsCheck />}
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70",
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70 flex gap-2 items-center justify-center",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-listening focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-listening",
|
||||
)
|
||||
}>
|
||||
Section 3
|
||||
Section 3 {part3 && <BsCheck />}
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70",
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70 flex gap-2 items-center justify-center",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-listening focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-listening",
|
||||
)
|
||||
}>
|
||||
Section 4
|
||||
Section 4 {part4 && <BsCheck />}
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
@@ -264,14 +289,14 @@ const ListeningGeneration = () => {
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
disabled={!part1 || !part2 || !part3 || !part4 || isLoading}
|
||||
disabled={(!part1 && !part2 && !part3 && !part4) || isLoading}
|
||||
data-tip="Please generate all three passages"
|
||||
onClick={submitExam}
|
||||
className={clsx(
|
||||
"bg-ielts-listening/70 border border-ielts-listening text-white w-full max-w-[200px] rounded-xl h-[70px] self-end",
|
||||
"hover:bg-ielts-listening disabled:bg-ielts-listening/40 disabled:cursor-not-allowed",
|
||||
"transition ease-in-out duration-300",
|
||||
(!part1 || !part2 || !part3 || !part4) && "tooltip",
|
||||
!part1 && !part2 && !part3 && !part4 && "tooltip",
|
||||
)}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
|
||||
@@ -8,8 +8,8 @@ import {Tab} from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {useRouter} from "next/router";
|
||||
import {useState} from "react";
|
||||
import {BsArrowRepeat} from "react-icons/bs";
|
||||
import {useEffect, useState} from "react";
|
||||
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import {v4} from "uuid";
|
||||
|
||||
@@ -27,7 +27,8 @@ const PartTab = ({part, types, index, setPart}: {part?: ReadingPart; types: stri
|
||||
axios
|
||||
.get(`/api/exam/reading/generate/reading_passage_${index}${topic || types ? `?${url.toString()}` : ""}`)
|
||||
.then((result) => {
|
||||
playSound("check");
|
||||
playSound(typeof result.data === "string" ? "error" : "check");
|
||||
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
||||
setPart(result.data);
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -43,7 +44,7 @@ const PartTab = ({part, types, index, setPart}: {part?: ReadingPart; types: stri
|
||||
<Input type="text" placeholder="Grand Canyon..." name="topic" label="Topic" onChange={setTopic} roundness="xl" defaultValue={topic} />
|
||||
<button
|
||||
onClick={generate}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || types.length === 0}
|
||||
data-tip="The passage is currently being generated"
|
||||
className={clsx(
|
||||
"bg-ielts-reading/70 border border-ielts-reading text-white w-full max-w-[200px] rounded-xl h-[70px]",
|
||||
@@ -87,10 +88,16 @@ const ReadingGeneration = () => {
|
||||
const [part1, setPart1] = useState<ReadingPart>();
|
||||
const [part2, setPart2] = useState<ReadingPart>();
|
||||
const [part3, setPart3] = useState<ReadingPart>();
|
||||
const [minTimer, setMinTimer] = useState(60);
|
||||
const [types, setTypes] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [resultingExam, setResultingExam] = useState<ReadingExam>();
|
||||
|
||||
useEffect(() => {
|
||||
const parts = [part1, part2, part3].filter((x) => !!x);
|
||||
setMinTimer(parts.length === 0 ? 60 : parts.length * 20);
|
||||
}, [part1, part2, part3]);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const setExams = useExamStore((state) => state.setExams);
|
||||
@@ -122,19 +129,21 @@ const ReadingGeneration = () => {
|
||||
};
|
||||
|
||||
const submitExam = () => {
|
||||
if (!part1 || !part2 || !part3) {
|
||||
toast.error("Please generate all three passages before submitting");
|
||||
const parts = [part1, part2, part3].filter((x) => !!x) as ReadingPart[];
|
||||
if (parts.length === 0) {
|
||||
toast.error("Please generate at least one passage before submitting");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const exam: ReadingExam = {
|
||||
parts: [part1, part2, part3],
|
||||
parts,
|
||||
isDiagnostic: false,
|
||||
minTimer: 60,
|
||||
minTimer,
|
||||
module: "reading",
|
||||
id: v4(),
|
||||
type: "academic",
|
||||
variant: parts.length === 3 ? "full" : "partial",
|
||||
};
|
||||
|
||||
axios
|
||||
@@ -148,6 +157,7 @@ const ReadingGeneration = () => {
|
||||
setPart1(undefined);
|
||||
setPart2(undefined);
|
||||
setPart3(undefined);
|
||||
setMinTimer(60);
|
||||
setTypes([]);
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -159,6 +169,17 @@ const ReadingGeneration = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="minTimer"
|
||||
onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))}
|
||||
value={minTimer}
|
||||
className="max-w-[300px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Exercises</label>
|
||||
<div className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
|
||||
@@ -182,35 +203,35 @@ const ReadingGeneration = () => {
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-reading/70",
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-reading/70 flex gap-2 items-center justify-center",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-reading focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-reading",
|
||||
)
|
||||
}>
|
||||
Passage 1
|
||||
Passage 1 {part1 && <BsCheck />}
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-reading/70",
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-reading/70 flex gap-2 items-center justify-center",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-reading focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-reading",
|
||||
)
|
||||
}>
|
||||
Passage 2
|
||||
Passage 2 {part2 && <BsCheck />}
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-reading/70",
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-reading/70 flex gap-2 items-center justify-center",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-reading focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-reading",
|
||||
)
|
||||
}>
|
||||
Passage 3
|
||||
Passage 3 {part3 && <BsCheck />}
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
@@ -237,14 +258,14 @@ const ReadingGeneration = () => {
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
disabled={!part1 || !part2 || !part3 || isLoading}
|
||||
disabled={(!part1 && !part2 && !part3) || isLoading}
|
||||
data-tip="Please generate all three passages"
|
||||
onClick={submitExam}
|
||||
className={clsx(
|
||||
"bg-ielts-reading/70 border border-ielts-reading text-white w-full max-w-[200px] rounded-xl h-[70px] self-end",
|
||||
"hover:bg-ielts-reading disabled:bg-ielts-reading/40 disabled:cursor-not-allowed",
|
||||
"transition ease-in-out duration-300",
|
||||
(!part1 || !part2 || !part3) && "tooltip",
|
||||
!part1 && !part2 && !part3 && "tooltip",
|
||||
)}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Input from "@/components/Low/Input";
|
||||
import {Exercise, SpeakingExam} from "@/interfaces/exam";
|
||||
import {Exercise, InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise} from "@/interfaces/exam";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {playSound} from "@/utils/sound";
|
||||
@@ -7,10 +7,12 @@ import {convertCamelCaseToReadable} from "@/utils/string";
|
||||
import {Tab} from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import moment from "moment";
|
||||
import {useRouter} from "next/router";
|
||||
import {useState} from "react";
|
||||
import {BsArrowRepeat} from "react-icons/bs";
|
||||
import {useEffect, useState} from "react";
|
||||
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import {v4} from "uuid";
|
||||
|
||||
const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; setPart: (part?: SpeakingPart) => void}) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -18,10 +20,12 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
|
||||
const generate = () => {
|
||||
setPart(undefined);
|
||||
setIsLoading(true);
|
||||
|
||||
axios
|
||||
.get(`/api/exam/speaking/generate/speaking_task_${index}`)
|
||||
.then((result) => {
|
||||
playSound("check");
|
||||
playSound(typeof result.data === "string" ? "error" : "check");
|
||||
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
||||
setPart(result.data);
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -31,6 +35,29 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
const generateVideo = () => {
|
||||
if (!part) return toast.error("Please generate the first part before generating the video!");
|
||||
toast.info("This will take quite a while, please do not leave this page or close the tab/window.");
|
||||
|
||||
setIsLoading(true);
|
||||
const initialTime = moment();
|
||||
|
||||
axios
|
||||
.post(`/api/exam/speaking/generate/speaking/generate_${index === 3 ? "interactive" : "speaking"}_video`, part)
|
||||
.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});
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.error("Something went wrong!");
|
||||
console.log(e);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Tab.Panel className="w-full bg-ielts-speaking/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
|
||||
<div className="flex gap-4 items-end">
|
||||
@@ -52,6 +79,24 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
|
||||
"Generate"
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={generateVideo}
|
||||
disabled={isLoading || !part}
|
||||
data-tip="The passage is currently being generated"
|
||||
className={clsx(
|
||||
"bg-ielts-speaking/70 border border-ielts-speaking text-white w-full rounded-xl h-[70px]",
|
||||
"hover:bg-ielts-speaking disabled:bg-ielts-speaking/40 disabled:cursor-not-allowed",
|
||||
"transition ease-in-out duration-300",
|
||||
isLoading && "tooltip",
|
||||
)}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
||||
</div>
|
||||
) : (
|
||||
"Generate Video"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className="w-fit h-fit mt-12 self-center animate-pulse flex flex-col gap-8 items-center">
|
||||
@@ -59,7 +104,7 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
|
||||
<span className={clsx("font-bold text-2xl text-ielts-speaking")}>Generating...</span>
|
||||
</div>
|
||||
)}
|
||||
{part && (
|
||||
{part && !isLoading && (
|
||||
<div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide h-96">
|
||||
<h3 className="text-xl font-semibold">{part.topic}</h3>
|
||||
{part.question && <span className="w-full">{part.question}</span>}
|
||||
@@ -82,6 +127,7 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{part.result && <span className="font-bold mt-4">Video Generated: ✅</span>}
|
||||
</div>
|
||||
)}
|
||||
</Tab.Panel>
|
||||
@@ -93,27 +139,43 @@ interface SpeakingPart {
|
||||
question?: string;
|
||||
questions?: string[];
|
||||
topic: string;
|
||||
result?: SpeakingExercise | InteractiveSpeakingExercise;
|
||||
}
|
||||
|
||||
const SpeakingGeneration = () => {
|
||||
const [part1, setPart1] = useState<SpeakingPart>();
|
||||
const [part2, setPart2] = useState<SpeakingPart>();
|
||||
const [part3, setPart3] = useState<SpeakingPart>();
|
||||
const [minTimer, setMinTimer] = useState(14);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [resultingExam, setResultingExam] = useState<SpeakingExam>();
|
||||
|
||||
useEffect(() => {
|
||||
const parts = [part1, part2, part3].filter((x) => !!x);
|
||||
setMinTimer(parts.length === 0 ? 5 : parts.length * 5);
|
||||
}, [part1, part2, part3]);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const setExams = useExamStore((state) => state.setExams);
|
||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||
|
||||
const submitExam = () => {
|
||||
if (!part1 || !part2 || !part3) return toast.error("Please generate all for tasks!");
|
||||
if (!part1?.result && !part2?.result && !part3?.result) return toast.error("Please generate at least one task!");
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const exam: SpeakingExam = {
|
||||
id: v4(),
|
||||
isDiagnostic: false,
|
||||
exercises: [part1?.result, part2?.result, part3?.result].filter((x) => !!x) as (SpeakingExercise | InteractiveSpeakingExercise)[],
|
||||
minTimer,
|
||||
variant: minTimer >= 14 ? "full" : "partial",
|
||||
module: "speaking",
|
||||
};
|
||||
|
||||
axios
|
||||
.post(`/api/exam/speaking/generate/speaking`, {exercises: [part1, part2, part3]})
|
||||
.post(`/api/exam/speaking`, exam)
|
||||
.then((result) => {
|
||||
playSound("sent");
|
||||
console.log(`Generated Exam ID: ${result.data.id}`);
|
||||
@@ -123,10 +185,11 @@ const SpeakingGeneration = () => {
|
||||
setPart1(undefined);
|
||||
setPart2(undefined);
|
||||
setPart3(undefined);
|
||||
setMinTimer(14);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
toast.error("Something went wrong!");
|
||||
toast.error("Something went wrong while generating, please try again later.");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
@@ -149,40 +212,51 @@ const SpeakingGeneration = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="minTimer"
|
||||
onChange={(e) => setMinTimer(parseInt(e) < 5 ? 5 : parseInt(e))}
|
||||
value={minTimer}
|
||||
className="max-w-[300px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tab.Group>
|
||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/70",
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/70 flex gap-2 items-center justify-center",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||
)
|
||||
}>
|
||||
Task 1
|
||||
Exercise 1 {part1 && part1.result && <BsCheck />}
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/70",
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/70 flex gap-2 items-center justify-center",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||
)
|
||||
}>
|
||||
Task 2
|
||||
Exercise 2 {part2 && part2.result && <BsCheck />}
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/70",
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/70 flex gap-2 items-center justify-center",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||
)
|
||||
}>
|
||||
Task 3
|
||||
Interactive {part3 && part3.result && <BsCheck />}
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
@@ -209,14 +283,14 @@ const SpeakingGeneration = () => {
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
disabled={!part1 || !part2 || !part3 || isLoading}
|
||||
disabled={(!part1?.result && !part2?.result && !part3?.result) || isLoading}
|
||||
data-tip="Please generate all three passages"
|
||||
onClick={submitExam}
|
||||
className={clsx(
|
||||
"bg-ielts-speaking/70 border border-ielts-speaking text-white w-full max-w-[200px] rounded-xl h-[70px] self-end",
|
||||
"hover:bg-ielts-speaking disabled:bg-ielts-speaking/40 disabled:cursor-not-allowed",
|
||||
"transition ease-in-out duration-300",
|
||||
(!part1 || !part2 || !part3) && "tooltip",
|
||||
!part1 && !part2 && !part3 && "tooltip",
|
||||
)}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Input from "@/components/Low/Input";
|
||||
import {WritingExam} from "@/interfaces/exam";
|
||||
import {WritingExam, WritingExercise} from "@/interfaces/exam";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {playSound} from "@/utils/sound";
|
||||
@@ -7,8 +7,8 @@ import {Tab} from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {useRouter} from "next/router";
|
||||
import {useState} from "react";
|
||||
import {BsArrowRepeat} from "react-icons/bs";
|
||||
import {useEffect, useState} from "react";
|
||||
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import {v4} from "uuid";
|
||||
|
||||
@@ -20,7 +20,8 @@ const TaskTab = ({task, index, setTask}: {task?: string; index: number; setTask:
|
||||
axios
|
||||
.get(`/api/exam/writing/generate/writing_task${index}_general`)
|
||||
.then((result) => {
|
||||
playSound("check");
|
||||
playSound(typeof result.data === "string" ? "error" : "check");
|
||||
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
||||
setTask(result.data.question);
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -68,9 +69,16 @@ const TaskTab = ({task, index, setTask}: {task?: string; index: number; setTask:
|
||||
const WritingGeneration = () => {
|
||||
const [task1, setTask1] = useState<string>();
|
||||
const [task2, setTask2] = useState<string>();
|
||||
const [minTimer, setMinTimer] = useState(60);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [resultingExam, setResultingExam] = useState<WritingExam>();
|
||||
|
||||
useEffect(() => {
|
||||
const task1Timer = task1 ? 20 : 0;
|
||||
const task2Timer = task2 ? 40 : 0;
|
||||
setMinTimer(task1Timer > 0 || task2Timer > 0 ? task1Timer + task2Timer : 20);
|
||||
}, [task1, task2]);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const setExams = useExamStore((state) => state.setExams);
|
||||
@@ -93,18 +101,13 @@ const WritingGeneration = () => {
|
||||
};
|
||||
|
||||
const submitExam = () => {
|
||||
if (!task1 || !task2) {
|
||||
toast.error("Please generate all tasks before submitting");
|
||||
if (!task1 && !task2) {
|
||||
toast.error("Please generate a task before submitting");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const exam: WritingExam = {
|
||||
isDiagnostic: false,
|
||||
minTimer: 60,
|
||||
module: "writing",
|
||||
exercises: [
|
||||
{
|
||||
const exercise1 = task1
|
||||
? ({
|
||||
id: v4(),
|
||||
type: "writing",
|
||||
prefix: `You should spend about 20 minutes on this task.`,
|
||||
@@ -115,8 +118,11 @@ const WritingGeneration = () => {
|
||||
limit: 150,
|
||||
type: "min",
|
||||
},
|
||||
},
|
||||
{
|
||||
} as WritingExercise)
|
||||
: undefined;
|
||||
|
||||
const exercise2 = task2
|
||||
? ({
|
||||
id: v4(),
|
||||
type: "writing",
|
||||
prefix: `You should spend about 40 minutes on this task.`,
|
||||
@@ -127,9 +133,17 @@ const WritingGeneration = () => {
|
||||
limit: 250,
|
||||
type: "min",
|
||||
},
|
||||
},
|
||||
],
|
||||
} as WritingExercise)
|
||||
: undefined;
|
||||
|
||||
setIsLoading(true);
|
||||
const exam: WritingExam = {
|
||||
isDiagnostic: false,
|
||||
minTimer,
|
||||
module: "writing",
|
||||
exercises: [...(exercise1 ? [exercise1] : []), ...(exercise2 ? [exercise2] : [])],
|
||||
id: v4(),
|
||||
variant: exercise1 && exercise2 ? "full" : "partial",
|
||||
};
|
||||
|
||||
axios
|
||||
@@ -152,29 +166,40 @@ const WritingGeneration = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="minTimer"
|
||||
onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))}
|
||||
value={minTimer}
|
||||
className="max-w-[300px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tab.Group>
|
||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1">
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/70",
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/70 flex gap-2 items-center justify-center",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
|
||||
)
|
||||
}>
|
||||
Task 1
|
||||
Task 1 {task1 && <BsCheck />}
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/70",
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/70 flex gap-2 items-center justify-center",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
|
||||
)
|
||||
}>
|
||||
Task 2
|
||||
Task 2 {task2 && <BsCheck />}
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
@@ -200,14 +225,14 @@ const WritingGeneration = () => {
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
disabled={!task1 || !task2 || isLoading}
|
||||
disabled={(!task1 && !task2) || isLoading}
|
||||
data-tip="Please generate all three passages"
|
||||
onClick={submitExam}
|
||||
className={clsx(
|
||||
"bg-ielts-writing/70 border border-ielts-writing text-white w-full max-w-[200px] rounded-xl h-[70px] self-end",
|
||||
"hover:bg-ielts-writing disabled:bg-ielts-writing/40 disabled:cursor-not-allowed",
|
||||
"transition ease-in-out duration-300",
|
||||
(!task1 || !task2) && "tooltip",
|
||||
!task1 && !task2 && "tooltip",
|
||||
)}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
|
||||
@@ -1,215 +1,279 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import Input from "@/components/Low/Input";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {User} from "@/interfaces/user";
|
||||
import {sendEmailVerification} from "@/utils/email";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { sendEmailVerification } from "@/utils/email";
|
||||
import axios from "axios";
|
||||
import {Divider} from "primereact/divider";
|
||||
import {useState} from "react";
|
||||
import {toast} from "react-toastify";
|
||||
import {KeyedMutator} from "swr";
|
||||
import { Divider } from "primereact/divider";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { KeyedMutator } from "swr";
|
||||
import Select from "react-select";
|
||||
import moment from "moment";
|
||||
|
||||
interface Props {
|
||||
isLoading: boolean;
|
||||
setIsLoading: (isLoading: boolean) => void;
|
||||
mutateUser: KeyedMutator<User>;
|
||||
sendEmailVerification: typeof sendEmailVerification;
|
||||
isLoading: boolean;
|
||||
setIsLoading: (isLoading: boolean) => void;
|
||||
mutateUser: KeyedMutator<User>;
|
||||
sendEmailVerification: typeof sendEmailVerification;
|
||||
}
|
||||
|
||||
const availableDurations = {
|
||||
"1_month": {label: "1 Month", number: 1},
|
||||
"3_months": {label: "3 Months", number: 3},
|
||||
"6_months": {label: "6 Months", number: 6},
|
||||
"12_months": {label: "12 Months", number: 12},
|
||||
"1_month": { label: "1 Month", number: 1 },
|
||||
"3_months": { label: "3 Months", number: 3 },
|
||||
"6_months": { label: "6 Months", number: 6 },
|
||||
"12_months": { label: "12 Months", number: 12 },
|
||||
};
|
||||
|
||||
export default function RegisterCorporate({isLoading, setIsLoading, mutateUser, sendEmailVerification}: Props) {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [referralAgent, setReferralAgent] = useState<string | undefined>();
|
||||
export default function RegisterCorporate({
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
mutateUser,
|
||||
sendEmailVerification,
|
||||
}: Props) {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [referralAgent, setReferralAgent] = useState<string | undefined>();
|
||||
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const [companyUsers, setCompanyUsers] = useState(0);
|
||||
const [subscriptionDuration, setSubscriptionDuration] = useState(1);
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const [companyUsers, setCompanyUsers] = useState(0);
|
||||
const [subscriptionDuration, setSubscriptionDuration] = useState(1);
|
||||
|
||||
const {users} = useUsers();
|
||||
const { users } = useUsers();
|
||||
|
||||
const onSuccess = () => toast.success("An e-mail has been sent, please make sure to check your spam folder!");
|
||||
const onSuccess = () =>
|
||||
toast.success(
|
||||
"An e-mail has been sent, please make sure to check your spam folder!",
|
||||
);
|
||||
|
||||
const onError = (e: Error) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong, please logout and re-login.", {toastId: "send-verify-error"});
|
||||
};
|
||||
const onError = (e: Error) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong, please logout and re-login.", {
|
||||
toastId: "send-verify-error",
|
||||
});
|
||||
};
|
||||
|
||||
const register = (e: any) => {
|
||||
e.preventDefault();
|
||||
const register = (e: any) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (confirmPassword !== password) {
|
||||
toast.error("Your passwords do not match!", {toastId: "password-not-match"});
|
||||
return;
|
||||
}
|
||||
if (confirmPassword !== password) {
|
||||
toast.error("Your passwords do not match!", {
|
||||
toastId: "password-not-match",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.post("/api/register", {
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
type: "corporate",
|
||||
profilePicture: "/defaultAvatar.png",
|
||||
subscriptionExpirationDate: moment().subtract(1, "days").toISOString(),
|
||||
corporateInformation: {
|
||||
companyInformation: {
|
||||
name: companyName,
|
||||
userAmount: companyUsers,
|
||||
},
|
||||
monthlyDuration: subscriptionDuration,
|
||||
referralAgent,
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
mutateUser(response.data.user).then(() => sendEmailVerification(setIsLoading, onSuccess, onError));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error.response.data);
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.post("/api/register", {
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
type: "corporate",
|
||||
profilePicture: "/defaultAvatar.png",
|
||||
subscriptionExpirationDate: moment().subtract(1, "days").toISOString(),
|
||||
corporateInformation: {
|
||||
companyInformation: {
|
||||
name: companyName,
|
||||
userAmount: companyUsers,
|
||||
},
|
||||
monthlyDuration: subscriptionDuration,
|
||||
referralAgent,
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
mutateUser(response.data.user).then(() =>
|
||||
sendEmailVerification(setIsLoading, onSuccess, onError),
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error.response.data);
|
||||
|
||||
if (error.response.status === 401) {
|
||||
toast.error("There is already a user with that e-mail!");
|
||||
return;
|
||||
}
|
||||
if (error.response.status === 401) {
|
||||
toast.error("There is already a user with that e-mail!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (error.response.status === 400) {
|
||||
toast.error("The provided code is invalid!");
|
||||
return;
|
||||
}
|
||||
if (error.response.status === 400) {
|
||||
toast.error("The provided code is invalid!");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("There was something wrong, please try again!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
toast.error("There was something wrong, please try again!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="flex flex-col items-center gap-4 w-full" onSubmit={register}>
|
||||
<div className="w-full flex gap-4">
|
||||
<Input type="text" name="name" onChange={(e) => setName(e)} placeholder="Enter your name" defaultValue={name} required />
|
||||
<Input type="email" name="email" onChange={(e) => setEmail(e)} placeholder="Enter email address" defaultValue={email} required />
|
||||
</div>
|
||||
return (
|
||||
<form
|
||||
className="flex w-full flex-col items-center gap-4"
|
||||
onSubmit={register}
|
||||
>
|
||||
<div className="flex w-full gap-4">
|
||||
<Input
|
||||
type="text"
|
||||
name="name"
|
||||
onChange={(e) => setName(e)}
|
||||
placeholder="Enter your name"
|
||||
defaultValue={name}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
onChange={(e) => setEmail(e.toLowerCase())}
|
||||
placeholder="Enter email address"
|
||||
defaultValue={email}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex gap-4">
|
||||
<Input
|
||||
type="password"
|
||||
name="password"
|
||||
onChange={(e) => setPassword(e)}
|
||||
placeholder="Enter your password"
|
||||
defaultValue={password}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
onChange={(e) => setConfirmPassword(e)}
|
||||
placeholder="Confirm your password"
|
||||
defaultValue={confirmPassword}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full gap-4">
|
||||
<Input
|
||||
type="password"
|
||||
name="password"
|
||||
onChange={(e) => setPassword(e)}
|
||||
placeholder="Enter your password"
|
||||
defaultValue={password}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
onChange={(e) => setConfirmPassword(e)}
|
||||
placeholder="Confirm your password"
|
||||
defaultValue={confirmPassword}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider className="w-full !my-2" />
|
||||
<Divider className="!my-2 w-full" />
|
||||
|
||||
<div className="w-full flex gap-4">
|
||||
<Input
|
||||
type="text"
|
||||
name="companyName"
|
||||
onChange={(e) => setCompanyName(e)}
|
||||
placeholder="Corporate name"
|
||||
label="Corporate name"
|
||||
defaultValue={companyName}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
name="companyUsers"
|
||||
onChange={(e) => setCompanyUsers(parseInt(e))}
|
||||
label="Number of users"
|
||||
defaultValue={companyUsers}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full gap-4">
|
||||
<Input
|
||||
type="text"
|
||||
name="companyName"
|
||||
onChange={(e) => setCompanyName(e)}
|
||||
placeholder="Corporate name"
|
||||
label="Corporate name"
|
||||
defaultValue={companyName}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
name="companyUsers"
|
||||
onChange={(e) => setCompanyUsers(parseInt(e))}
|
||||
label="Number of users"
|
||||
defaultValue={companyUsers}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex gap-4">
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Referral *</label>
|
||||
<Select
|
||||
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||
options={[
|
||||
{value: "", label: "No referral"},
|
||||
...users.filter((u) => u.type === "agent").map((x) => ({value: x.id, label: `${x.name} - ${x.email}`})),
|
||||
]}
|
||||
defaultValue={{value: "", label: "No referral"}}
|
||||
onChange={(value) => setReferralAgent(value?.value)}
|
||||
styles={{
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full gap-4">
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Referral *
|
||||
</label>
|
||||
<Select
|
||||
className="placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none disabled:cursor-not-allowed"
|
||||
options={[
|
||||
{ value: "", label: "No referral" },
|
||||
...users
|
||||
.filter((u) => u.type === "agent")
|
||||
.map((x) => ({ value: x.id, label: `${x.name} - ${x.email}` })),
|
||||
]}
|
||||
defaultValue={{ value: "", label: "No referral" }}
|
||||
onChange={(value) => setReferralAgent(value?.value)}
|
||||
styles={{
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused
|
||||
? "#D5D9F0"
|
||||
: state.isSelected
|
||||
? "#7872BF"
|
||||
: "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Subscription Duration *</label>
|
||||
<Select
|
||||
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||
options={Object.keys(availableDurations).map((value) => ({
|
||||
value,
|
||||
label: availableDurations[value as keyof typeof availableDurations].label,
|
||||
}))}
|
||||
defaultValue={{value: "1_month", label: availableDurations["1_month"].label}}
|
||||
onChange={(value) =>
|
||||
setSubscriptionDuration(value ? availableDurations[value.value as keyof typeof availableDurations].number : 1)
|
||||
}
|
||||
styles={{
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Subscription Duration *
|
||||
</label>
|
||||
<Select
|
||||
className="placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none disabled:cursor-not-allowed"
|
||||
options={Object.keys(availableDurations).map((value) => ({
|
||||
value,
|
||||
label:
|
||||
availableDurations[value as keyof typeof availableDurations]
|
||||
.label,
|
||||
}))}
|
||||
defaultValue={{
|
||||
value: "1_month",
|
||||
label: availableDurations["1_month"].label,
|
||||
}}
|
||||
onChange={(value) =>
|
||||
setSubscriptionDuration(
|
||||
value
|
||||
? availableDurations[
|
||||
value.value as keyof typeof availableDurations
|
||||
].number
|
||||
: 1,
|
||||
)
|
||||
}
|
||||
styles={{
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused
|
||||
? "#D5D9F0"
|
||||
: state.isSelected
|
||||
? "#7872BF"
|
||||
: "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="lg:mt-8 w-full"
|
||||
color="purple"
|
||||
disabled={
|
||||
isLoading || !email || !name || !password || !confirmPassword || password !== confirmPassword || !companyName || companyUsers <= 0
|
||||
}>
|
||||
Create account
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
<Button
|
||||
className="w-full lg:mt-8"
|
||||
color="purple"
|
||||
disabled={
|
||||
isLoading ||
|
||||
!email ||
|
||||
!name ||
|
||||
!password ||
|
||||
!confirmPassword ||
|
||||
password !== confirmPassword ||
|
||||
!companyName ||
|
||||
companyUsers <= 0
|
||||
}
|
||||
>
|
||||
Create account
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,132 +1,167 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import Checkbox from "@/components/Low/Checkbox";
|
||||
import Input from "@/components/Low/Input";
|
||||
import {User} from "@/interfaces/user";
|
||||
import {sendEmailVerification} from "@/utils/email";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { sendEmailVerification } from "@/utils/email";
|
||||
import axios from "axios";
|
||||
import {useEffect, useState} from "react";
|
||||
import {toast} from "react-toastify";
|
||||
import {KeyedMutator} from "swr";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { KeyedMutator } from "swr";
|
||||
|
||||
interface Props {
|
||||
queryCode?: string;
|
||||
defaultInformation?: {
|
||||
email: string;
|
||||
name: string;
|
||||
passport_id?: string;
|
||||
};
|
||||
isLoading: boolean;
|
||||
setIsLoading: (isLoading: boolean) => void;
|
||||
mutateUser: KeyedMutator<User>;
|
||||
sendEmailVerification: typeof sendEmailVerification;
|
||||
queryCode?: string;
|
||||
defaultInformation?: {
|
||||
email: string;
|
||||
name: string;
|
||||
passport_id?: string;
|
||||
};
|
||||
isLoading: boolean;
|
||||
setIsLoading: (isLoading: boolean) => void;
|
||||
mutateUser: KeyedMutator<User>;
|
||||
sendEmailVerification: typeof sendEmailVerification;
|
||||
}
|
||||
|
||||
export default function RegisterIndividual({queryCode, defaultInformation, isLoading, setIsLoading, mutateUser, sendEmailVerification}: Props) {
|
||||
const [name, setName] = useState(defaultInformation?.name || "");
|
||||
const [email, setEmail] = useState(defaultInformation?.email || "");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [code, setCode] = useState(queryCode || "");
|
||||
const [hasCode, setHasCode] = useState<boolean>(!!queryCode);
|
||||
export default function RegisterIndividual({
|
||||
queryCode,
|
||||
defaultInformation,
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
mutateUser,
|
||||
sendEmailVerification,
|
||||
}: Props) {
|
||||
const [name, setName] = useState(defaultInformation?.name || "");
|
||||
const [email, setEmail] = useState(defaultInformation?.email || "");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [code, setCode] = useState(queryCode || "");
|
||||
const [hasCode, setHasCode] = useState<boolean>(!!queryCode);
|
||||
|
||||
const onSuccess = () => toast.success("An e-mail has been sent, please make sure to check your spam folder!");
|
||||
const onSuccess = () =>
|
||||
toast.success(
|
||||
"An e-mail has been sent, please make sure to check your spam folder!",
|
||||
);
|
||||
|
||||
const onError = (e: Error) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong, please logout and re-login.", {toastId: "send-verify-error"});
|
||||
};
|
||||
const onError = (e: Error) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong, please logout and re-login.", {
|
||||
toastId: "send-verify-error",
|
||||
});
|
||||
};
|
||||
|
||||
const register = (e: any) => {
|
||||
e.preventDefault();
|
||||
const register = (e: any) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (confirmPassword !== password) {
|
||||
toast.error("Your passwords do not match!", {toastId: "password-not-match"});
|
||||
return;
|
||||
}
|
||||
if (confirmPassword !== password) {
|
||||
toast.error("Your passwords do not match!", {
|
||||
toastId: "password-not-match",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.post("/api/register", {
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
type: "individual",
|
||||
code,
|
||||
passport_id: defaultInformation?.passport_id,
|
||||
profilePicture: "/defaultAvatar.png",
|
||||
})
|
||||
.then((response) => {
|
||||
mutateUser(response.data.user).then(() => sendEmailVerification(setIsLoading, onSuccess, onError));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error.response.data);
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.post("/api/register", {
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
type: "individual",
|
||||
code,
|
||||
passport_id: defaultInformation?.passport_id,
|
||||
profilePicture: "/defaultAvatar.png",
|
||||
})
|
||||
.then((response) => {
|
||||
mutateUser(response.data.user).then(() =>
|
||||
sendEmailVerification(setIsLoading, onSuccess, onError),
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error.response.data);
|
||||
|
||||
if (error.response.status === 401) {
|
||||
toast.error("There is already a user with that e-mail!");
|
||||
return;
|
||||
}
|
||||
if (error.response.status === 401) {
|
||||
toast.error("There is already a user with that e-mail!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (error.response.status === 400) {
|
||||
toast.error("The provided code is invalid!");
|
||||
return;
|
||||
}
|
||||
if (error.response.status === 400) {
|
||||
toast.error("The provided code is invalid!");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("There was something wrong, please try again!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
toast.error("There was something wrong, please try again!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="flex flex-col items-center gap-6 w-full" onSubmit={register}>
|
||||
<Input type="text" name="name" onChange={(e) => setName(e)} placeholder="Enter your name" value={name} required />
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
onChange={(e) => setEmail(e)}
|
||||
placeholder="Enter email address"
|
||||
value={email}
|
||||
disabled={!!defaultInformation?.email}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
name="password"
|
||||
onChange={(e) => setPassword(e)}
|
||||
placeholder="Enter your password"
|
||||
defaultValue={password}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
onChange={(e) => setConfirmPassword(e)}
|
||||
placeholder="Confirm your password"
|
||||
defaultValue={confirmPassword}
|
||||
required
|
||||
/>
|
||||
return (
|
||||
<form
|
||||
className="flex w-full flex-col items-center gap-6"
|
||||
onSubmit={register}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
name="name"
|
||||
onChange={(e) => setName(e)}
|
||||
placeholder="Enter your name"
|
||||
value={name}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
onChange={(e) => setEmail(e.toLowerCase())}
|
||||
placeholder="Enter email address"
|
||||
value={email}
|
||||
disabled={!!defaultInformation?.email}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
name="password"
|
||||
onChange={(e) => setPassword(e)}
|
||||
placeholder="Enter your password"
|
||||
defaultValue={password}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
onChange={(e) => setConfirmPassword(e)}
|
||||
placeholder="Confirm your password"
|
||||
defaultValue={confirmPassword}
|
||||
required
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-4 w-full items-start">
|
||||
<Checkbox isChecked={hasCode} onChange={setHasCode}>
|
||||
I have a code
|
||||
</Checkbox>
|
||||
{hasCode && (
|
||||
<Input
|
||||
type="text"
|
||||
name="code"
|
||||
onChange={(e) => setCode(e)}
|
||||
placeholder="Enter your registration code (optional)"
|
||||
defaultValue={code}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-start gap-4">
|
||||
<Checkbox isChecked={hasCode} onChange={setHasCode}>
|
||||
I have a code
|
||||
</Checkbox>
|
||||
{hasCode && (
|
||||
<Input
|
||||
type="text"
|
||||
name="code"
|
||||
onChange={(e) => setCode(e)}
|
||||
placeholder="Enter your registration code (optional)"
|
||||
defaultValue={code}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="lg:mt-8 w-full"
|
||||
color="purple"
|
||||
disabled={isLoading || !email || !name || !password || !confirmPassword || password !== confirmPassword || (hasCode ? !code : false)}>
|
||||
Create account
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
<Button
|
||||
className="w-full lg:mt-8"
|
||||
color="purple"
|
||||
disabled={
|
||||
isLoading ||
|
||||
!email ||
|
||||
!name ||
|
||||
!password ||
|
||||
!confirmPassword ||
|
||||
password !== confirmPassword ||
|
||||
(hasCode ? !code : false)
|
||||
}
|
||||
>
|
||||
Create account
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@ import clsx from "clsx";
|
||||
import {capitalize} from "lodash";
|
||||
import {useState} from "react";
|
||||
import getSymbolFromCurrency from "currency-symbol-map";
|
||||
import useInvites from "@/hooks/useInvites";
|
||||
import {BsArrowRepeat} from "react-icons/bs";
|
||||
import InviteCard from "@/components/Medium/InviteCard";
|
||||
import {useRouter} from "next/router";
|
||||
import {PayPalScriptProvider} from "@paypal/react-paypal-js";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
@@ -20,9 +25,12 @@ interface Props {
|
||||
export default function PaymentDue({user, hasExpired = false, clientID, reload}: Props) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const {packages} = usePackages();
|
||||
const {users} = useUsers();
|
||||
const {groups} = useGroups();
|
||||
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user?.id});
|
||||
|
||||
const isIndividual = () => {
|
||||
if (user?.type === "developer") return true;
|
||||
@@ -38,59 +46,94 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
|
||||
return (
|
||||
<>
|
||||
{isLoading && (
|
||||
<div className="w-screen h-screen absolute top-0 left-0 overflow-hidden z-[999] bg-black/60">
|
||||
<div className="w-fit h-fit absolute top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 animate-pulse flex flex-col gap-8 items-center text-white">
|
||||
<div className="absolute left-0 top-0 z-[999] h-screen w-screen overflow-hidden bg-black/60">
|
||||
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-8 text-white">
|
||||
<span className={clsx("loading loading-infinity w-48")} />
|
||||
<span className={clsx("font-bold text-2xl")}>Completing your payment...</span>
|
||||
<span className={clsx("text-2xl font-bold")}>Completing your payment...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{user ? (
|
||||
<Layout user={user} navDisabled={hasExpired}>
|
||||
<div className="flex flex-col items-center justify-center text-center w-full gap-4">
|
||||
{hasExpired && <span className="font-bold text-lg">You do not have time credits for your account type!</span>}
|
||||
{invites.length > 0 && (
|
||||
<section className="flex flex-col gap-1 md:gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
onClick={reloadInvites}
|
||||
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out">
|
||||
<span className="text-mti-black text-lg font-bold">Invites</span>
|
||||
<BsArrowRepeat className={clsx("text-xl", isInvitesLoading && "animate-spin")} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
||||
{invites.map((invite) => (
|
||||
<InviteCard
|
||||
key={invite.id}
|
||||
invite={invite}
|
||||
users={users}
|
||||
reload={() => {
|
||||
reloadInvites();
|
||||
router.reload();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="flex w-full flex-col items-center justify-center gap-4 text-center">
|
||||
{hasExpired && <span className="text-lg font-bold">You do not have time credits for your account type!</span>}
|
||||
{isIndividual() && (
|
||||
<div className="flex flex-col items-center w-full overflow-x-scroll scrollbar-hide gap-12">
|
||||
<div className="scrollbar-hide flex w-full flex-col items-center gap-12 overflow-x-scroll">
|
||||
<span className="max-w-lg">
|
||||
To add to your use of EnCoach, please purchase one of the time packages available below:
|
||||
</span>
|
||||
<div className="w-full flex flex-wrap justify-center gap-8">
|
||||
{packages.map((p) => (
|
||||
<div key={p.id} className={clsx("p-4 bg-white rounded-xl flex flex-col gap-6 items-start")}>
|
||||
<div className="flex flex-col items-start mb-2">
|
||||
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" />
|
||||
<span className="font-semibold text-xl">
|
||||
EnCoach - {p.duration}{" "}
|
||||
{capitalize(
|
||||
p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit,
|
||||
)}
|
||||
</span>
|
||||
<div className="flex w-full flex-wrap justify-center gap-8">
|
||||
<PayPalScriptProvider
|
||||
options={{
|
||||
clientId: clientID,
|
||||
currency: "USD",
|
||||
intent: "capture",
|
||||
commit: true,
|
||||
vault: true,
|
||||
}}>
|
||||
{packages.map((p) => (
|
||||
<div key={p.id} className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}>
|
||||
<div className="mb-2 flex flex-col items-start">
|
||||
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" />
|
||||
<span className="text-xl font-semibold">
|
||||
EnCoach - {p.duration}{" "}
|
||||
{capitalize(
|
||||
p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-start gap-2">
|
||||
<span className="text-2xl">
|
||||
{p.price}
|
||||
{getSymbolFromCurrency(p.currency)}
|
||||
</span>
|
||||
<PayPalPayment
|
||||
key={clientID}
|
||||
{...p}
|
||||
clientID={clientID}
|
||||
setIsLoading={setIsLoading}
|
||||
onSuccess={() => {
|
||||
setTimeout(reload, 500);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<span>This includes:</span>
|
||||
<ul className="flex flex-col items-start text-sm">
|
||||
<li>- Train your abilities for the IELTS exam</li>
|
||||
<li>- Gain insights into your weaknesses and strengths</li>
|
||||
<li>- Allow yourself to correctly prepare for the exam</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 items-start w-full">
|
||||
<span className="text-2xl">
|
||||
{p.price}
|
||||
{getSymbolFromCurrency(p.currency)}
|
||||
</span>
|
||||
<PayPalPayment
|
||||
key={clientID}
|
||||
{...p}
|
||||
clientID={clientID}
|
||||
setIsLoading={setIsLoading}
|
||||
onSuccess={() => {
|
||||
setTimeout(reload, 500);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<span>This includes:</span>
|
||||
<ul className="flex flex-col items-start text-sm">
|
||||
<li>- Train your abilities for the IELTS exam</li>
|
||||
<li>- Gain insights into your weaknesses and strengths</li>
|
||||
<li>- Allow yourself to correctly prepare for the exam</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</PayPalScriptProvider>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -99,12 +142,12 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
|
||||
<span className="max-w-lg">
|
||||
To add to your use of EnCoach and that of your students and teachers, please pay your designated package below:
|
||||
</span>
|
||||
<div className={clsx("p-4 bg-white rounded-xl flex flex-col gap-6 items-start")}>
|
||||
<div className="flex flex-col items-start mb-2">
|
||||
<div className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}>
|
||||
<div className="mb-2 flex flex-col items-start">
|
||||
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" />
|
||||
<span className="font-semibold text-xl">EnCoach - {user.corporateInformation?.monthlyDuration} Months</span>
|
||||
<span className="text-xl font-semibold">EnCoach - {user.corporateInformation?.monthlyDuration} Months</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 items-start w-full">
|
||||
<div className="flex w-full flex-col items-start gap-2">
|
||||
<span className="text-2xl">
|
||||
{user.corporateInformation.payment.value}
|
||||
{getSymbolFromCurrency(user.corporateInformation.payment.currency)}
|
||||
@@ -121,9 +164,10 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
|
||||
setIsLoading(false);
|
||||
setTimeout(reload, 500);
|
||||
}}
|
||||
loadScript
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<span>This includes:</span>
|
||||
<ul className="flex flex-col items-start text-sm">
|
||||
<li>
|
||||
|
||||
@@ -10,9 +10,10 @@ import {useRouter} from "next/router";
|
||||
import {useEffect} from "react";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import usePreferencesStore from "@/stores/preferencesStore";
|
||||
import axios from "axios";
|
||||
|
||||
export default function App({Component, pageProps}: AppProps) {
|
||||
const reset = useExamStore((state) => state.reset);
|
||||
const {reset} = useExamStore((state) => state);
|
||||
const setIsSidebarMinimized = usePreferencesStore((state) => state.setSidebarMinimized);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -1,157 +1,227 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {toast, ToastContainer} from "react-toastify";
|
||||
import { toast, ToastContainer } from "react-toastify";
|
||||
import axios from "axios";
|
||||
import {FormEvent, useEffect, useState} from "react";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import Head from "next/head";
|
||||
import useUser from "@/hooks/useUser";
|
||||
import {Divider} from "primereact/divider";
|
||||
import { Divider } from "primereact/divider";
|
||||
import Button from "@/components/Low/Button";
|
||||
import {BsArrowRepeat} from "react-icons/bs";
|
||||
import { BsArrowRepeat } from "react-icons/bs";
|
||||
import Link from "next/link";
|
||||
import Input from "@/components/Low/Input";
|
||||
import {useRouter} from "next/router";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
export function getServerSideProps({query, res}: {query: {oobCode: string; mode: string; apiKey?: string; continueUrl?: string}; res: any}) {
|
||||
if (!query || !query.oobCode || !query.mode) {
|
||||
res.setHeader("location", "/login");
|
||||
res.statusCode = 302;
|
||||
res.end();
|
||||
return {
|
||||
props: {},
|
||||
};
|
||||
}
|
||||
export function getServerSideProps({
|
||||
query,
|
||||
res,
|
||||
}: {
|
||||
query: {
|
||||
oobCode: string;
|
||||
mode: string;
|
||||
continueUrl?: string;
|
||||
};
|
||||
res: any;
|
||||
}) {
|
||||
if (!query || !query.oobCode || !query.mode) {
|
||||
res.setHeader("location", "/login");
|
||||
res.statusCode = 302;
|
||||
res.end();
|
||||
return {
|
||||
props: {},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
code: query.oobCode,
|
||||
mode: query.mode,
|
||||
apiKey: query.apiKey,
|
||||
continueUrl: query.continueUrl,
|
||||
},
|
||||
};
|
||||
return {
|
||||
props: {
|
||||
code: query.oobCode,
|
||||
mode: query.mode,
|
||||
...(query.continueUrl ? { continueUrl: query.continueUrl } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function Reset({code, mode, apiKey, continueUrl}: {code: string; mode: string; apiKey?: string; continueUrl?: string}) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
export default function Reset({
|
||||
code,
|
||||
mode,
|
||||
continueUrl,
|
||||
}: {
|
||||
code: string;
|
||||
mode: string;
|
||||
continueUrl?: string;
|
||||
}) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const router = useRouter();
|
||||
|
||||
useUser({
|
||||
redirectTo: "/",
|
||||
redirectIfFound: true,
|
||||
});
|
||||
useUser({
|
||||
redirectTo: "/",
|
||||
redirectIfFound: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "signIn") {
|
||||
axios
|
||||
.post<{ok: boolean}>("/api/reset/verify", {
|
||||
email: continueUrl?.replace("https://platform.encoach.com/", ""),
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.data.ok) {
|
||||
toast.success("Your account has been verified!", {toastId: "verify-successful"});
|
||||
setTimeout(() => {
|
||||
router.reload();
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (mode === "signIn") {
|
||||
axios
|
||||
.post<{ ok: boolean }>("/api/reset/verify", {
|
||||
email: continueUrl?.replace("https://platform.encoach.com/", ""),
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.data.ok) {
|
||||
toast.success("Your account has been verified!", {
|
||||
toastId: "verify-successful",
|
||||
});
|
||||
setTimeout(() => {
|
||||
router.push("/");
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("Something went wrong! Please make sure to click the link in your e-mail again and input the correct e-mail!", {
|
||||
toastId: "verify-error",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong! Please make sure to click the link in your e-mail again and input the correct e-mail!", {
|
||||
toastId: "verify-error",
|
||||
});
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
toast.error(
|
||||
"Something went wrong! Please make sure to click the link in your e-mail again and input the correct e-mail!",
|
||||
{
|
||||
toastId: "verify-error",
|
||||
},
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Something went wrong! Please make sure to click the link in your e-mail again and input the correct e-mail!",
|
||||
{
|
||||
toastId: "verify-error",
|
||||
},
|
||||
);
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const login = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const login = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.post<{ok: boolean}>("/api/reset/confirm", {code, password})
|
||||
.then((response) => {
|
||||
if (response.data.ok) {
|
||||
toast.success("Your password has been reset!", {toastId: "reset-successful"});
|
||||
setTimeout(() => {
|
||||
router.push("/login");
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.post<{ ok: boolean }>("/api/reset/confirm", { code, password })
|
||||
.then((response) => {
|
||||
if (response.data.ok) {
|
||||
toast.success("Your password has been reset!", {
|
||||
toastId: "reset-successful",
|
||||
});
|
||||
setTimeout(() => {
|
||||
router.push("/login");
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("Something went wrong! Please make sure to click the link in your e-mail again!", {toastId: "reset-error"});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong! Please make sure to click the link in your e-mail again!", {toastId: "reset-error"});
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
toast.error(
|
||||
"Something went wrong! Please make sure to click the link in your e-mail again!",
|
||||
{ toastId: "reset-error" },
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Something went wrong! Please make sure to click the link in your e-mail again!",
|
||||
{ toastId: "reset-error" },
|
||||
);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Reset | EnCoach</title>
|
||||
<meta name="description" content="Generated by create next app" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<main className="w-full h-[100vh] flex bg-white text-black">
|
||||
<ToastContainer />
|
||||
<section className="h-full w-fit min-w-fit relative hidden lg:flex">
|
||||
<div className="absolute h-full w-full bg-mti-rose-light z-10 bg-opacity-50" />
|
||||
<img src="/people-talking-tablet.png" alt="People smiling looking at a tablet" className="h-full aspect-auto" />
|
||||
</section>
|
||||
{mode === "resetPassword" && (
|
||||
<section className="h-full w-full flex flex-col items-center justify-center gap-2">
|
||||
<div className="flex flex-col gap-2 items-center relative">
|
||||
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-36 lg:w-64 absolute -top-36 lg:-top-64" />
|
||||
<h1 className="font-bold text-2xl lg:text-4xl">Reset your password</h1>
|
||||
<p className="self-start text-sm lg:text-base font-normal text-mti-gray-cool">to your registered Email Address</p>
|
||||
</div>
|
||||
<Divider className="max-w-xs lg:max-w-md" />
|
||||
<form className="flex flex-col items-center gap-6 w-full -lg:px-8 lg:w-1/2" onSubmit={login}>
|
||||
<Input type="password" name="password" onChange={(e) => setPassword(e)} placeholder="Password" />
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Reset | EnCoach</title>
|
||||
<meta name="description" content="Generated by create next app" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<main className="flex h-[100vh] w-full bg-white text-black">
|
||||
<ToastContainer />
|
||||
<section className="relative hidden h-full w-fit min-w-fit lg:flex">
|
||||
<div className="bg-mti-rose-light absolute z-10 h-full w-full bg-opacity-50" />
|
||||
<img
|
||||
src="/people-talking-tablet.png"
|
||||
alt="People smiling looking at a tablet"
|
||||
className="aspect-auto h-full"
|
||||
/>
|
||||
</section>
|
||||
{mode === "resetPassword" && (
|
||||
<section className="flex h-full w-full flex-col items-center justify-center gap-2">
|
||||
<div className="relative flex flex-col items-center gap-2">
|
||||
<img
|
||||
src="/logo_title.png"
|
||||
alt="EnCoach's Logo"
|
||||
className="absolute -top-36 w-36 lg:-top-64 lg:w-64"
|
||||
/>
|
||||
<h1 className="text-2xl font-bold lg:text-4xl">
|
||||
Reset your password
|
||||
</h1>
|
||||
<p className="text-mti-gray-cool self-start text-sm font-normal lg:text-base">
|
||||
to your registered Email Address
|
||||
</p>
|
||||
</div>
|
||||
<Divider className="max-w-xs lg:max-w-md" />
|
||||
<form
|
||||
className="-lg:px-8 flex w-full flex-col items-center gap-6 lg:w-1/2"
|
||||
onSubmit={login}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
name="password"
|
||||
onChange={(e) => setPassword(e)}
|
||||
placeholder="Password"
|
||||
/>
|
||||
|
||||
<Button className="mt-8 w-full" color="purple" disabled={isLoading}>
|
||||
{!isLoading && "Reset"}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
<span className="text-mti-gray-cool text-sm font-normal mt-8">
|
||||
Don't have an account?{" "}
|
||||
<Link className="text-mti-purple-light" href="/register">
|
||||
Sign up
|
||||
</Link>
|
||||
</span>
|
||||
</section>
|
||||
)}
|
||||
{mode === "signIn" && (
|
||||
<section className="h-full w-full flex flex-col items-center justify-center gap-2">
|
||||
<div className="flex flex-col gap-2 items-center relative">
|
||||
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-36 lg:w-64 absolute -top-36 lg:-top-64" />
|
||||
<h1 className="font-bold text-2xl lg:text-4xl">Confirm your account</h1>
|
||||
<p className="self-start text-sm lg:text-base font-normal text-mti-gray-cool">to your registered Email Address</p>
|
||||
</div>
|
||||
<Divider className="max-w-xs lg:max-w-md" />
|
||||
<div className="flex flex-col items-center gap-6 w-full -lg:px-8 lg:w-1/2">
|
||||
<span className="text-center">
|
||||
Your e-mail is currently being verified, please wait a second. <br /> <br />
|
||||
Once it has been verified, you will be redirected to the home page.
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
<Button
|
||||
className="mt-8 w-full"
|
||||
color="purple"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{!isLoading && "Reset"}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat
|
||||
className="animate-spin text-white"
|
||||
size={25}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
<span className="text-mti-gray-cool mt-8 text-sm font-normal">
|
||||
Don'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,22 +106,20 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
results: any;
|
||||
exams: {module: Module}[];
|
||||
startDate: string;
|
||||
pdf?: string;
|
||||
pdf: {
|
||||
path: string,
|
||||
version: string,
|
||||
},
|
||||
};
|
||||
if (!data) {
|
||||
res.status(400).end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.assigner !== req.session.user.id) {
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
if (data.pdf) {
|
||||
if (data.pdf && data.pdf.path && data.pdf.version === process.env.PDF_VERSION) {
|
||||
// if it does, return the pdf url
|
||||
const fileRef = ref(storage, data.pdf);
|
||||
const fileRef = ref(storage, data.pdf.path);
|
||||
const url = await getDownloadURL(fileRef);
|
||||
|
||||
res.status(200).end(url);
|
||||
return;
|
||||
}
|
||||
@@ -158,9 +156,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
return {...e, bandScore};
|
||||
});
|
||||
|
||||
const moduleResults = data.exams.map(({module}) => {
|
||||
// in order to make sure we are using unique modules, generate the set based on them
|
||||
const uniqueModules = [...new Set(flattenResults.map(item => item.module))] as Module[];
|
||||
const moduleResults = uniqueModules.map((module) => {
|
||||
const moduleResults = flattenResultsWithGrade.filter((e) => e.module === module);
|
||||
|
||||
const baseBandScore = moduleResults.reduce((accm, curr) => accm + curr.bandScore, 0) / moduleResults.length;
|
||||
const bandScore = isNaN(baseBandScore) ? 0 : baseBandScore;
|
||||
const {correct, total} = getScoreAndTotal(moduleResults);
|
||||
@@ -239,6 +238,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
const result = exams.length === 0 ? "N/A" : `${correct}/${total}`;
|
||||
|
||||
const userDemographicInformation = user?.demographicInformation as DemographicInformation;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: user?.name || "N/A",
|
||||
@@ -248,6 +249,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
result,
|
||||
level: showLevel ? getLevelScoreForUserExams(bandScore) : undefined,
|
||||
bandScore,
|
||||
passportId: userDemographicInformation?.passport_id || ""
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -387,7 +389,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
// update the stats entries with the pdf url to prevent duplication
|
||||
await updateDoc(docSnap.ref, {
|
||||
pdf: refName,
|
||||
pdf: {
|
||||
path: refName,
|
||||
version: process.env.PDF_VERSION,
|
||||
},
|
||||
});
|
||||
const url = await getDownloadURL(fileRef);
|
||||
res.status(200).end(url);
|
||||
@@ -419,8 +424,8 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.pdf) {
|
||||
const fileRef = ref(storage, data.pdf);
|
||||
if (data.pdf?.path) {
|
||||
const fileRef = ref(storage, data.pdf.path);
|
||||
const url = await getDownloadURL(fileRef);
|
||||
return res.redirect(url);
|
||||
}
|
||||
|
||||
@@ -1,149 +1,198 @@
|
||||
// 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} from "@/interfaces/exam";
|
||||
import {capitalize, flatten} 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, 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[]): Promise<ExamWithUser[]> => {
|
||||
if (generateMultiple) {
|
||||
// for optimization purposes, it would be better to create a new endpoint that returned the answers for all users at once
|
||||
const allExams = await assignees.map(async (assignee) => {
|
||||
const selectedModulePromises = await selectedModules.map(async (module: Module) => {
|
||||
try {
|
||||
const exams: Exam[] = await getExams(db, module, "true", assignee);
|
||||
const generateExams = async (
|
||||
generateMultiple: Boolean,
|
||||
selectedModules: Module[],
|
||||
assignees: string[],
|
||||
variant?: Variant,
|
||||
): 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,
|
||||
);
|
||||
|
||||
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 = await 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);
|
||||
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,
|
||||
...body
|
||||
} = req.body as {
|
||||
selectedModules: Module[];
|
||||
assignees: string[];
|
||||
generateMultiple: Boolean;
|
||||
name: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
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 exams: ExamWithUser[] = await generateExams(generateMultiple, selectedModules, assignees);
|
||||
const exams: ExamWithUser[] = await generateExams(
|
||||
generateMultiple,
|
||||
selectedModules,
|
||||
assignees,
|
||||
variant,
|
||||
);
|
||||
|
||||
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,
|
||||
...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 = exams.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,
|
||||
},
|
||||
},
|
||||
[assignee.email],
|
||||
"EnCoach - New Assignment!",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,77 +1,143 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type {NextApiRequest, NextApiResponse} from "next";
|
||||
import {app} from "@/firebase";
|
||||
import {getFirestore, setDoc, doc, query, collection, where, getDocs} from "firebase/firestore";
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {Type} from "@/interfaces/user";
|
||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||
import {uuidv4} from "@firebase/util";
|
||||
import {prepareMailer, prepareMailOptions} from "@/email";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { app } from "@/firebase";
|
||||
import {
|
||||
getFirestore,
|
||||
setDoc,
|
||||
doc,
|
||||
query,
|
||||
collection,
|
||||
where,
|
||||
getDocs,
|
||||
} from "firebase/firestore";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { Type } from "@/interfaces/user";
|
||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||
import { uuidv4 } from "@firebase/util";
|
||||
import { prepareMailer, prepareMailOptions } from "@/email";
|
||||
|
||||
const db = getFirestore(app);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ok: false, reason: "You must be logged in to generate a code!"});
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET") return get(req, res);
|
||||
if (req.method === "POST") return post(req, res);
|
||||
|
||||
const {type, codes, infos, expiryDate} = req.body as {
|
||||
type: Type;
|
||||
codes: string[];
|
||||
infos?: {email: string; name: string; passport_id: string}[];
|
||||
expiryDate: null | Date;
|
||||
};
|
||||
const permission = PERMISSIONS.generateCode[type];
|
||||
|
||||
if (!permission.includes(req.session.user.type)) {
|
||||
res.status(403).json({ok: false, reason: "Your account type does not have permissions to generate a code for that type of user!"});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.session.user.type === "corporate") {
|
||||
const codesGeneratedByUserSnapshot = await getDocs(query(collection(db, "codes"), where("creator", "==", req.session.user.id)));
|
||||
const totalCodes = codesGeneratedByUserSnapshot.docs.length + codes.length;
|
||||
const allowedCodes = req.session.user.corporateInformation?.companyInformation.userAmount || 0;
|
||||
|
||||
if (totalCodes > allowedCodes) {
|
||||
res.status(403).json({
|
||||
ok: false,
|
||||
reason: `You have or would have exceeded your amount of allowed codes, you currently are allowed to generate ${
|
||||
allowedCodes - codesGeneratedByUserSnapshot.docs.length
|
||||
} codes.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const codePromises = codes.map(async (code, index) => {
|
||||
const codeRef = doc(db, "codes", code);
|
||||
await setDoc(codeRef, {type, code, creator: req.session.user!.id, expiryDate});
|
||||
|
||||
if (infos && infos.length > index) {
|
||||
const {email, name, passport_id} = infos[index];
|
||||
await setDoc(codeRef, {email: email.trim(), name: name.trim(), passport_id: passport_id.trim()}, {merge: true});
|
||||
|
||||
const transport = prepareMailer();
|
||||
const mailOptions = prepareMailOptions(
|
||||
{
|
||||
type,
|
||||
code,
|
||||
},
|
||||
[email.trim()],
|
||||
"EnCoach Registration",
|
||||
"main",
|
||||
);
|
||||
|
||||
await transport.sendMail(mailOptions);
|
||||
}
|
||||
});
|
||||
|
||||
Promise.all(codePromises).then(() => {
|
||||
res.status(200).json({ok: true});
|
||||
});
|
||||
return res.status(404).json({ ok: false });
|
||||
}
|
||||
|
||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res
|
||||
.status(401)
|
||||
.json({ ok: false, reason: "You must be logged in to generate a code!" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { creator } = req.query as { creator?: string };
|
||||
const q = query(collection(db, "codes"), where("creator", "==", creator));
|
||||
const snapshot = await getDocs(creator ? q : collection(db, "codes"));
|
||||
|
||||
res.status(200).json(snapshot.docs.map((doc) => doc.data()));
|
||||
}
|
||||
|
||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res
|
||||
.status(401)
|
||||
.json({ ok: false, reason: "You must be logged in to generate a code!" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, codes, infos, expiryDate } = req.body as {
|
||||
type: Type;
|
||||
codes: string[];
|
||||
infos?: { email: string; name: string; passport_id?: string }[];
|
||||
expiryDate: null | Date;
|
||||
};
|
||||
const permission = PERMISSIONS.generateCode[type];
|
||||
|
||||
if (!permission.includes(req.session.user.type)) {
|
||||
res
|
||||
.status(403)
|
||||
.json({
|
||||
ok: false,
|
||||
reason:
|
||||
"Your account type does not have permissions to generate a code for that type of user!",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.session.user.type === "corporate") {
|
||||
const codesGeneratedByUserSnapshot = await getDocs(
|
||||
query(
|
||||
collection(db, "codes"),
|
||||
where("creator", "==", req.session.user.id),
|
||||
),
|
||||
);
|
||||
const totalCodes = codesGeneratedByUserSnapshot.docs.length + codes.length;
|
||||
const allowedCodes =
|
||||
req.session.user.corporateInformation?.companyInformation.userAmount || 0;
|
||||
|
||||
if (totalCodes > allowedCodes) {
|
||||
res.status(403).json({
|
||||
ok: false,
|
||||
reason: `You have or would have exceeded your amount of allowed codes, you currently are allowed to generate ${
|
||||
allowedCodes - codesGeneratedByUserSnapshot.docs.length
|
||||
} codes.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const codePromises = codes.map(async (code, index) => {
|
||||
const codeRef = doc(db, "codes", code);
|
||||
const codeInformation = {
|
||||
type,
|
||||
code,
|
||||
creator: req.session.user!.id,
|
||||
expiryDate,
|
||||
};
|
||||
|
||||
if (infos && infos.length > index) {
|
||||
const { email, name, passport_id } = infos[index];
|
||||
|
||||
const transport = prepareMailer();
|
||||
const mailOptions = prepareMailOptions(
|
||||
{
|
||||
type,
|
||||
code,
|
||||
},
|
||||
[email.toLowerCase().trim()],
|
||||
"EnCoach Registration",
|
||||
"main",
|
||||
);
|
||||
|
||||
try {
|
||||
await transport.sendMail(mailOptions);
|
||||
await setDoc(
|
||||
codeRef,
|
||||
{
|
||||
...codeInformation,
|
||||
email: email.trim().toLowerCase(),
|
||||
name: name.trim(),
|
||||
...(passport_id ? { passport_id: passport_id.trim() } : {}),
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
await setDoc(codeRef, codeInformation);
|
||||
}
|
||||
});
|
||||
|
||||
Promise.all(codePromises).then((results) => {
|
||||
res.status(200).json({ ok: true, valid: results.filter((x) => x).length });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,8 +39,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) return res.status(401).json({ok: false});
|
||||
if (req.session.user.type !== "developer") return res.status(403).json({ok: false});
|
||||
|
||||
const {endpoint, topic, exercises} = req.query as {module: Module; endpoint: string; topic?: string; exercises?: string[]};
|
||||
const url = `${process.env.BACKEND_URL}/${endpoint}`;
|
||||
const {endpoint, topic, exercises} = req.query as {module: Module; endpoint: string[]; topic?: string; exercises?: string[]};
|
||||
const url = `${process.env.BACKEND_URL}/${endpoint.join("/")}`;
|
||||
|
||||
const result = await axios.post(
|
||||
`${url}${topic && exercises ? `?topic=${topic.toLowerCase()}&exercises=${exercises.join("&exercises=")}` : ""}`,
|
||||
@@ -4,8 +4,8 @@ import {app} from "@/firebase";
|
||||
import {getFirestore, setDoc, doc} from "firebase/firestore";
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {Exam} from "@/interfaces/exam";
|
||||
import { getExams } from "@/utils/exams.be";
|
||||
import {Exam, Variant} from "@/interfaces/exam";
|
||||
import {getExams} from "@/utils/exams.be";
|
||||
const db = getFirestore(app);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
@@ -23,12 +23,9 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
module,
|
||||
avoidRepeated,
|
||||
} = req.query as {module: string; avoidRepeated: string};
|
||||
const {module, avoidRepeated, variant} = req.query as {module: string; avoidRepeated: string; variant?: Variant};
|
||||
|
||||
const exams: Exam[] = await getExams(db, module, avoidRepeated, req.session.user.id);
|
||||
const exams: Exam[] = await getExams(db, module, avoidRepeated, req.session.user.id, variant);
|
||||
res.status(200).json(exams);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,80 +1,108 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type {NextApiRequest, NextApiResponse} from "next";
|
||||
import {app} from "@/firebase";
|
||||
import {getFirestore, collection, getDocs, getDoc, doc, deleteDoc, setDoc} from "firebase/firestore";
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {Group} from "@/interfaces/user";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { app } from "@/firebase";
|
||||
import {
|
||||
getFirestore,
|
||||
collection,
|
||||
getDocs,
|
||||
getDoc,
|
||||
doc,
|
||||
deleteDoc,
|
||||
setDoc,
|
||||
} from "firebase/firestore";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { Group } from "@/interfaces/user";
|
||||
import { updateExpiryDateOnGroup } from "@/utils/groups.be";
|
||||
|
||||
const db = getFirestore(app);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") return await get(req, res);
|
||||
if (req.method === "DELETE") return await del(req, res);
|
||||
if (req.method === "PATCH") return await patch(req, res);
|
||||
if (req.method === "GET") return await get(req, res);
|
||||
if (req.method === "DELETE") return await del(req, res);
|
||||
if (req.method === "PATCH") return await patch(req, res);
|
||||
|
||||
res.status(404).json(undefined);
|
||||
res.status(404).json(undefined);
|
||||
}
|
||||
|
||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const {id} = req.query as {id: string};
|
||||
const { id } = req.query as { id: string };
|
||||
|
||||
const snapshot = await getDoc(doc(db, "groups", id));
|
||||
const snapshot = await getDoc(doc(db, "groups", id));
|
||||
|
||||
if (snapshot.exists()) {
|
||||
res.status(200).json({...snapshot.data(), id: snapshot.id});
|
||||
} else {
|
||||
res.status(404).json(undefined);
|
||||
}
|
||||
if (snapshot.exists()) {
|
||||
res.status(200).json({ ...snapshot.data(), id: snapshot.id });
|
||||
} else {
|
||||
res.status(404).json(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function del(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const {id} = req.query as {id: string};
|
||||
const { id } = req.query as { id: string };
|
||||
|
||||
const snapshot = await getDoc(doc(db, "groups", id));
|
||||
const group = {...snapshot.data(), id: snapshot.id} as Group;
|
||||
const snapshot = await getDoc(doc(db, "groups", id));
|
||||
const group = { ...snapshot.data(), id: snapshot.id } as Group;
|
||||
|
||||
const user = req.session.user;
|
||||
if (user.type === "admin" || user.type === "developer" || user.id === group.admin) {
|
||||
await deleteDoc(snapshot.ref);
|
||||
const user = req.session.user;
|
||||
if (
|
||||
user.type === "admin" ||
|
||||
user.type === "developer" ||
|
||||
user.id === group.admin
|
||||
) {
|
||||
await deleteDoc(snapshot.ref);
|
||||
|
||||
res.status(200).json({ok: true});
|
||||
return;
|
||||
}
|
||||
res.status(200).json({ ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(403).json({ok: false});
|
||||
res.status(403).json({ ok: false });
|
||||
}
|
||||
|
||||
async function patch(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const {id} = req.query as {id: string};
|
||||
const { id } = req.query as { id: string };
|
||||
|
||||
const snapshot = await getDoc(doc(db, "groups", id));
|
||||
const group = {...snapshot.data(), id: snapshot.id} as Group;
|
||||
const snapshot = await getDoc(doc(db, "groups", id));
|
||||
const group = { ...snapshot.data(), id: snapshot.id } as Group;
|
||||
|
||||
const user = req.session.user;
|
||||
if (user.type === "admin" || user.type === "developer" || user.id === group.admin) {
|
||||
await setDoc(snapshot.ref, req.body, {merge: true});
|
||||
const user = req.session.user;
|
||||
if (
|
||||
user.type === "admin" ||
|
||||
user.type === "developer" ||
|
||||
user.id === group.admin
|
||||
) {
|
||||
if ("participants" in req.body) {
|
||||
const newParticipants = (req.body.participants as string[]).filter(
|
||||
(x) => !group.participants.includes(x),
|
||||
);
|
||||
await Promise.all(
|
||||
newParticipants.map(
|
||||
async (p) => await updateExpiryDateOnGroup(p, group.admin),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
res.status(200).json({ok: true});
|
||||
return;
|
||||
}
|
||||
await setDoc(snapshot.ref, req.body, { merge: true });
|
||||
|
||||
res.status(403).json({ok: false});
|
||||
res.status(200).json({ ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(403).json({ ok: false });
|
||||
}
|
||||
|
||||
@@ -1,45 +1,73 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type {NextApiRequest, NextApiResponse} from "next";
|
||||
import {app} from "@/firebase";
|
||||
import {getFirestore, collection, getDocs, setDoc, doc, query, where} from "firebase/firestore";
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {Group} from "@/interfaces/user";
|
||||
import {v4} from "uuid";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { app } from "@/firebase";
|
||||
import {
|
||||
getFirestore,
|
||||
collection,
|
||||
getDocs,
|
||||
setDoc,
|
||||
doc,
|
||||
query,
|
||||
where,
|
||||
} from "firebase/firestore";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { Group } from "@/interfaces/user";
|
||||
import { v4 } from "uuid";
|
||||
import { updateExpiryDateOnGroup } from "@/utils/groups.be";
|
||||
|
||||
const db = getFirestore(app);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET") await get(req, res);
|
||||
if (req.method === "POST") await post(req, res);
|
||||
if (req.method === "GET") await get(req, res);
|
||||
if (req.method === "POST") await post(req, res);
|
||||
}
|
||||
|
||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
const {admin, participant} = req.query as {admin: string; participant: string};
|
||||
const { admin, participant } = req.query as {
|
||||
admin: string;
|
||||
participant: string;
|
||||
};
|
||||
|
||||
const queryConstraints = [
|
||||
...(admin ? [where("admin", "==", admin)] : []),
|
||||
...(participant ? [where("participants", "array-contains", participant)] : []),
|
||||
];
|
||||
const snapshot = await getDocs(queryConstraints.length > 0 ? query(collection(db, "groups"), ...queryConstraints) : collection(db, "groups"));
|
||||
const groups = snapshot.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
...doc.data(),
|
||||
})) as Group[];
|
||||
const queryConstraints = [
|
||||
...(admin ? [where("admin", "==", admin)] : []),
|
||||
...(participant
|
||||
? [where("participants", "array-contains", participant)]
|
||||
: []),
|
||||
];
|
||||
const snapshot = await getDocs(
|
||||
queryConstraints.length > 0
|
||||
? query(collection(db, "groups"), ...queryConstraints)
|
||||
: collection(db, "groups"),
|
||||
);
|
||||
const groups = snapshot.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
...doc.data(),
|
||||
})) as Group[];
|
||||
|
||||
res.status(200).json(groups);
|
||||
res.status(200).json(groups);
|
||||
}
|
||||
|
||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
const body = req.body as Group;
|
||||
const body = req.body as Group;
|
||||
|
||||
await setDoc(doc(db, "groups", v4()), {name: body.name, admin: body.admin, participants: body.participants});
|
||||
res.status(200).json({ok: true});
|
||||
await Promise.all(
|
||||
body.participants.map(
|
||||
async (p) => await updateExpiryDateOnGroup(p, body.admin),
|
||||
),
|
||||
);
|
||||
|
||||
await setDoc(doc(db, "groups", v4()), {
|
||||
name: body.name,
|
||||
admin: body.admin,
|
||||
participants: body.participants,
|
||||
});
|
||||
res.status(200).json({ ok: true });
|
||||
}
|
||||
|
||||
82
src/pages/api/invites/[id].ts
Normal file
82
src/pages/api/invites/[id].ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { app } from "@/firebase";
|
||||
import {
|
||||
getFirestore,
|
||||
getDoc,
|
||||
doc,
|
||||
deleteDoc,
|
||||
setDoc,
|
||||
} from "firebase/firestore";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { Ticket } from "@/interfaces/ticket";
|
||||
import { Invite } from "@/interfaces/invite";
|
||||
|
||||
const db = getFirestore(app);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") return await get(req, res);
|
||||
if (req.method === "DELETE") return await del(req, res);
|
||||
if (req.method === "PATCH") return await patch(req, res);
|
||||
|
||||
res.status(404).json(undefined);
|
||||
}
|
||||
|
||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.query as { id: string };
|
||||
|
||||
const snapshot = await getDoc(doc(db, "invites", id));
|
||||
|
||||
if (snapshot.exists()) {
|
||||
res.status(200).json({ ...snapshot.data(), id: snapshot.id });
|
||||
} else {
|
||||
res.status(404).json(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function del(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.query as { id: string };
|
||||
|
||||
const snapshot = await getDoc(doc(db, "invites", id));
|
||||
const data = snapshot.data() as Invite;
|
||||
|
||||
const user = req.session.user;
|
||||
if (user.type === "admin" || user.type === "developer") {
|
||||
await deleteDoc(snapshot.ref);
|
||||
res.status(200).json({ ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(403).json({ ok: false });
|
||||
}
|
||||
|
||||
async function patch(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.query as { id: string };
|
||||
const snapshot = await getDoc(doc(db, "invites", id));
|
||||
|
||||
const user = req.session.user;
|
||||
if (user.type === "admin" || user.type === "developer") {
|
||||
await setDoc(snapshot.ref, req.body, { merge: true });
|
||||
return res.status(200).json({ ok: true });
|
||||
}
|
||||
|
||||
res.status(403).json({ ok: false });
|
||||
}
|
||||
128
src/pages/api/invites/accept/[id].ts
Normal file
128
src/pages/api/invites/accept/[id].ts
Normal file
@@ -0,0 +1,128 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type {NextApiRequest, NextApiResponse} from "next";
|
||||
import {app} from "@/firebase";
|
||||
import {getFirestore, getDoc, doc, deleteDoc, setDoc, getDocs, collection, where, query} from "firebase/firestore";
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {Ticket} from "@/interfaces/ticket";
|
||||
import {Invite} from "@/interfaces/invite";
|
||||
import {CorporateUser, Group, User} from "@/interfaces/user";
|
||||
import {v4} from "uuid";
|
||||
import {sendEmail} from "@/email";
|
||||
import {updateExpiryDateOnGroup} from "@/utils/groups.be";
|
||||
|
||||
const db = getFirestore(app);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") return await get(req, res);
|
||||
|
||||
res.status(404).json(undefined);
|
||||
}
|
||||
|
||||
async function addToInviterGroup(user: User, invitedBy: User) {
|
||||
const invitedByGroupsRef = await getDocs(query(collection(db, "groups"), where("admin", "==", invitedBy.id)));
|
||||
const invitedByGroups = invitedByGroupsRef.docs.map((g) => ({
|
||||
...g.data(),
|
||||
id: g.id,
|
||||
})) as Group[];
|
||||
|
||||
const typeGroupName = user.type === "student" ? "Students" : user.type === "teacher" ? "Teachers" : undefined;
|
||||
|
||||
if (typeGroupName) {
|
||||
const typeGroup: Group = invitedByGroups.find((g) => g.name === typeGroupName) || {
|
||||
id: v4(),
|
||||
admin: invitedBy.id,
|
||||
name: typeGroupName,
|
||||
participants: [],
|
||||
disableEditing: true,
|
||||
};
|
||||
await setDoc(
|
||||
doc(db, "groups", typeGroup.id),
|
||||
{
|
||||
...typeGroup,
|
||||
participants: [...typeGroup.participants.filter((x) => x !== user.id), user.id],
|
||||
},
|
||||
{merge: true},
|
||||
);
|
||||
}
|
||||
|
||||
const invitationsGroup: Group = invitedByGroups.find((g) => g.name === "Invited") || {
|
||||
id: v4(),
|
||||
admin: invitedBy.id,
|
||||
name: "Invited",
|
||||
participants: [],
|
||||
disableEditing: true,
|
||||
};
|
||||
await setDoc(
|
||||
doc(db, "groups", invitationsGroup.id),
|
||||
{
|
||||
...invitationsGroup,
|
||||
participants: [...invitationsGroup.participants.filter((x) => x !== user.id), user.id],
|
||||
},
|
||||
{
|
||||
merge: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteFromPreviousCorporateGroups(user: User, invitedBy: User) {
|
||||
const corporatesRef = await getDocs(query(collection(db, "users"), where("type", "==", "corporate")));
|
||||
const corporates = (corporatesRef.docs.map((x) => ({...x.data(), id: x.id})) as CorporateUser[]).filter((x) => x.id !== invitedBy.id);
|
||||
|
||||
const userGroupsRef = await getDocs(query(collection(db, "groups"), where("participants", "array-contains", user.id)));
|
||||
const userGroups = userGroupsRef.docs.map((x) => ({...x.data(), id: x.id})) as Group[];
|
||||
|
||||
const corporateGroups = userGroups.filter((x) => corporates.map((c) => c.id).includes(x.admin));
|
||||
await Promise.all(
|
||||
corporateGroups.map(async (group) => {
|
||||
await setDoc(doc(db, "groups", group.id), {participants: group.participants.filter((x) => x !== user.id)}, {merge: true});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
|
||||
const {id} = req.query as {id: string};
|
||||
const snapshot = await getDoc(doc(db, "invites", id));
|
||||
|
||||
if (snapshot.exists()) {
|
||||
const invite = {...snapshot.data(), id: snapshot.id} as Invite;
|
||||
if (invite.to !== req.session.user.id) return res.status(403).json({ok: false});
|
||||
|
||||
await deleteDoc(snapshot.ref);
|
||||
const invitedByRef = await getDoc(doc(db, "users", invite.from));
|
||||
if (!invitedByRef.exists()) return res.status(404).json({ok: false});
|
||||
|
||||
await updateExpiryDateOnGroup(invite.to, invite.from);
|
||||
|
||||
const invitedBy = {...invitedByRef.data(), id: invitedByRef.id} as User;
|
||||
|
||||
if (invitedBy.type === "corporate") await deleteFromPreviousCorporateGroups(req.session.user, invitedBy);
|
||||
await addToInviterGroup(req.session.user, invitedBy);
|
||||
|
||||
try {
|
||||
await sendEmail(
|
||||
"respondedInvite",
|
||||
{
|
||||
corporateName: invitedBy.name,
|
||||
name: req.session.user.name,
|
||||
decision: "accept",
|
||||
},
|
||||
[invitedBy.email],
|
||||
`${req.session.user.name} has accepted your invite!`,
|
||||
);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
res.status(200).json({ok: true});
|
||||
} else {
|
||||
res.status(404).json(undefined);
|
||||
}
|
||||
}
|
||||
72
src/pages/api/invites/decline/[id].ts
Normal file
72
src/pages/api/invites/decline/[id].ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { app } from "@/firebase";
|
||||
import {
|
||||
getFirestore,
|
||||
getDoc,
|
||||
doc,
|
||||
deleteDoc,
|
||||
setDoc,
|
||||
getDocs,
|
||||
collection,
|
||||
where,
|
||||
query,
|
||||
} from "firebase/firestore";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { Ticket } from "@/interfaces/ticket";
|
||||
import { Invite } from "@/interfaces/invite";
|
||||
import { Group, User } from "@/interfaces/user";
|
||||
import { v4 } from "uuid";
|
||||
import { sendEmail } from "@/email";
|
||||
|
||||
const db = getFirestore(app);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") return await get(req, res);
|
||||
|
||||
res.status(404).json(undefined);
|
||||
}
|
||||
|
||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.query as { id: string };
|
||||
const snapshot = await getDoc(doc(db, "invites", id));
|
||||
|
||||
if (snapshot.exists()) {
|
||||
const invite = { ...snapshot.data(), id: snapshot.id } as Invite;
|
||||
if (invite.to !== req.session.user.id)
|
||||
return res.status(403).json({ ok: false });
|
||||
|
||||
await deleteDoc(snapshot.ref);
|
||||
const invitedByRef = await getDoc(doc(db, "users", invite.from));
|
||||
if (!invitedByRef.exists()) return res.status(404).json({ ok: false });
|
||||
|
||||
const invitedBy = { ...invitedByRef.data(), id: invitedByRef.id } as User;
|
||||
|
||||
try {
|
||||
await sendEmail(
|
||||
"respondedInvite",
|
||||
{
|
||||
corporateName: invitedBy.name,
|
||||
name: req.session.user.name,
|
||||
decision: "decline",
|
||||
},
|
||||
[invitedBy.email],
|
||||
`${req.session.user.name} has declined your invite!`,
|
||||
);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
res.status(200).json({ ok: true });
|
||||
} else {
|
||||
res.status(404).json(undefined);
|
||||
}
|
||||
}
|
||||
88
src/pages/api/invites/index.ts
Normal file
88
src/pages/api/invites/index.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import { sendEmail } from "@/email";
|
||||
import { app } from "@/firebase";
|
||||
import { Invite } from "@/interfaces/invite";
|
||||
import { Ticket } from "@/interfaces/ticket";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import {
|
||||
collection,
|
||||
doc,
|
||||
getDoc,
|
||||
getDocs,
|
||||
getFirestore,
|
||||
setDoc,
|
||||
} from "firebase/firestore";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import ShortUniqueId from "short-unique-id";
|
||||
|
||||
const db = getFirestore(app);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET") await get(req, res);
|
||||
if (req.method === "POST") await post(req, res);
|
||||
}
|
||||
|
||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
const snapshot = await getDocs(collection(db, "invites"));
|
||||
|
||||
res.status(200).json(
|
||||
snapshot.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
...doc.data(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
const body = req.body as Invite;
|
||||
|
||||
const existingInvites = (await getDocs(collection(db, "invites"))).docs.map(
|
||||
(x) => ({ ...x.data(), id: x.id }),
|
||||
) as Invite[];
|
||||
|
||||
const invitedRef = await getDoc(doc(db, "users", body.to));
|
||||
if (!invitedRef.exists()) return res.status(404).json({ ok: false });
|
||||
|
||||
const invitedByRef = await getDoc(doc(db, "users", body.from));
|
||||
if (!invitedByRef.exists()) return res.status(404).json({ ok: false });
|
||||
|
||||
const invited = { ...invitedRef.data(), id: invitedRef.id } as User;
|
||||
const invitedBy = { ...invitedByRef.data(), id: invitedByRef.id } as User;
|
||||
|
||||
try {
|
||||
await sendEmail(
|
||||
"receivedInvite",
|
||||
{
|
||||
name: invited.name,
|
||||
corporateName:
|
||||
invitedBy.type === "corporate"
|
||||
? invitedBy.corporateInformation?.companyInformation?.name ||
|
||||
invitedBy.name
|
||||
: invitedBy.name,
|
||||
},
|
||||
[invited.email],
|
||||
"You have been invited to a group!",
|
||||
);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
if (
|
||||
existingInvites.filter((i) => i.to === body.to && i.from === body.from)
|
||||
.length == 0
|
||||
) {
|
||||
const shortUID = new ShortUniqueId();
|
||||
await setDoc(doc(db, "invites", body.id || shortUID.randomUUID(8)), body);
|
||||
}
|
||||
|
||||
res.status(200).json({ ok: true });
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import {NextApiRequest, NextApiResponse} from "next";
|
||||
import {getAuth, signInWithEmailAndPassword} from "firebase/auth";
|
||||
import {app} from "@/firebase";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {User} from "@/interfaces/user";
|
||||
import {getFirestore, getDoc, doc} from "firebase/firestore";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getAuth, signInWithEmailAndPassword } from "firebase/auth";
|
||||
import { app } from "@/firebase";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { getFirestore, getDoc, doc } from "firebase/firestore";
|
||||
|
||||
const auth = getAuth(app);
|
||||
const db = getFirestore(app);
|
||||
@@ -12,27 +12,27 @@ const db = getFirestore(app);
|
||||
export default withIronSessionApiRoute(login, sessionOptions);
|
||||
|
||||
async function login(req: NextApiRequest, res: NextApiResponse) {
|
||||
const {email, password} = req.body as {email: string; password: string};
|
||||
const { email, password } = req.body as { email: string; password: string };
|
||||
|
||||
signInWithEmailAndPassword(auth, email, password)
|
||||
.then(async (userCredentials) => {
|
||||
const userId = userCredentials.user.uid;
|
||||
signInWithEmailAndPassword(auth, email.toLowerCase(), password)
|
||||
.then(async (userCredentials) => {
|
||||
const userId = userCredentials.user.uid;
|
||||
|
||||
const docUser = await getDoc(doc(db, "users", userId));
|
||||
if (!docUser.exists()) {
|
||||
res.status(401).json({error: 401, message: "User does not exist!"});
|
||||
return;
|
||||
}
|
||||
const docUser = await getDoc(doc(db, "users", userId));
|
||||
if (!docUser.exists()) {
|
||||
res.status(401).json({ error: 401, message: "User does not exist!" });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = docUser.data() as User;
|
||||
const user = docUser.data() as User;
|
||||
|
||||
req.session.user = {...user, id: userId};
|
||||
await req.session.save();
|
||||
req.session.user = { ...user, id: userId };
|
||||
await req.session.save();
|
||||
|
||||
res.status(200).json({user: {...user, id: userId}});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
res.status(401).json({error});
|
||||
});
|
||||
res.status(200).json({ user: { ...user, id: userId } });
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
res.status(401).json({ error });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -74,6 +74,10 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (user.type === "admin" || user.type === "developer") {
|
||||
await setDoc(snapshot.ref, req.body, {merge: true});
|
||||
|
||||
if (req.body.isPaid) {
|
||||
const corporateID = req.body.corporate;
|
||||
await setDoc(doc(db, "users", corporateID), {status: "active"}, {merge: true});
|
||||
}
|
||||
return res.status(200).json({ok: true});
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
const updatedDoc = (await getDoc(doc(db, "payments", paymentId))).data() as Payment;
|
||||
if (updatedDoc.commissionTransfer && updatedDoc.corporateTransfer) {
|
||||
await setDoc(doc(db, "payments", paymentId), {isPaid: true}, {merge: true});
|
||||
|
||||
await setDoc(doc(db, "users", updatedDoc.corporate), {status: "active"}, {merge: true});
|
||||
}
|
||||
res.status(200).json({ref});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import {NextApiRequest, NextApiResponse} from "next";
|
||||
import {createUserWithEmailAndPassword, getAuth} from "firebase/auth";
|
||||
import {app} from "@/firebase";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {getFirestore, doc, setDoc, query, collection, where, getDocs} from "firebase/firestore";
|
||||
import {CorporateInformation, DemographicInformation, Type} from "@/interfaces/user";
|
||||
import {addUserToGroupOnCreation} from "@/utils/registration";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createUserWithEmailAndPassword, getAuth } from "firebase/auth";
|
||||
import { app } from "@/firebase";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import {
|
||||
getFirestore,
|
||||
doc,
|
||||
setDoc,
|
||||
query,
|
||||
collection,
|
||||
where,
|
||||
getDocs,
|
||||
} from "firebase/firestore";
|
||||
import {
|
||||
CorporateInformation,
|
||||
DemographicInformation,
|
||||
Type,
|
||||
} from "@/interfaces/user";
|
||||
import { addUserToGroupOnCreation } from "@/utils/registration";
|
||||
import moment from "moment";
|
||||
|
||||
const auth = getAuth(app);
|
||||
@@ -14,117 +26,140 @@ const db = getFirestore(app);
|
||||
export default withIronSessionApiRoute(register, sessionOptions);
|
||||
|
||||
const DEFAULT_DESIRED_LEVELS = {
|
||||
reading: 9,
|
||||
listening: 9,
|
||||
writing: 9,
|
||||
speaking: 9,
|
||||
reading: 9,
|
||||
listening: 9,
|
||||
writing: 9,
|
||||
speaking: 9,
|
||||
};
|
||||
|
||||
const DEFAULT_LEVELS = {
|
||||
reading: 0,
|
||||
listening: 0,
|
||||
writing: 0,
|
||||
speaking: 0,
|
||||
reading: 0,
|
||||
listening: 0,
|
||||
writing: 0,
|
||||
speaking: 0,
|
||||
};
|
||||
|
||||
async function register(req: NextApiRequest, res: NextApiResponse) {
|
||||
const {type} = req.body as {
|
||||
type: "individual" | "corporate";
|
||||
};
|
||||
const { type } = req.body as {
|
||||
type: "individual" | "corporate";
|
||||
};
|
||||
|
||||
if (type === "individual") return registerIndividual(req, res);
|
||||
if (type === "corporate") return registerCorporate(req, res);
|
||||
if (type === "individual") return registerIndividual(req, res);
|
||||
if (type === "corporate") return registerCorporate(req, res);
|
||||
}
|
||||
|
||||
async function registerIndividual(req: NextApiRequest, res: NextApiResponse) {
|
||||
const {email, passport_id, password, code} = req.body as {
|
||||
email: string;
|
||||
passport_id?: string;
|
||||
password: string;
|
||||
code?: string;
|
||||
};
|
||||
const { email, passport_id, password, code } = req.body as {
|
||||
email: string;
|
||||
passport_id?: string;
|
||||
password: string;
|
||||
code?: string;
|
||||
};
|
||||
|
||||
const codeQuery = query(collection(db, "codes"), where("code", "==", code));
|
||||
const codeDocs = (await getDocs(codeQuery)).docs.filter((x) => !Object.keys(x.data()).includes("userId"));
|
||||
const codeQuery = query(collection(db, "codes"), where("code", "==", code));
|
||||
const codeDocs = (await getDocs(codeQuery)).docs.filter(
|
||||
(x) => !Object.keys(x.data()).includes("userId"),
|
||||
);
|
||||
|
||||
if (code && code.length > 0 && codeDocs.length === 0) {
|
||||
res.status(400).json({error: "Invalid Code!"});
|
||||
return;
|
||||
}
|
||||
if (code && code.length > 0 && codeDocs.length === 0) {
|
||||
res.status(400).json({ error: "Invalid Code!" });
|
||||
return;
|
||||
}
|
||||
|
||||
const codeData = codeDocs.length > 0 ? (codeDocs[0].data() as {code: string; type: Type; creator?: string; expiryDate: Date | null}) : undefined;
|
||||
const codeData =
|
||||
codeDocs.length > 0
|
||||
? (codeDocs[0].data() as {
|
||||
code: string;
|
||||
type: Type;
|
||||
creator?: string;
|
||||
expiryDate: Date | null;
|
||||
})
|
||||
: undefined;
|
||||
|
||||
createUserWithEmailAndPassword(auth, email, password)
|
||||
.then(async (userCredentials) => {
|
||||
const userId = userCredentials.user.uid;
|
||||
delete req.body.password;
|
||||
createUserWithEmailAndPassword(auth, email.toLowerCase(), password)
|
||||
.then(async (userCredentials) => {
|
||||
const userId = userCredentials.user.uid;
|
||||
delete req.body.password;
|
||||
|
||||
const user = {
|
||||
...req.body,
|
||||
desiredLevels: DEFAULT_DESIRED_LEVELS,
|
||||
levels: DEFAULT_LEVELS,
|
||||
bio: "",
|
||||
isFirstLogin: codeData ? codeData.type === "student" : true,
|
||||
focus: "academic",
|
||||
type: email.endsWith("@ecrop.dev") ? "developer" : codeData ? codeData.type : "student",
|
||||
subscriptionExpirationDate: codeData ? codeData.expiryDate : moment().subtract(1, "days").toISOString(),
|
||||
...(passport_id ? {demographicInformation: {passport_id}} : {}),
|
||||
registrationDate: new Date(),
|
||||
status: code ? "active" : "paymentDue",
|
||||
};
|
||||
const user = {
|
||||
...req.body,
|
||||
email: email.toLowerCase(),
|
||||
desiredLevels: DEFAULT_DESIRED_LEVELS,
|
||||
levels: DEFAULT_LEVELS,
|
||||
bio: "",
|
||||
isFirstLogin: codeData ? codeData.type === "student" : true,
|
||||
focus: "academic",
|
||||
type: email.endsWith("@ecrop.dev")
|
||||
? "developer"
|
||||
: codeData
|
||||
? codeData.type
|
||||
: "student",
|
||||
subscriptionExpirationDate: codeData
|
||||
? codeData.expiryDate
|
||||
: moment().subtract(1, "days").toISOString(),
|
||||
...(passport_id ? { demographicInformation: { passport_id } } : {}),
|
||||
registrationDate: new Date().toISOString(),
|
||||
status: code ? "active" : "paymentDue",
|
||||
};
|
||||
|
||||
await setDoc(doc(db, "users", userId), user);
|
||||
await setDoc(doc(db, "users", userId), user);
|
||||
|
||||
if (codeDocs.length > 0 && codeData) {
|
||||
await setDoc(codeDocs[0].ref, {userId: userId}, {merge: true});
|
||||
if (codeData.creator) await addUserToGroupOnCreation(userId, codeData.type, codeData.creator);
|
||||
}
|
||||
if (codeDocs.length > 0 && codeData) {
|
||||
await setDoc(codeDocs[0].ref, { userId: userId }, { merge: true });
|
||||
if (codeData.creator)
|
||||
await addUserToGroupOnCreation(
|
||||
userId,
|
||||
codeData.type,
|
||||
codeData.creator,
|
||||
);
|
||||
}
|
||||
|
||||
req.session.user = {...user, id: userId};
|
||||
await req.session.save();
|
||||
req.session.user = { ...user, id: userId };
|
||||
await req.session.save();
|
||||
|
||||
res.status(200).json({user: {...user, id: userId}});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
res.status(401).json({error});
|
||||
});
|
||||
res.status(200).json({ user: { ...user, id: userId } });
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
res.status(401).json({ error });
|
||||
});
|
||||
}
|
||||
|
||||
async function registerCorporate(req: NextApiRequest, res: NextApiResponse) {
|
||||
const {email, password} = req.body as {
|
||||
email: string;
|
||||
password: string;
|
||||
corporateInformation: CorporateInformation;
|
||||
};
|
||||
const { email, password } = req.body as {
|
||||
email: string;
|
||||
password: string;
|
||||
corporateInformation: CorporateInformation;
|
||||
};
|
||||
|
||||
createUserWithEmailAndPassword(auth, email, password)
|
||||
.then(async (userCredentials) => {
|
||||
const userId = userCredentials.user.uid;
|
||||
delete req.body.password;
|
||||
createUserWithEmailAndPassword(auth, email.toLowerCase(), password)
|
||||
.then(async (userCredentials) => {
|
||||
const userId = userCredentials.user.uid;
|
||||
delete req.body.password;
|
||||
|
||||
const user = {
|
||||
...req.body,
|
||||
desiredLevels: DEFAULT_DESIRED_LEVELS,
|
||||
levels: DEFAULT_LEVELS,
|
||||
bio: "",
|
||||
isFirstLogin: false,
|
||||
focus: "academic",
|
||||
type: "corporate",
|
||||
subscriptionExpirationDate: req.body.subscriptionExpirationDate || null,
|
||||
status: "paymentDue",
|
||||
registrationDate: new Date().toISOString(),
|
||||
};
|
||||
const user = {
|
||||
...req.body,
|
||||
email: email.toLowerCase(),
|
||||
desiredLevels: DEFAULT_DESIRED_LEVELS,
|
||||
levels: DEFAULT_LEVELS,
|
||||
bio: "",
|
||||
isFirstLogin: false,
|
||||
focus: "academic",
|
||||
type: "corporate",
|
||||
subscriptionExpirationDate: req.body.subscriptionExpirationDate || null,
|
||||
status: "paymentDue",
|
||||
registrationDate: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await setDoc(doc(db, "users", userId), user);
|
||||
await setDoc(doc(db, "users", userId), user);
|
||||
|
||||
req.session.user = {...user, id: userId};
|
||||
await req.session.save();
|
||||
req.session.user = { ...user, id: userId };
|
||||
await req.session.save();
|
||||
|
||||
res.status(200).json({user: {...user, id: userId}});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
res.status(401).json({error});
|
||||
});
|
||||
res.status(200).json({ user: { ...user, id: userId } });
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
res.status(401).json({ error });
|
||||
});
|
||||
}
|
||||
|
||||
54
src/pages/api/sessions/[id].ts
Normal file
54
src/pages/api/sessions/[id].ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type {NextApiRequest, NextApiResponse} from "next";
|
||||
import {app} from "@/firebase";
|
||||
import {getFirestore, doc, getDoc, deleteDoc} from "firebase/firestore";
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {Session} from "@/hooks/useSessions";
|
||||
|
||||
const db = getFirestore(app);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") return get(req, res);
|
||||
if (req.method === "DELETE") return del(req, res);
|
||||
}
|
||||
|
||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
|
||||
const {id} = req.query as {id: string};
|
||||
|
||||
const docRef = doc(db, "sessions", id);
|
||||
const docSnap = await getDoc(docRef);
|
||||
|
||||
if (docSnap.exists()) {
|
||||
res.status(200).json({
|
||||
id: docSnap.id,
|
||||
...docSnap.data(),
|
||||
});
|
||||
} else {
|
||||
res.status(404).json(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function del(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
|
||||
const {id} = req.query as {id: string};
|
||||
|
||||
const docRef = doc(db, "sessions", id);
|
||||
const docSnap = await getDoc(docRef);
|
||||
|
||||
if (!docSnap.exists()) return res.status(404).json({ok: false});
|
||||
|
||||
await deleteDoc(docRef);
|
||||
return res.status(200).json({ok: true});
|
||||
}
|
||||
46
src/pages/api/sessions/index.ts
Normal file
46
src/pages/api/sessions/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type {NextApiRequest, NextApiResponse} from "next";
|
||||
import {app} from "@/firebase";
|
||||
import {getFirestore, collection, getDocs, query, where, doc, setDoc, addDoc, getDoc} from "firebase/firestore";
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
|
||||
const db = getFirestore(app);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") return get(req, res);
|
||||
if (req.method === "POST") return post(req, res);
|
||||
}
|
||||
|
||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
|
||||
const {user} = req.query as {user?: string};
|
||||
|
||||
const q = user ? query(collection(db, "sessions"), where("user", "==", user)) : collection(db, "sessions");
|
||||
const snapshot = await getDocs(q);
|
||||
|
||||
res.status(200).json(
|
||||
snapshot.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
...doc.data(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
|
||||
const session = req.body;
|
||||
await setDoc(doc(db, "sessions", session.id), session, {merge: true});
|
||||
|
||||
res.status(200).json({ok: true});
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type {NextApiRequest, NextApiResponse} from "next";
|
||||
import {app} from "@/firebase";
|
||||
import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc, deleteDoc} from "firebase/firestore";
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {uuidv4} from "@firebase/util";
|
||||
|
||||
const db = getFirestore(app);
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") return GET(req, res);
|
||||
|
||||
res.status(404).json({ok: false});
|
||||
}
|
||||
|
||||
async function GET(req: NextApiRequest, res: NextApiResponse) {
|
||||
const {id} = req.query;
|
||||
|
||||
const snapshot = await getDoc(doc(db, "stats", id as string));
|
||||
|
||||
res.status(200).json({...snapshot.data(), id: snapshot.id});
|
||||
}
|
||||
@@ -1,33 +1,20 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { app, storage } from "@/firebase";
|
||||
import {
|
||||
getFirestore,
|
||||
doc,
|
||||
getDoc,
|
||||
updateDoc,
|
||||
getDocs,
|
||||
query,
|
||||
collection,
|
||||
where,
|
||||
} from "firebase/firestore";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import type {NextApiRequest, NextApiResponse} from "next";
|
||||
import {app, storage} from "@/firebase";
|
||||
import {getFirestore, doc, getDoc, updateDoc, getDocs, query, collection, where} from "firebase/firestore";
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import ReactPDF from "@react-pdf/renderer";
|
||||
import TestReport from "@/exams/pdf/test.report";
|
||||
import { ref, uploadBytes, getDownloadURL } from "firebase/storage";
|
||||
import { DemographicInformation, User } from "@/interfaces/user";
|
||||
import { Module } from "@/interfaces";
|
||||
import { ModuleScore } from "@/interfaces/module.scores";
|
||||
import { SkillExamDetails } from "@/exams/pdf/details/skill.exam";
|
||||
import { LevelExamDetails } from "@/exams/pdf/details/level.exam";
|
||||
import { calculateBandScore } from "@/utils/score";
|
||||
import {ref, uploadBytes, getDownloadURL} from "firebase/storage";
|
||||
import {DemographicInformation, User} from "@/interfaces/user";
|
||||
import {Module} from "@/interfaces";
|
||||
import {ModuleScore} from "@/interfaces/module.scores";
|
||||
import {SkillExamDetails} from "@/exams/pdf/details/skill.exam";
|
||||
import {LevelExamDetails} from "@/exams/pdf/details/level.exam";
|
||||
import {calculateBandScore} from "@/utils/score";
|
||||
import axios from "axios";
|
||||
import { moduleLabels } from "@/utils/moduleUtils";
|
||||
import {
|
||||
generateQRCode,
|
||||
getRadialProgressPNG,
|
||||
streamToBuffer,
|
||||
} from "@/utils/pdf";
|
||||
import {moduleLabels} from "@/utils/moduleUtils";
|
||||
import {generateQRCode, getRadialProgressPNG, streamToBuffer} from "@/utils/pdf";
|
||||
import moment from "moment-timezone";
|
||||
|
||||
const db = getFirestore(app);
|
||||
@@ -35,350 +22,330 @@ const db = getFirestore(app);
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") return get(req, res);
|
||||
if (req.method === "POST") return post(req, res);
|
||||
if (req.method === "GET") return get(req, res);
|
||||
if (req.method === "POST") return post(req, res);
|
||||
}
|
||||
|
||||
const getExamSummary = (score: number) => {
|
||||
if (score > 0.8) {
|
||||
return "Scoring between 81% and 100% on the English exam demonstrates an outstanding level of proficiency in writing, speaking, listening, and reading. Mastery of key concepts is evident across all language domains, showcasing not only a high level of skill but also a dedication to excellence. Continuing to challenge oneself with advanced material in writing, speaking, listening, and reading will further refine the already impressive command of the English language.";
|
||||
}
|
||||
if (score > 0.8) {
|
||||
return "Scoring between 81% and 100% on the English exam demonstrates an outstanding level of proficiency in writing, speaking, listening, and reading. Mastery of key concepts is evident across all language domains, showcasing not only a high level of skill but also a dedication to excellence. Continuing to challenge oneself with advanced material in writing, speaking, listening, and reading will further refine the already impressive command of the English language.";
|
||||
}
|
||||
|
||||
if (score > 0.6) {
|
||||
return "Scoring between 61% and 80% on the English exam, encompassing writing, speaking, listening, and reading, reflects a commendable level of proficiency in each domain. There's evidence of a solid grasp of key concepts, and effective application of skills. Room for refinement and deeper exploration in writing, speaking, listening, and reading remains, presenting an opportunity for further mastery.";
|
||||
}
|
||||
if (score > 0.6) {
|
||||
return "Scoring between 61% and 80% on the English exam, encompassing writing, speaking, listening, and reading, reflects a commendable level of proficiency in each domain. There's evidence of a solid grasp of key concepts, and effective application of skills. Room for refinement and deeper exploration in writing, speaking, listening, and reading remains, presenting an opportunity for further mastery.";
|
||||
}
|
||||
|
||||
if (score > 0.4) {
|
||||
return "Scoring between 41% and 60% on the English exam across writing, speaking, listening, and reading demonstrates a moderate level of understanding in each domain. While there's a commendable grasp of key concepts, refining fundamental skills in writing, speaking, listening, and reading can lead to notable improvement. Consistent effort and targeted focus on weaker areas are recommended.";
|
||||
}
|
||||
if (score > 0.4) {
|
||||
return "Scoring between 41% and 60% on the English exam across writing, speaking, listening, and reading demonstrates a moderate level of understanding in each domain. While there's a commendable grasp of key concepts, refining fundamental skills in writing, speaking, listening, and reading can lead to notable improvement. Consistent effort and targeted focus on weaker areas are recommended.";
|
||||
}
|
||||
|
||||
if (score > 0.2) {
|
||||
return "Scoring between 21% and 40% on the English exam, spanning writing, speaking, listening, and reading, indicates some understanding of key concepts in each domain. However, there's room for improvement in fundamental skills. Strengthening writing, speaking, listening, and reading abilities through consistent effort and focused study will contribute to overall proficiency.";
|
||||
}
|
||||
if (score > 0.2) {
|
||||
return "Scoring between 21% and 40% on the English exam, spanning writing, speaking, listening, and reading, indicates some understanding of key concepts in each domain. However, there's room for improvement in fundamental skills. Strengthening writing, speaking, listening, and reading abilities through consistent effort and focused study will contribute to overall proficiency.";
|
||||
}
|
||||
|
||||
return "This student's performance on the English exam, encompassing writing, speaking, listening, and reading, reflects a significant need for improvement, scoring between 0% and 20%. There's a notable gap in understanding key concepts across all language domains. Strengthening fundamental skills in writing, speaking, listening, and reading is crucial. Developing a consistent study routine and seeking additional support in each area can contribute to substantial progress.";
|
||||
return "This student's performance on the English exam, encompassing writing, speaking, listening, and reading, reflects a significant need for improvement, scoring between 0% and 20%. There's a notable gap in understanding key concepts across all language domains. Strengthening fundamental skills in writing, speaking, listening, and reading is crucial. Developing a consistent study routine and seeking additional support in each area can contribute to substantial progress.";
|
||||
};
|
||||
|
||||
const getLevelSummary = (score: number) => {
|
||||
if (score > 0.8) {
|
||||
return "Scoring between 81% and 100% on the English exam showcases an outstanding level of understanding and proficiency. Your performance reflects a mastery of key concepts, including grammar, vocabulary, and comprehension. You exhibit a high level of skill in applying these elements effectively. Your dedication to excellence is evident, and your consistent, stellar performance is commendable. Continue to challenge yourself with advanced material to further refine your already impressive command of the English language. Your commitment to excellence positions you as a standout student in English studies, and your achievements are a testament to your hard work and capability.";
|
||||
}
|
||||
if (score > 0.8) {
|
||||
return "Scoring between 81% and 100% on the English exam showcases an outstanding level of understanding and proficiency. Your performance reflects a mastery of key concepts, including grammar, vocabulary, and comprehension. You exhibit a high level of skill in applying these elements effectively. Your dedication to excellence is evident, and your consistent, stellar performance is commendable. Continue to challenge yourself with advanced material to further refine your already impressive command of the English language. Your commitment to excellence positions you as a standout student in English studies, and your achievements are a testament to your hard work and capability.";
|
||||
}
|
||||
|
||||
if (score > 0.6) {
|
||||
return "Scoring between 61% and 80% on the English exam reflects a commendable level of understanding and proficiency. You have demonstrated a solid grasp of key concepts, including grammar, vocabulary, and comprehension. There's evidence of effective application of skills, but room for refinement and deeper exploration remains. Consistent effort in honing nuanced aspects of language will contribute to even greater mastery. Continue engaging with challenging material and seeking opportunities for advanced comprehension. With sustained dedication, you have the potential to elevate your performance to an exceptional level and further excel in your English studies.";
|
||||
}
|
||||
if (score > 0.6) {
|
||||
return "Scoring between 61% and 80% on the English exam reflects a commendable level of understanding and proficiency. You have demonstrated a solid grasp of key concepts, including grammar, vocabulary, and comprehension. There's evidence of effective application of skills, but room for refinement and deeper exploration remains. Consistent effort in honing nuanced aspects of language will contribute to even greater mastery. Continue engaging with challenging material and seeking opportunities for advanced comprehension. With sustained dedication, you have the potential to elevate your performance to an exceptional level and further excel in your English studies.";
|
||||
}
|
||||
|
||||
if (score > 0.4) {
|
||||
return "Scoring between 41% and 60% on the English exam reflects a moderate level of understanding. You demonstrate a grasp of some key concepts, but there's room for refinement in areas like grammar, vocabulary, and comprehension. Consistent effort and a strategic focus on weaker areas can lead to notable improvement. Engaging with supplementary resources and seeking feedback will further enhance your skills. With continued dedication, there's a solid foundation to build upon, and achieving a higher level of proficiency is within reach. Keep up the good work and aim for sustained progress in your English studies.";
|
||||
}
|
||||
if (score > 0.4) {
|
||||
return "Scoring between 41% and 60% on the English exam reflects a moderate level of understanding. You demonstrate a grasp of some key concepts, but there's room for refinement in areas like grammar, vocabulary, and comprehension. Consistent effort and a strategic focus on weaker areas can lead to notable improvement. Engaging with supplementary resources and seeking feedback will further enhance your skills. With continued dedication, there's a solid foundation to build upon, and achieving a higher level of proficiency is within reach. Keep up the good work and aim for sustained progress in your English studies.";
|
||||
}
|
||||
|
||||
if (score > 0.2) {
|
||||
return "Scoring between 21% and 40% on the English exam shows some understanding of key concepts, but there's still ample room for improvement. Strengthening foundational skills, such as grammar, vocabulary, and comprehension, is essential. Consistent effort and focused study can help bridge gaps in knowledge and elevate your performance. Consider seeking additional guidance or resources to refine your understanding of the material. With commitment and targeted improvements, you have the potential to make significant strides in your English proficiency.";
|
||||
}
|
||||
if (score > 0.2) {
|
||||
return "Scoring between 21% and 40% on the English exam shows some understanding of key concepts, but there's still ample room for improvement. Strengthening foundational skills, such as grammar, vocabulary, and comprehension, is essential. Consistent effort and focused study can help bridge gaps in knowledge and elevate your performance. Consider seeking additional guidance or resources to refine your understanding of the material. With commitment and targeted improvements, you have the potential to make significant strides in your English proficiency.";
|
||||
}
|
||||
|
||||
return "Your performance on the English exam falls within the 0% to 20% range, indicating a need for improvement. There's room to enhance your grasp of fundamental concepts like grammar, vocabulary, and comprehension. Establishing a consistent study routine and seeking extra support can be beneficial. With dedication and targeted efforts, you have the potential to significantly boost your performance in upcoming assessments.";
|
||||
return "Your performance on the English exam falls within the 0% to 20% range, indicating a need for improvement. There's room to enhance your grasp of fundamental concepts like grammar, vocabulary, and comprehension. Establishing a consistent study routine and seeking extra support can be beneficial. With dedication and targeted efforts, you have the potential to significantly boost your performance in upcoming assessments.";
|
||||
};
|
||||
|
||||
const getPerformanceSummary = (module: Module, score: number) => {
|
||||
if (module === "level") return getLevelSummary(score);
|
||||
return getExamSummary(score);
|
||||
if (module === "level") return getLevelSummary(score);
|
||||
return getExamSummary(score);
|
||||
};
|
||||
interface SkillsFeedbackRequest {
|
||||
code: Module;
|
||||
name: string;
|
||||
grade: number;
|
||||
code: Module;
|
||||
name: string;
|
||||
grade: number;
|
||||
}
|
||||
|
||||
interface SkillsFeedbackResponse extends SkillsFeedbackRequest {
|
||||
evaluation: string;
|
||||
suggestions: string;
|
||||
evaluation: string;
|
||||
suggestions: string;
|
||||
}
|
||||
|
||||
const getSkillsFeedback = async (sections: SkillsFeedbackRequest[]) => {
|
||||
const backendRequest = await axios.post(
|
||||
`${process.env.BACKEND_URL}/grading_summary`,
|
||||
{ sections },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
const backendRequest = await axios.post(
|
||||
`${process.env.BACKEND_URL}/grading_summary`,
|
||||
{sections},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return backendRequest.data?.sections;
|
||||
return backendRequest.data?.sections;
|
||||
};
|
||||
|
||||
// perform the request with several retries if needed
|
||||
const handleSkillsFeedbackRequest = async (
|
||||
sections: SkillsFeedbackRequest[]
|
||||
): Promise<SkillsFeedbackResponse[] | null> => {
|
||||
let i = 0;
|
||||
try {
|
||||
const data = await getSkillsFeedback(sections);
|
||||
return data;
|
||||
} catch (err) {
|
||||
if (i < 3) {
|
||||
i++;
|
||||
return handleSkillsFeedbackRequest(sections);
|
||||
}
|
||||
const handleSkillsFeedbackRequest = async (sections: SkillsFeedbackRequest[]): Promise<SkillsFeedbackResponse[] | null> => {
|
||||
let i = 0;
|
||||
try {
|
||||
const data = await getSkillsFeedback(sections);
|
||||
return data;
|
||||
} catch (err) {
|
||||
if (i < 3) {
|
||||
i++;
|
||||
return handleSkillsFeedbackRequest(sections);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
// verify if it's a logged user that is trying to export
|
||||
if (req.session.user) {
|
||||
const { id } = req.query as { id: string };
|
||||
// fetch stats entries for this particular user with the requested exam session
|
||||
const docsSnap = await getDocs(
|
||||
query(
|
||||
collection(db, "stats"),
|
||||
where("session", "==", id),
|
||||
where("user", "==", req.session.user.id)
|
||||
)
|
||||
);
|
||||
// verify if it's a logged user that is trying to export
|
||||
if (req.session.user) {
|
||||
const {id} = req.query as {id: string};
|
||||
// fetch stats entries for this particular user with the requested exam session
|
||||
const docsSnap = await getDocs(query(collection(db, "stats"), where("session", "==", id)));
|
||||
|
||||
if (docsSnap.empty) {
|
||||
res.status(400).end();
|
||||
return;
|
||||
}
|
||||
if (docsSnap.empty) {
|
||||
res.status(400).end();
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = docsSnap.docs.map((d) => d.data());
|
||||
// verify if the stats already have a pdf generated
|
||||
const hasPDF = stats.find((s) => s.pdf);
|
||||
const stats = docsSnap.docs.map((d) => d.data());
|
||||
// verify if the stats already have a pdf generated
|
||||
const hasPDF = stats.find((s) => s.pdf?.path && s.pdf?.version === process.env.PDF_VERSION);
|
||||
// find the user that generated the stats
|
||||
const statIndex = stats.findIndex((s) => s.user);
|
||||
|
||||
if (hasPDF) {
|
||||
// if it does, return the pdf url
|
||||
const fileRef = ref(storage, hasPDF.pdf);
|
||||
const url = await getDownloadURL(fileRef);
|
||||
if(statIndex === -1) {
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
const userId = stats[statIndex].user;
|
||||
|
||||
|
||||
res.status(200).end(url);
|
||||
return;
|
||||
}
|
||||
if (hasPDF) {
|
||||
// if it does, return the pdf url
|
||||
const fileRef = ref(storage, hasPDF.pdf.path);
|
||||
const url = await getDownloadURL(fileRef);
|
||||
|
||||
try {
|
||||
// generate the pdf report
|
||||
const docUser = await getDoc(doc(db, "users", req.session.user.id));
|
||||
res.status(200).end(url);
|
||||
return;
|
||||
}
|
||||
|
||||
if (docUser.exists()) {
|
||||
// we'll need the user in order to get the user data (name, email, focus, etc);
|
||||
const user = docUser.data() as User;
|
||||
try {
|
||||
// generate the pdf report
|
||||
const docUser = await getDoc(doc(db, "users", userId));
|
||||
|
||||
// generate the QR code for the report
|
||||
const qrcode = await generateQRCode(
|
||||
(req.headers.origin || "") + req.url
|
||||
);
|
||||
if (docUser.exists()) {
|
||||
// we'll need the user in order to get the user data (name, email, focus, etc);
|
||||
const user = docUser.data() as User;
|
||||
|
||||
if (!qrcode) {
|
||||
res.status(500).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
// generate the QR code for the report
|
||||
const qrcode = await generateQRCode((req.headers.origin || "") + req.url);
|
||||
|
||||
// stats may contain multiple exams of the same type so we need to aggregate them
|
||||
const results = (
|
||||
stats.reduce((accm: ModuleScore[], { module, score }) => {
|
||||
const fixedModuleStr =
|
||||
module[0].toUpperCase() + module.substring(1);
|
||||
if (accm.find((e: ModuleScore) => e.module === fixedModuleStr)) {
|
||||
return accm.map((e: ModuleScore) => {
|
||||
if (e.module === fixedModuleStr) {
|
||||
return {
|
||||
...e,
|
||||
score: e.score + score.correct,
|
||||
total: e.total + score.total,
|
||||
};
|
||||
}
|
||||
if (!qrcode) {
|
||||
res.status(500).json({ok: false});
|
||||
return;
|
||||
}
|
||||
|
||||
return e;
|
||||
});
|
||||
}
|
||||
// stats may contain multiple exams of the same type so we need to aggregate them
|
||||
const results = (
|
||||
stats.reduce((accm: ModuleScore[], {module, score}) => {
|
||||
const fixedModuleStr = module[0].toUpperCase() + module.substring(1);
|
||||
if (accm.find((e: ModuleScore) => e.module === fixedModuleStr)) {
|
||||
return accm.map((e: ModuleScore) => {
|
||||
if (e.module === fixedModuleStr) {
|
||||
return {
|
||||
...e,
|
||||
score: e.score + score.correct,
|
||||
total: e.total + score.total,
|
||||
};
|
||||
}
|
||||
|
||||
return [
|
||||
...accm,
|
||||
{
|
||||
module: fixedModuleStr,
|
||||
score: score.correct,
|
||||
total: score.total,
|
||||
code: module,
|
||||
},
|
||||
];
|
||||
}, []) as ModuleScore[]
|
||||
).map((moduleScore) => {
|
||||
const { score, total } = moduleScore;
|
||||
// with all the scores aggreated we can calculate the band score for each module
|
||||
const bandScore = calculateBandScore(
|
||||
score,
|
||||
total,
|
||||
moduleScore.code as Module,
|
||||
user.focus
|
||||
);
|
||||
return e;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...moduleScore,
|
||||
// generate the closest radial progress png for the score
|
||||
png: getRadialProgressPNG("azul", score, total),
|
||||
bandScore,
|
||||
};
|
||||
});
|
||||
return [
|
||||
...accm,
|
||||
{
|
||||
module: fixedModuleStr,
|
||||
score: score.correct,
|
||||
total: score.total,
|
||||
code: module,
|
||||
},
|
||||
];
|
||||
}, []) as ModuleScore[]
|
||||
).map((moduleScore) => {
|
||||
const {score, total} = moduleScore;
|
||||
// with all the scores aggreated we can calculate the band score for each module
|
||||
const bandScore = calculateBandScore(score, total, moduleScore.code as Module, user.focus);
|
||||
|
||||
// get the skills feedback from the backend based on the module grade
|
||||
const skillsFeedback = (await handleSkillsFeedbackRequest(
|
||||
results.map(({ code, bandScore }) => ({
|
||||
code,
|
||||
name: moduleLabels[code],
|
||||
grade: bandScore,
|
||||
}))
|
||||
)) as SkillsFeedbackResponse[];
|
||||
return {
|
||||
...moduleScore,
|
||||
// generate the closest radial progress png for the score
|
||||
png: getRadialProgressPNG("azul", score, total),
|
||||
bandScore,
|
||||
};
|
||||
});
|
||||
|
||||
if (!skillsFeedback) {
|
||||
res.status(500).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
// get the skills feedback from the backend based on the module grade
|
||||
const skillsFeedback = (await handleSkillsFeedbackRequest(
|
||||
results.map(({code, bandScore}) => ({
|
||||
code,
|
||||
name: moduleLabels[code],
|
||||
grade: bandScore,
|
||||
})),
|
||||
)) as SkillsFeedbackResponse[];
|
||||
|
||||
// assign the feedback to the results
|
||||
const finalResults = results.map((result) => {
|
||||
const feedback = skillsFeedback.find(
|
||||
(f: SkillsFeedbackResponse) => f.code === result.code
|
||||
);
|
||||
if (!skillsFeedback) {
|
||||
res.status(500).json({ok: false});
|
||||
return;
|
||||
}
|
||||
|
||||
if (feedback) {
|
||||
return {
|
||||
...result,
|
||||
evaluation: feedback?.evaluation,
|
||||
suggestions: feedback?.suggestions,
|
||||
};
|
||||
}
|
||||
// assign the feedback to the results
|
||||
const finalResults = results.map((result) => {
|
||||
const feedback = skillsFeedback.find((f: SkillsFeedbackResponse) => f.code === result.code);
|
||||
|
||||
return result;
|
||||
});
|
||||
if (feedback) {
|
||||
return {
|
||||
...result,
|
||||
evaluation: feedback?.evaluation,
|
||||
suggestions: feedback?.suggestions,
|
||||
};
|
||||
}
|
||||
|
||||
// calculate the overall score out of all the aggregated results
|
||||
const overallScore = results.reduce(
|
||||
(accm, { score }) => accm + score,
|
||||
0
|
||||
);
|
||||
const overallTotal = results.reduce(
|
||||
(accm, { total }) => accm + total,
|
||||
0
|
||||
);
|
||||
const overallResult = overallScore / overallTotal;
|
||||
return result;
|
||||
});
|
||||
|
||||
const overallPNG = getRadialProgressPNG("laranja", overallScore, overallTotal);
|
||||
// calculate the overall score out of all the aggregated results
|
||||
const overallScore = results.reduce((accm, {score}) => accm + score, 0);
|
||||
const overallTotal = results.reduce((accm, {total}) => accm + total, 0);
|
||||
const overallResult = overallScore / overallTotal;
|
||||
|
||||
// generate the overall detail report
|
||||
const overallDetail = {
|
||||
module: "Overall",
|
||||
score: overallScore,
|
||||
total: overallTotal,
|
||||
png: overallPNG,
|
||||
} as ModuleScore;
|
||||
const testDetails = [overallDetail, ...finalResults];
|
||||
const overallPNG = getRadialProgressPNG("laranja", overallScore, overallTotal);
|
||||
|
||||
const [stat] = stats;
|
||||
// generate the overall detail report
|
||||
const overallDetail = {
|
||||
module: "Overall",
|
||||
score: overallScore,
|
||||
total: overallTotal,
|
||||
png: overallPNG,
|
||||
} as ModuleScore;
|
||||
const testDetails = [overallDetail, ...finalResults];
|
||||
|
||||
// generate the performance summary based on the overall result
|
||||
const performanceSummary = getPerformanceSummary(
|
||||
stat.module,
|
||||
overallResult
|
||||
);
|
||||
const [stat] = stats;
|
||||
|
||||
// level exams have a different report structure than the skill exams
|
||||
const getCustomData = () => {
|
||||
if (stat.module === "level") {
|
||||
return {
|
||||
title: "ENGLISH LEVEL TEST RESULT REPORT ",
|
||||
details: (
|
||||
<LevelExamDetails
|
||||
detail={overallDetail}
|
||||
title="Level as per CEFR Levels"
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
// generate the performance summary based on the overall result
|
||||
const performanceSummary = getPerformanceSummary(stat.module, overallResult);
|
||||
|
||||
return {
|
||||
title: "ENGLISH SKILLS TEST RESULT REPORT",
|
||||
details: <SkillExamDetails testDetails={testDetails} />,
|
||||
};
|
||||
};
|
||||
// level exams have a different report structure than the skill exams
|
||||
const getCustomData = () => {
|
||||
if (stat.module === "level") {
|
||||
return {
|
||||
title: "ENGLISH LEVEL TEST RESULT REPORT ",
|
||||
details: <LevelExamDetails detail={overallDetail} title="Level as per CEFR Levels" />,
|
||||
};
|
||||
}
|
||||
|
||||
const { title, details } = getCustomData();
|
||||
|
||||
const demographicInformation = user.demographicInformation as DemographicInformation;
|
||||
const pdfStream = await ReactPDF.renderToStream(
|
||||
<TestReport
|
||||
title={title}
|
||||
date={moment(stat.date).tz(user.demographicInformation?.timezone || 'UTC').format('ll HH:mm:ss')}
|
||||
name={user.name}
|
||||
email={user.email}
|
||||
id={user.id}
|
||||
gender={demographicInformation?.gender}
|
||||
summary={performanceSummary}
|
||||
testDetails={testDetails}
|
||||
renderDetails={details}
|
||||
logo={"public/logo_title.png"}
|
||||
qrcode={qrcode}
|
||||
summaryPNG={overallPNG}
|
||||
summaryScore={`${(overallResult * 100).toFixed(0)}%`}
|
||||
passportId={demographicInformation?.passport_id || ""}
|
||||
/>
|
||||
);
|
||||
return {
|
||||
title: "ENGLISH SKILLS TEST RESULT REPORT",
|
||||
details: <SkillExamDetails testDetails={testDetails} />,
|
||||
};
|
||||
};
|
||||
|
||||
// generate the file ref for storage
|
||||
const fileName = `${Date.now().toString()}.pdf`;
|
||||
const refName = `exam_report/${fileName}`;
|
||||
const fileRef = ref(storage, refName);
|
||||
const {title, details} = getCustomData();
|
||||
|
||||
// upload the pdf to storage
|
||||
const pdfBuffer = await streamToBuffer(pdfStream);
|
||||
const snapshot = await uploadBytes(fileRef, pdfBuffer, {
|
||||
contentType: "application/pdf",
|
||||
});
|
||||
const demographicInformation = user.demographicInformation as DemographicInformation;
|
||||
const pdfStream = await ReactPDF.renderToStream(
|
||||
<TestReport
|
||||
title={title}
|
||||
date={moment(stat.date)
|
||||
.tz(user.demographicInformation?.timezone || "UTC")
|
||||
.format("ll HH:mm:ss")}
|
||||
name={user.name}
|
||||
email={user.email}
|
||||
id={userId}
|
||||
gender={demographicInformation?.gender}
|
||||
summary={performanceSummary}
|
||||
testDetails={testDetails}
|
||||
renderDetails={details}
|
||||
logo={"public/logo_title.png"}
|
||||
qrcode={qrcode}
|
||||
summaryPNG={overallPNG}
|
||||
summaryScore={`${(overallResult * 100).toFixed(0)}%`}
|
||||
passportId={demographicInformation?.passport_id || ""}
|
||||
/>,
|
||||
);
|
||||
|
||||
// update the stats entries with the pdf url to prevent duplication
|
||||
docsSnap.docs.forEach(async (doc) => {
|
||||
await updateDoc(doc.ref, {
|
||||
pdf: refName,
|
||||
});
|
||||
});
|
||||
const url = await getDownloadURL(fileRef);
|
||||
res.status(200).end(url);
|
||||
return;
|
||||
}
|
||||
// generate the file ref for storage
|
||||
const fileName = `${Date.now().toString()}.pdf`;
|
||||
const refName = `exam_report/${fileName}`;
|
||||
const fileRef = ref(storage, refName);
|
||||
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// upload the pdf to storage
|
||||
const pdfBuffer = await streamToBuffer(pdfStream);
|
||||
const snapshot = await uploadBytes(fileRef, pdfBuffer, {
|
||||
contentType: "application/pdf",
|
||||
});
|
||||
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
// update the stats entries with the pdf url to prevent duplication
|
||||
docsSnap.docs.forEach(async (doc) => {
|
||||
await updateDoc(doc.ref, {
|
||||
pdf: {
|
||||
path: refName,
|
||||
version: process.env.PDF_VERSION,
|
||||
},
|
||||
});
|
||||
});
|
||||
const url = await getDownloadURL(fileRef);
|
||||
res.status(200).end(url);
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
} catch (err) {
|
||||
res.status(500).json({ok: false});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
|
||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { id } = req.query as { id: string };
|
||||
const docsSnap = await getDocs(
|
||||
query(collection(db, "stats"), where("session", "==", id))
|
||||
);
|
||||
const {id} = req.query as {id: string};
|
||||
const docsSnap = await getDocs(query(collection(db, "stats"), where("session", "==", id)));
|
||||
|
||||
if (docsSnap.empty) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
if (docsSnap.empty) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = docsSnap.docs.map((d) => d.data());
|
||||
const stats = docsSnap.docs.map((d) => d.data());
|
||||
|
||||
const hasPDF = stats.find((s) => s.pdf);
|
||||
const hasPDF = stats.find((s) => s.pdf?.path);
|
||||
|
||||
if (hasPDF) {
|
||||
const fileRef = ref(storage, hasPDF.pdf);
|
||||
const url = await getDownloadURL(fileRef);
|
||||
return res.redirect(url);
|
||||
}
|
||||
if (hasPDF) {
|
||||
const fileRef = ref(storage, hasPDF.pdf.path);
|
||||
const url = await getDownloadURL(fileRef);
|
||||
return res.redirect(url);
|
||||
}
|
||||
|
||||
res.status(500).end();
|
||||
res.status(500).end();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type {NextApiRequest, NextApiResponse} from "next";
|
||||
import {app} from "@/firebase";
|
||||
import {getFirestore, collection, getDocs, query, where, doc, setDoc, addDoc, getDoc} from "firebase/firestore";
|
||||
import {getFirestore, collection, getDocs, query, where, doc, setDoc, addDoc, getDoc, deleteDoc} from "firebase/firestore";
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {Stat} from "@/interfaces/user";
|
||||
@@ -43,6 +43,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
const stats = req.body as Stat[];
|
||||
await stats.forEach(async (stat) => await setDoc(doc(db, "stats", stat.id), stat));
|
||||
await stats.forEach(async (stat) => {
|
||||
const sessionDoc = await getDoc(doc(db, "sessions", stat.session));
|
||||
if (sessionDoc.exists()) await deleteDoc(sessionDoc.ref);
|
||||
});
|
||||
|
||||
const groupedStatsByAssignment = groupBy(
|
||||
stats.filter((x) => !!x.assignment),
|
||||
|
||||
28
src/pages/api/storage/delete.ts
Normal file
28
src/pages/api/storage/delete.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type {NextApiRequest, NextApiResponse} from "next";
|
||||
import {app} from "@/firebase";
|
||||
import {getFirestore, doc, getDoc, deleteDoc} from "firebase/firestore";
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {Session} from "@/hooks/useSessions";
|
||||
import {deleteObject, getStorage, ref} from "firebase/storage";
|
||||
|
||||
const storage = getStorage(app);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "POST") return post(req, res);
|
||||
}
|
||||
|
||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
|
||||
const {path} = req.body as {path: string};
|
||||
await deleteObject(ref(storage, path));
|
||||
|
||||
return res.status(200).json({ok: true});
|
||||
}
|
||||
45
src/pages/api/storage/insert.ts
Normal file
45
src/pages/api/storage/insert.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type {NextApiRequest, NextApiResponse} from "next";
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import axios, {AxiosResponse} from "axios";
|
||||
import formidable from "formidable-serverless";
|
||||
import {getDownloadURL, ref, uploadBytes} from "firebase/storage";
|
||||
import fs from "fs";
|
||||
import {app, storage} from "@/firebase";
|
||||
import {doc, getDoc, getFirestore, setDoc} from "firebase/firestore";
|
||||
import {Stat} from "@/interfaces/user";
|
||||
import {speakingReverseMarking} from "@/utils/score";
|
||||
|
||||
const db = getFirestore(app);
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
|
||||
const form = formidable({keepExtensions: true});
|
||||
await form.parse(req, async (err: any, fields: any, files: any) => {
|
||||
if (err) {
|
||||
console.log(err);
|
||||
return res.status(500).json({ok: false});
|
||||
}
|
||||
|
||||
const audioFile = files.audio;
|
||||
const audioFileRef = ref(storage, `${fields.root}/${(audioFile as any).path.split("/").pop()!.replace("upload_", "")}`);
|
||||
|
||||
const binary = fs.readFileSync((audioFile as any).path).buffer;
|
||||
const snapshot = await uploadBytes(audioFileRef, binary);
|
||||
|
||||
const path = await getDownloadURL(snapshot.ref);
|
||||
res.status(200).json({path});
|
||||
});
|
||||
}
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
81
src/pages/api/tickets/[id].ts
Normal file
81
src/pages/api/tickets/[id].ts
Normal file
@@ -0,0 +1,81 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { app } from "@/firebase";
|
||||
import {
|
||||
getFirestore,
|
||||
getDoc,
|
||||
doc,
|
||||
deleteDoc,
|
||||
setDoc,
|
||||
} from "firebase/firestore";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { Ticket } from "@/interfaces/ticket";
|
||||
|
||||
const db = getFirestore(app);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") return await get(req, res);
|
||||
if (req.method === "DELETE") return await del(req, res);
|
||||
if (req.method === "PATCH") return await patch(req, res);
|
||||
|
||||
res.status(404).json(undefined);
|
||||
}
|
||||
|
||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.query as { id: string };
|
||||
|
||||
const snapshot = await getDoc(doc(db, "tickets", id));
|
||||
|
||||
if (snapshot.exists()) {
|
||||
res.status(200).json({ ...snapshot.data(), id: snapshot.id });
|
||||
} else {
|
||||
res.status(404).json(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function del(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.query as { id: string };
|
||||
|
||||
const snapshot = await getDoc(doc(db, "tickets", id));
|
||||
const data = snapshot.data() as Ticket;
|
||||
|
||||
const user = req.session.user;
|
||||
if (user.type === "admin" || user.type === "developer") {
|
||||
await deleteDoc(snapshot.ref);
|
||||
res.status(200).json({ ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(403).json({ ok: false });
|
||||
}
|
||||
|
||||
async function patch(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.query as { id: string };
|
||||
const snapshot = await getDoc(doc(db, "tickets", id));
|
||||
|
||||
const user = req.session.user;
|
||||
if (user.type === "admin" || user.type === "developer") {
|
||||
await setDoc(snapshot.ref, req.body, { merge: true });
|
||||
return res.status(200).json({ ok: true });
|
||||
}
|
||||
|
||||
res.status(403).json({ ok: false });
|
||||
}
|
||||
69
src/pages/api/tickets/index.ts
Normal file
69
src/pages/api/tickets/index.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import { sendEmail } from "@/email";
|
||||
import { app } from "@/firebase";
|
||||
import { Ticket, TicketTypeLabel } from "@/interfaces/ticket";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import {
|
||||
collection,
|
||||
doc,
|
||||
getDocs,
|
||||
getFirestore,
|
||||
setDoc,
|
||||
} from "firebase/firestore";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import moment from "moment";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import ShortUniqueId from "short-unique-id";
|
||||
|
||||
const db = getFirestore(app);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET") await get(req, res);
|
||||
if (req.method === "POST") await post(req, res);
|
||||
}
|
||||
|
||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
const snapshot = await getDocs(collection(db, "tickets"));
|
||||
|
||||
res.status(200).json(
|
||||
snapshot.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
...doc.data(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
const body = req.body as Ticket;
|
||||
|
||||
const shortUID = new ShortUniqueId();
|
||||
const id = body.id || shortUID.randomUUID(8);
|
||||
await setDoc(doc(db, "tickets", id), body);
|
||||
res.status(200).json({ ok: true });
|
||||
|
||||
try {
|
||||
await sendEmail(
|
||||
"submittedFeedback",
|
||||
{
|
||||
id,
|
||||
subject: body.subject,
|
||||
reporter: body.reporter,
|
||||
date: moment(body.date).format("DD/MM/YYYY - HH:mm"),
|
||||
type: TicketTypeLabel[body.type],
|
||||
reportedFrom: body.reportedFrom,
|
||||
description: body.description,
|
||||
},
|
||||
[body.reporter.email],
|
||||
`Ticket ${id}: ${body.subject}`,
|
||||
);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||
import {app, adminApp} from "@/firebase";
|
||||
import {User} from "@/interfaces/user";
|
||||
import {Group, User} from "@/interfaces/user";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {collection, deleteDoc, doc, getDoc, getDocs, getFirestore, query, setDoc, where} from "firebase/firestore";
|
||||
import {getAuth} from "firebase-admin/auth";
|
||||
@@ -43,6 +43,19 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
const targetUser = {...docTargetUser.data(), id: docTargetUser.id} as User;
|
||||
|
||||
if (user.type === "corporate" && (targetUser.type === "student" || targetUser.type === "teacher")) {
|
||||
res.json({ok: true});
|
||||
|
||||
const userParticipantGroup = await getDocs(query(collection(db, "groups"), where("participants", "array-contains", id)));
|
||||
await Promise.all([
|
||||
...userParticipantGroup.docs
|
||||
.filter((x) => (x.data() as Group).admin === user.id)
|
||||
.map(async (x) => await setDoc(x.ref, {participants: x.data().participants.filter((y: string) => y !== id)}, {merge: true})),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const permission = PERMISSIONS.deleteUser[targetUser.type];
|
||||
if (!permission.includes(user.type)) {
|
||||
res.status(403).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