Compare commits
107 Commits
feature/99
...
feature/sp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03f78ceb46 | ||
|
|
872cc62fe4 | ||
|
|
ce7032c8a7 | ||
|
|
71f07af2eb | ||
|
|
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 {EmploymentStatus, EMPLOYMENT_STATUS, Gender, User} from "@/interfaces/user";
|
||||||
import {FormEvent, useState} from "react";
|
import {FormEvent, useEffect, useState} from "react";
|
||||||
import countryCodes from "country-codes-list";
|
import countryCodes from "country-codes-list";
|
||||||
import {RadioGroup} from "@headlessui/react";
|
import {RadioGroup} from "@headlessui/react";
|
||||||
import Input from "./Low/Input";
|
import Input from "./Low/Input";
|
||||||
@@ -12,6 +12,8 @@ import {KeyedMutator} from "swr";
|
|||||||
import CountrySelect from "./Low/CountrySelect";
|
import CountrySelect from "./Low/CountrySelect";
|
||||||
import GenderInput from "@/components/High/GenderInput";
|
import GenderInput from "@/components/High/GenderInput";
|
||||||
import EmploymentStatusInput from "@/components/High/EmploymentStatusInput";
|
import EmploymentStatusInput from "@/components/High/EmploymentStatusInput";
|
||||||
|
import TimezoneSelect from "./Low/TImezoneSelect";
|
||||||
|
import moment from "moment";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -25,6 +27,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
|
|||||||
const [gender, setGender] = useState<Gender>();
|
const [gender, setGender] = useState<Gender>();
|
||||||
const [employment, setEmployment] = useState<EmploymentStatus>();
|
const [employment, setEmployment] = useState<EmploymentStatus>();
|
||||||
const [position, setPosition] = useState<string>();
|
const [position, setPosition] = useState<string>();
|
||||||
|
const [timezone, setTimezone] = useState<string>(moment.tz.guess());
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const [companyName, setCompanyName] = useState<string>();
|
const [companyName, setCompanyName] = useState<string>();
|
||||||
@@ -43,6 +46,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
|
|||||||
employment: user.type === "corporate" ? undefined : employment,
|
employment: user.type === "corporate" ? undefined : employment,
|
||||||
position: user.type === "corporate" ? position : undefined,
|
position: user.type === "corporate" ? position : undefined,
|
||||||
passport_id,
|
passport_id,
|
||||||
|
timezone,
|
||||||
},
|
},
|
||||||
agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined,
|
agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined,
|
||||||
})
|
})
|
||||||
@@ -94,6 +98,12 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Timezone</label>
|
||||||
|
<TimezoneSelect value={timezone} onChange={setTimezone} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<GenderInput value={gender} onChange={setGender} />
|
<GenderInput value={gender} onChange={setGender} />
|
||||||
{user.type === "corporate" && (
|
{user.type === "corporate" && (
|
||||||
<Input name="position" onChange={setPosition} type="text" label="Position" placeholder="CEO, Head of Marketing..." required />
|
<Input name="position" onChange={setPosition} type="text" label="Position" placeholder="CEO, Head of Marketing..." required />
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {useEffect, useState} from "react";
|
|||||||
import {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen, BsQuestionSquare} from "react-icons/bs";
|
import {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen, BsQuestionSquare} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import Button from "./Low/Button";
|
import Button from "./Low/Button";
|
||||||
|
import ModuleLevelSelector from "./Medium/ModuleLevelSelector";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -36,7 +37,7 @@ export default function Diagnostic({onFinish}: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectExam = () => {
|
const selectExam = () => {
|
||||||
const examPromises = MODULE_ARRAY.map((module) => getExam(module, true));
|
const examPromises = MODULE_ARRAY.map((module) => getExam(module, true, "partial"));
|
||||||
|
|
||||||
Promise.all(examPromises).then((exams) => {
|
Promise.all(examPromises).then((exams) => {
|
||||||
if (exams.every((x) => !!x)) {
|
if (exams.every((x) => !!x)) {
|
||||||
@@ -90,111 +91,17 @@ export default function Diagnostic({onFinish}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center justify-center gap-8 w-full">
|
<div className="flex flex-col items-center justify-center gap-8 w-full">
|
||||||
<h2 className="font-semibold text-xl">What is your current IELTS level?</h2>
|
<h2 className="font-semibold text-xl">What is your current IELTS level?</h2>
|
||||||
<div className="flex flex-col gap-32 w-full mb-20">
|
<ModuleLevelSelector levels={levels} setLevels={setLevels} />
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-16 mb-24">
|
|
||||||
<div className="w-full flex flex-col gap-3.5 relative">
|
|
||||||
<span className="text-sm text-mti-gray-dim">
|
|
||||||
<span className="font-bold">Reading</span> level
|
|
||||||
</span>
|
|
||||||
<Menu>
|
|
||||||
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
|
||||||
<BsBook className="text-ielts-reading" size={34} />
|
|
||||||
<span className="text-mti-gray-cool text-sm">
|
|
||||||
{levels.reading === -1 ? "Select your reading level" : `Level ${levels.reading}`}
|
|
||||||
</span>
|
|
||||||
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
|
||||||
</Menu.Button>
|
|
||||||
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
|
|
||||||
{Object.values(writingMarking).map((x) => (
|
|
||||||
<Menu.Item key={x}>
|
|
||||||
<span
|
|
||||||
onClick={() => setLevels((prev) => ({...prev, reading: x}))}
|
|
||||||
className="w-full py-4 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
|
||||||
Level {x}
|
|
||||||
</span>
|
|
||||||
</Menu.Item>
|
|
||||||
))}
|
|
||||||
</Menu.Items>
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
<div className="w-full flex flex-col gap-3.5 relative">
|
|
||||||
<span className="text-sm text-mti-gray-dim">
|
|
||||||
<span className="font-bold">Listening</span> level
|
|
||||||
</span>
|
|
||||||
<Menu>
|
|
||||||
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
|
||||||
<BsHeadphones className="text-ielts-listening" size={34} />
|
|
||||||
<span className="text-mti-gray-cool text-sm">
|
|
||||||
{levels.listening === -1 ? "Select your listening level" : `Level ${levels.listening}`}
|
|
||||||
</span>
|
|
||||||
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
|
||||||
</Menu.Button>
|
|
||||||
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
|
|
||||||
{Object.values(writingMarking).map((x) => (
|
|
||||||
<Menu.Item key={x}>
|
|
||||||
<span
|
|
||||||
onClick={() => setLevels((prev) => ({...prev, listening: x}))}
|
|
||||||
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
|
||||||
Level {x}
|
|
||||||
</span>
|
|
||||||
</Menu.Item>
|
|
||||||
))}
|
|
||||||
</Menu.Items>
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
<div className="w-full flex flex-col gap-3.5 relative">
|
|
||||||
<span className="text-sm text-mti-gray-dim">
|
|
||||||
<span className="font-bold">Writing</span> level
|
|
||||||
</span>
|
|
||||||
<Menu>
|
|
||||||
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
|
||||||
<BsPen className="text-ielts-writing" size={34} />
|
|
||||||
<span className="text-mti-gray-cool text-sm">
|
|
||||||
{levels.writing === -1 ? "Select your writing level" : `Level ${levels.writing}`}
|
|
||||||
</span>
|
|
||||||
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
|
||||||
</Menu.Button>
|
|
||||||
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
|
|
||||||
{Object.values(writingMarking).map((x) => (
|
|
||||||
<Menu.Item key={x}>
|
|
||||||
<span
|
|
||||||
onClick={() => setLevels((prev) => ({...prev, writing: x}))}
|
|
||||||
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
|
||||||
Level {x}
|
|
||||||
</span>
|
|
||||||
</Menu.Item>
|
|
||||||
))}
|
|
||||||
</Menu.Items>
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
<div className="w-full flex flex-col gap-3.5 relative">
|
|
||||||
<span className="text-sm text-mti-gray-dim">
|
|
||||||
<span className="font-bold">Speaking</span> level
|
|
||||||
</span>
|
|
||||||
<Menu>
|
|
||||||
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
|
||||||
<BsMegaphone className="text-ielts-speaking" size={34} />
|
|
||||||
<span className="text-mti-gray-cool text-sm">
|
|
||||||
{levels.speaking === -1 ? "Select your speaking level" : `Level ${levels.speaking}`}
|
|
||||||
</span>
|
|
||||||
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
|
||||||
</Menu.Button>
|
|
||||||
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
|
|
||||||
{Object.values(writingMarking).map((x) => (
|
|
||||||
<Menu.Item key={x}>
|
|
||||||
<span
|
|
||||||
onClick={() => setLevels((prev) => ({...prev, speaking: x}))}
|
|
||||||
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
|
||||||
Level {x}
|
|
||||||
</span>
|
|
||||||
</Menu.Item>
|
|
||||||
))}
|
|
||||||
</Menu.Items>
|
|
||||||
</Menu>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center justify-center gap-8 w-full mb-44">
|
||||||
|
<h2 className="font-semibold text-xl">What is your desired IELTS level?</h2>
|
||||||
|
<ModuleLevelSelector levels={desiredLevels} setLevels={setDesiredLevels} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:self-end flex -md:flex-col justify-between w-full gap-8 absolute bottom-8 left-0 px-4 md:px-8">
|
<div className="md:self-end flex -md:flex-col justify-between w-full gap-8 absolute bottom-8 left-0 px-4 md:px-8">
|
||||||
<div className="w-full tooltip" data-tip="Your screen size is too small to perform a diagnostic test">
|
<div className="w-full tooltip" data-tip="Your screen size is too small to perform a diagnostic test">
|
||||||
<Button
|
<Button
|
||||||
@@ -224,7 +131,5 @@ export default function Diagnostic({onFinish}: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill}
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import {downloadBlob} from "@/utils/evaluation";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||||
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
||||||
@@ -14,9 +16,11 @@ const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mo
|
|||||||
export default function InteractiveSpeaking({
|
export default function InteractiveSpeaking({
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
|
examID,
|
||||||
text,
|
text,
|
||||||
type,
|
type,
|
||||||
prompts,
|
prompts,
|
||||||
|
userSolutions,
|
||||||
updateIndex,
|
updateIndex,
|
||||||
onNext,
|
onNext,
|
||||||
onBack,
|
onBack,
|
||||||
@@ -24,20 +28,109 @@ export default function InteractiveSpeaking({
|
|||||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [mediaBlob, setMediaBlob] = useState<string>();
|
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||||
const [promptIndex, setPromptIndex] = useState(0);
|
const [answers, setAnswers] = useState<{prompt: string; blob: string; questionIndex: number}[]>([]);
|
||||||
const [answers, setAnswers] = useState<{prompt: string; blob: string}[]>([]);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
|
||||||
|
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
|
const saveToStorage = async (previousURL?: string) => {
|
||||||
|
if (mediaBlob && mediaBlob.startsWith("blob")) {
|
||||||
|
const blobBuffer = await downloadBlob(mediaBlob);
|
||||||
|
const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"});
|
||||||
|
|
||||||
|
const seed = Math.random().toString().replace("0.", "");
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("audio", audioFile, `${seed}.wav`);
|
||||||
|
formData.append("root", "speaking_recordings");
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "audio/wav",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await axios.post<{path: string}>("/api/storage/insert", formData, config);
|
||||||
|
if (previousURL && !previousURL.startsWith("blob")) await axios.post("/api/storage/delete", {path: previousURL});
|
||||||
|
return response.data.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const back = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const answer = await saveAnswer(questionIndex);
|
||||||
|
if (questionIndex - 1 >= 0) {
|
||||||
|
setQuestionIndex(questionIndex - 1);
|
||||||
|
setIsLoading(false);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
|
||||||
|
onBack({
|
||||||
|
exercise: id,
|
||||||
|
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||||
|
score: {correct: 1, total: 1, missing: 0},
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const answer = await saveAnswer(questionIndex);
|
||||||
|
if (questionIndex + 1 < prompts.length) {
|
||||||
|
setQuestionIndex(questionIndex + 1);
|
||||||
|
setIsLoading(false);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
|
||||||
|
onNext({
|
||||||
|
exercise: id,
|
||||||
|
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||||
|
score: {correct: 1, total: 1, missing: 0},
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (updateIndex) updateIndex(promptIndex);
|
if (userSolutions.length > 0 && answers.length === 0) {
|
||||||
}, [promptIndex, updateIndex]);
|
console.log(userSolutions);
|
||||||
|
const solutions = userSolutions as unknown as typeof answers;
|
||||||
|
setAnswers(solutions);
|
||||||
|
|
||||||
|
if (!mediaBlob) setMediaBlob(solutions.find((x) => x.questionIndex === questionIndex)?.blob);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [userSolutions, mediaBlob, answers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log({answers});
|
||||||
|
}, [answers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (updateIndex) updateIndex(questionIndex);
|
||||||
|
}, [questionIndex, updateIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded) {
|
if (hasExamEnded) {
|
||||||
|
const answer = {
|
||||||
|
questionIndex,
|
||||||
|
prompt: prompts[questionIndex].text,
|
||||||
|
blob: mediaBlob!,
|
||||||
|
};
|
||||||
|
|
||||||
onNext({
|
onNext({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||||
score: {correct: 1, total: 1, missing: 0},
|
score: {correct: 1, total: 1, missing: 0},
|
||||||
type,
|
type,
|
||||||
});
|
});
|
||||||
@@ -59,19 +152,38 @@ export default function InteractiveSpeaking({
|
|||||||
}, [isRecording]);
|
}, [isRecording]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (promptIndex === answers.length - 1) {
|
if (questionIndex <= answers.length - 1) {
|
||||||
setMediaBlob(answers[promptIndex].blob);
|
const blob = answers.find((x) => x.questionIndex === questionIndex)?.blob;
|
||||||
|
setMediaBlob(blob);
|
||||||
}
|
}
|
||||||
}, [answers, promptIndex]);
|
}, [answers, questionIndex]);
|
||||||
|
|
||||||
|
const saveAnswer = async (index: number) => {
|
||||||
|
const previousURL = answers.find((x) => x.questionIndex === questionIndex)?.blob;
|
||||||
|
const audioPath = await saveToStorage(previousURL);
|
||||||
|
|
||||||
const saveAnswer = () => {
|
|
||||||
const answer = {
|
const answer = {
|
||||||
prompt: prompts[promptIndex].text,
|
questionIndex,
|
||||||
blob: mediaBlob!,
|
prompt: prompts[questionIndex].text,
|
||||||
|
blob: audioPath ? audioPath : mediaBlob!,
|
||||||
};
|
};
|
||||||
|
|
||||||
setAnswers((prev) => [...prev, answer]);
|
setAnswers((prev) => [...prev.filter((x) => x.questionIndex !== index), answer]);
|
||||||
setMediaBlob(undefined);
|
setMediaBlob(undefined);
|
||||||
|
|
||||||
|
setUserSolutions([
|
||||||
|
...storeUserSolutions.filter((x) => x.exercise !== id),
|
||||||
|
{
|
||||||
|
exercise: id,
|
||||||
|
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||||
|
score: {correct: 1, total: 1, missing: 0},
|
||||||
|
module: "speaking",
|
||||||
|
exam: examID,
|
||||||
|
type,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return answer;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -82,8 +194,8 @@ export default function InteractiveSpeaking({
|
|||||||
</div>
|
</div>
|
||||||
{prompts && prompts.length > 0 && (
|
{prompts && prompts.length > 0 && (
|
||||||
<div className="flex flex-col gap-4 w-full items-center">
|
<div className="flex flex-col gap-4 w-full items-center">
|
||||||
<video key={promptIndex} autoPlay controls className="max-w-3xl rounded-xl">
|
<video key={questionIndex} autoPlay controls className="max-w-3xl rounded-xl">
|
||||||
<source src={prompts[promptIndex].video_url} />
|
<source src={prompts[questionIndex].video_url} />
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -91,13 +203,13 @@ export default function InteractiveSpeaking({
|
|||||||
|
|
||||||
<ReactMediaRecorder
|
<ReactMediaRecorder
|
||||||
audio
|
audio
|
||||||
key={promptIndex}
|
key={questionIndex}
|
||||||
onStop={(blob) => setMediaBlob(blob)}
|
onStop={(blob) => setMediaBlob(blob)}
|
||||||
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => (
|
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => (
|
||||||
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
||||||
<p className="text-base font-normal">Record your answer:</p>
|
<p className="text-base font-normal">Record your answer:</p>
|
||||||
<div className="flex gap-8 items-center justify-center py-8">
|
<div className="flex gap-8 items-center justify-center py-8">
|
||||||
{status === "idle" && (
|
{status === "idle" && !mediaBlob && (
|
||||||
<>
|
<>
|
||||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
{status === "idle" && (
|
{status === "idle" && (
|
||||||
@@ -176,9 +288,9 @@ export default function InteractiveSpeaking({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{status === "stopped" && mediaBlobUrl && (
|
{((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && (
|
||||||
<>
|
<>
|
||||||
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
<Waveform audio={mediaBlobUrl ? mediaBlobUrl : mediaBlob!} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<BsTrashFill
|
<BsTrashFill
|
||||||
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||||
@@ -208,44 +320,11 @@ export default function InteractiveSpeaking({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8">
|
<div className="self-end flex justify-between w-full gap-8">
|
||||||
<Button
|
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() =>
|
|
||||||
onBack({
|
|
||||||
exercise: id,
|
|
||||||
solutions: answers,
|
|
||||||
score: {correct: 1, total: 1, missing: 0},
|
|
||||||
type,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button color="purple" disabled={!mediaBlob} isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
|
||||||
color="purple"
|
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
|
||||||
disabled={!mediaBlob}
|
|
||||||
onClick={() => {
|
|
||||||
saveAnswer();
|
|
||||||
if (promptIndex + 1 < prompts.length) {
|
|
||||||
setPromptIndex((prev) => prev + 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onNext({
|
|
||||||
exercise: id,
|
|
||||||
solutions: [
|
|
||||||
...answers,
|
|
||||||
{
|
|
||||||
prompt: prompts[promptIndex].text,
|
|
||||||
blob: mediaBlob!,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
score: {correct: 1, total: 1, missing: 0},
|
|
||||||
type,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
{promptIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,10 +59,18 @@ export default function MultipleChoice({
|
|||||||
onBack,
|
onBack,
|
||||||
}: MultipleChoiceExercise & CommonProps) {
|
}: MultipleChoiceExercise & CommonProps) {
|
||||||
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
||||||
const [questionIndex, setQuestionIndex] = useState(0);
|
|
||||||
|
|
||||||
|
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
|
||||||
|
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
|
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUserSolutions([...storeUserSolutions.filter((x) => x.exercise !== id), {exercise: id, solutions: answers, score: calculateScore(), type}]);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [answers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -91,16 +99,20 @@ export default function MultipleChoice({
|
|||||||
if (questionIndex === questions.length - 1) {
|
if (questionIndex === questions.length - 1) {
|
||||||
onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
} else {
|
} else {
|
||||||
setQuestionIndex((prev) => prev + 1);
|
setQuestionIndex(questionIndex + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scrollToTop();
|
||||||
};
|
};
|
||||||
|
|
||||||
const back = () => {
|
const back = () => {
|
||||||
if (questionIndex === 0) {
|
if (questionIndex === 0) {
|
||||||
onBack({exercise: id, solutions: answers, score: calculateScore(), type});
|
onBack({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
} else {
|
} else {
|
||||||
setQuestionIndex((prev) => prev - 1);
|
setQuestionIndex(questionIndex - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scrollToTop();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,28 +5,58 @@ import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill}
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import {downloadBlob} from "@/utils/evaluation";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||||
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function Speaking({id, title, text, video_url, type, prompts, onNext, onBack}: SpeakingExercise & CommonProps) {
|
export default function Speaking({id, title, text, video_url, type, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
|
||||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [mediaBlob, setMediaBlob] = useState<string>();
|
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||||
|
const [audioURL, setAudioURL] = useState<string>();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
useEffect(() => {
|
const saveToStorage = async () => {
|
||||||
if (hasExamEnded) {
|
if (mediaBlob && mediaBlob.startsWith("blob")) {
|
||||||
onNext({
|
const blobBuffer = await downloadBlob(mediaBlob);
|
||||||
exercise: id,
|
const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"});
|
||||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
|
||||||
score: {correct: 1, total: 1, missing: 0},
|
const seed = Math.random().toString().replace("0.", "");
|
||||||
type,
|
|
||||||
});
|
const formData = new FormData();
|
||||||
|
formData.append("audio", audioFile, `${seed}.wav`);
|
||||||
|
formData.append("root", "speaking_recordings");
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "audio/wav",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await axios.post<{path: string}>("/api/storage/insert", formData, config);
|
||||||
|
if (audioURL) await axios.post("/api/storage/delete", {path: audioURL});
|
||||||
|
return response.data.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (userSolutions.length > 0) {
|
||||||
|
const {solution} = userSolutions[0] as {solution?: string};
|
||||||
|
if (solution && !mediaBlob) setMediaBlob(solution);
|
||||||
|
if (solution && !solution.startsWith("blob")) setAudioURL(solution);
|
||||||
|
}
|
||||||
|
}, [userSolutions, mediaBlob]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasExamEnded) next();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [hasExamEnded]);
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
@@ -43,6 +73,32 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
|
|||||||
};
|
};
|
||||||
}, [isRecording]);
|
}, [isRecording]);
|
||||||
|
|
||||||
|
const next = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
const storagePath = await saveToStorage();
|
||||||
|
setIsLoading(false);
|
||||||
|
|
||||||
|
onNext({
|
||||||
|
exercise: id,
|
||||||
|
solutions: storagePath ? [{id, solution: storagePath}] : [],
|
||||||
|
score: {correct: 1, total: 1, missing: 0},
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const back = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
const storagePath = await saveToStorage();
|
||||||
|
setIsLoading(false);
|
||||||
|
|
||||||
|
onBack({
|
||||||
|
exercise: id,
|
||||||
|
solutions: storagePath ? [{id, solution: storagePath}] : [],
|
||||||
|
score: {correct: 1, total: 1, missing: 0},
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-full gap-9">
|
<div className="flex flex-col h-full w-full gap-9">
|
||||||
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
|
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
|
||||||
@@ -89,7 +145,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
|
|||||||
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
||||||
<p className="text-base font-normal">Record your answer:</p>
|
<p className="text-base font-normal">Record your answer:</p>
|
||||||
<div className="flex gap-8 items-center justify-center py-8">
|
<div className="flex gap-8 items-center justify-center py-8">
|
||||||
{status === "idle" && (
|
{status === "idle" && !mediaBlob && (
|
||||||
<>
|
<>
|
||||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
{status === "idle" && (
|
{status === "idle" && (
|
||||||
@@ -168,9 +224,9 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{status === "stopped" && mediaBlobUrl && (
|
{((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && (
|
||||||
<>
|
<>
|
||||||
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
<Waveform audio={mediaBlobUrl ? mediaBlobUrl : mediaBlob!} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<BsTrashFill
|
<BsTrashFill
|
||||||
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||||
@@ -200,32 +256,10 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8">
|
<div className="self-end flex justify-between w-full gap-8">
|
||||||
<Button
|
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() =>
|
|
||||||
onBack({
|
|
||||||
exercise: id,
|
|
||||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
|
||||||
score: {correct: 1, total: 1, missing: 0},
|
|
||||||
type,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
|
||||||
color="purple"
|
|
||||||
disabled={!mediaBlob}
|
|
||||||
onClick={() =>
|
|
||||||
onNext({
|
|
||||||
exercise: id,
|
|
||||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
|
||||||
score: {correct: 1, total: 1, missing: 0},
|
|
||||||
type,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,9 +22,31 @@ export default function Writing({
|
|||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : "");
|
const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : "");
|
||||||
const [isSubmitEnabled, setIsSubmitEnabled] = useState(false);
|
const [isSubmitEnabled, setIsSubmitEnabled] = useState(false);
|
||||||
|
const [saveTimer, setSaveTimer] = useState(0);
|
||||||
|
|
||||||
|
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saveTimerInterval = setInterval(() => {
|
||||||
|
setSaveTimer((prev) => prev + 1);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(saveTimerInterval);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inputText.length > 0 && saveTimer % 10 === 0) {
|
||||||
|
setUserSolutions([
|
||||||
|
...storeUserSolutions.filter((x) => x.exercise !== id),
|
||||||
|
{exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [saveTimer]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (localStorage.getItem("enable_paste")) return;
|
if (localStorage.getItem("enable_paste")) return;
|
||||||
|
|
||||||
@@ -132,7 +154,14 @@ export default function Writing({
|
|||||||
<Button
|
<Button
|
||||||
color="purple"
|
color="purple"
|
||||||
disabled={!isSubmitEnabled}
|
disabled={!isSubmitEnabled}
|
||||||
onClick={() => onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type})}
|
onClick={() =>
|
||||||
|
onNext({
|
||||||
|
exercise: id,
|
||||||
|
solutions: [{id, solution: inputText.replaceAll(/\s{2,}/g, " ")}],
|
||||||
|
score: {correct: 1, total: 1, missing: 0},
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
}
|
||||||
className="max-w-[200px] self-end w-full">
|
className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import InteractiveSpeaking from "./InteractiveSpeaking";
|
|||||||
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false});
|
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false});
|
||||||
|
|
||||||
export interface CommonProps {
|
export interface CommonProps {
|
||||||
|
examID?: string;
|
||||||
updateIndex?: (internalIndex: number) => void;
|
updateIndex?: (internalIndex: number) => void;
|
||||||
onNext: (userSolutions: UserSolution) => void;
|
onNext: (userSolutions: UserSolution) => void;
|
||||||
onBack: (userSolutions: UserSolution) => void;
|
onBack: (userSolutions: UserSolution) => void;
|
||||||
@@ -29,17 +30,18 @@ export interface CommonProps {
|
|||||||
|
|
||||||
export const renderExercise = (
|
export const renderExercise = (
|
||||||
exercise: Exercise,
|
exercise: Exercise,
|
||||||
|
examID: string,
|
||||||
onNext: (userSolutions: UserSolution) => void,
|
onNext: (userSolutions: UserSolution) => void,
|
||||||
onBack: (userSolutions: UserSolution) => void,
|
onBack: (userSolutions: UserSolution) => void,
|
||||||
updateIndex?: (internalIndex: number) => void,
|
updateIndex?: (internalIndex: number) => void,
|
||||||
) => {
|
) => {
|
||||||
switch (exercise.type) {
|
switch (exercise.type) {
|
||||||
case "fillBlanks":
|
case "fillBlanks":
|
||||||
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||||
case "trueFalse":
|
case "trueFalse":
|
||||||
return <TrueFalse key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
|
return <TrueFalse key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||||
case "matchSentences":
|
case "matchSentences":
|
||||||
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||||
case "multipleChoice":
|
case "multipleChoice":
|
||||||
return (
|
return (
|
||||||
<MultipleChoice
|
<MultipleChoice
|
||||||
@@ -48,19 +50,21 @@ export const renderExercise = (
|
|||||||
updateIndex={updateIndex}
|
updateIndex={updateIndex}
|
||||||
onNext={onNext}
|
onNext={onNext}
|
||||||
onBack={onBack}
|
onBack={onBack}
|
||||||
|
examID={examID}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "writeBlanks":
|
case "writeBlanks":
|
||||||
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||||
case "writing":
|
case "writing":
|
||||||
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
|
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||||
case "speaking":
|
case "speaking":
|
||||||
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||||
case "interactiveSpeaking":
|
case "interactiveSpeaking":
|
||||||
return (
|
return (
|
||||||
<InteractiveSpeaking
|
<InteractiveSpeaking
|
||||||
key={exercise.id}
|
key={exercise.id}
|
||||||
{...(exercise as InteractiveSpeakingExercise)}
|
{...(exercise as InteractiveSpeakingExercise)}
|
||||||
|
examID={examID}
|
||||||
updateIndex={updateIndex}
|
updateIndex={updateIndex}
|
||||||
onNext={onNext}
|
onNext={onNext}
|
||||||
onBack={onBack}
|
onBack={onBack}
|
||||||
|
|||||||
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", "agent"].includes(x.type))
|
||||||
|
.map((u) => ({
|
||||||
|
value: u.id,
|
||||||
|
label: `${u.name} - ${u.email}`,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
disabled={user.type === "agent"}
|
||||||
|
value={
|
||||||
|
assignedTo
|
||||||
|
? {
|
||||||
|
value: assignedTo,
|
||||||
|
label: `${users.find((u) => u.id === assignedTo)?.name} - ${users.find((u) => u.id === assignedTo)?.email}`,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onChange={(value) =>
|
||||||
|
value
|
||||||
|
? setAssignedTo(value.value === "me" ? user.id : value.value)
|
||||||
|
: setAssignedTo(null)
|
||||||
|
}
|
||||||
|
placeholder="Assignee..."
|
||||||
|
isClearable
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="-md:flex-col flex w-full items-center gap-4">
|
||||||
|
<Input
|
||||||
|
label="Reported From"
|
||||||
|
type="text"
|
||||||
|
name="reportedFrom"
|
||||||
|
onChange={() => null}
|
||||||
|
value={reportedFrom}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Date"
|
||||||
|
type="text"
|
||||||
|
name="date"
|
||||||
|
onChange={() => null}
|
||||||
|
value={moment(ticket.date).format("DD/MM/YYYY - HH:mm")}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="-md:flex-col flex w-full items-center gap-4">
|
||||||
|
<Input
|
||||||
|
label="Reporter's Name"
|
||||||
|
type="text"
|
||||||
|
name="reporter"
|
||||||
|
onChange={() => null}
|
||||||
|
value={reporter.name}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Reporter's E-mail"
|
||||||
|
type="text"
|
||||||
|
name="reporter"
|
||||||
|
onChange={() => null}
|
||||||
|
value={reporter.email}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Reporter's Type"
|
||||||
|
type="text"
|
||||||
|
name="reporterType"
|
||||||
|
onChange={() => null}
|
||||||
|
value={USER_TYPE_LABELS[reporter.type]}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8"
|
||||||
|
placeholder="Write your ticket's description here..."
|
||||||
|
contentEditable={false}
|
||||||
|
value={description}
|
||||||
|
spellCheck
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="-md:flex-col-reverse mt-2 flex w-full items-center justify-between gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="red"
|
||||||
|
className="w-full md:max-w-[200px]"
|
||||||
|
variant="outline"
|
||||||
|
onClick={del}
|
||||||
|
isLoading={isLoading}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="-md:flex-col-reverse flex w-full items-center justify-end gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="red"
|
||||||
|
className="w-full md:max-w-[200px]"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
isLoading={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full md:max-w-[200px]"
|
||||||
|
isLoading={isLoading}
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
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 {
|
interface Props {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
color?: "rose" | "purple" | "red" | "green" | "gray";
|
color?: "rose" | "purple" | "red" | "green" | "gray" | "pink";
|
||||||
variant?: "outline" | "solid";
|
variant?: "outline" | "solid";
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -49,6 +49,11 @@ export default function Button({
|
|||||||
outline:
|
outline:
|
||||||
"bg-transparent text-mti-rose-light border border-mti-rose-light hover:bg-mti-rose-light disabled:text-mti-rose disabled:bg-mti-rose-ultralight disabled:border-none selection:bg-mti-rose-dark hover:text-white selection:text-white",
|
"bg-transparent text-mti-rose-light border border-mti-rose-light hover:bg-mti-rose-light disabled:text-mti-rose disabled:bg-mti-rose-ultralight disabled:border-none selection:bg-mti-rose-dark hover:text-white selection:text-white",
|
||||||
},
|
},
|
||||||
|
pink: {
|
||||||
|
solid: "bg-ielts-speaking text-white border border-ielts-speaking hover:bg-ielts-speaking disabled:text-ielts-speaking disabled:bg-ielts-speaking-transparent selection:bg-ielts-speaking",
|
||||||
|
outline:
|
||||||
|
"bg-transparent text-ielts-speaking border border-ielts-speaking hover:bg-ielts-speaking disabled:text-ielts-speaking disabled:bg-ielts-speaking-transparent disabled:border-none selection:bg-ielts-speaking hover:text-white selection:text-white",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ export default function CountrySelect({value, disabled = false, onChange}: Props
|
|||||||
displayValue={(code: string) => {
|
displayValue={(code: string) => {
|
||||||
const country = countries[code as unknown as keyof TCountries];
|
const country = countries[code as unknown as keyof TCountries];
|
||||||
|
|
||||||
return `${countryCodes.findOne("countryCode" as any, code).flag} ${country.name} (+${country.phone})`;
|
return `${countryCodes.findOne("countryCode" as any, code)?.flag || ""} ${country?.name || "N/A"} (+${
|
||||||
|
country?.phone || "N/A"
|
||||||
|
})`;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-8">
|
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-8">
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ interface Props {
|
|||||||
label: string;
|
label: string;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
color: "red" | "rose" | "purple" | Module;
|
color: "red" | "rose" | "purple" | Module;
|
||||||
|
mark?: number;
|
||||||
|
markLabel?: string;
|
||||||
useColor?: boolean;
|
useColor?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
textClassName?: string;
|
textClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProgressBar({label, percentage, color, useColor = false, className, textClassName}: Props) {
|
export default function ProgressBar({label, percentage, color, mark, markLabel, useColor = false, className, textClassName}: Props) {
|
||||||
const progressColorClass: {[key in typeof color]: string} = {
|
const progressColorClass: {[key in typeof color]: string} = {
|
||||||
red: "bg-mti-red-light",
|
red: "bg-mti-red-light",
|
||||||
rose: "bg-mti-rose-light",
|
rose: "bg-mti-rose-light",
|
||||||
@@ -30,6 +32,9 @@ export default function ProgressBar({label, percentage, color, useColor = false,
|
|||||||
!useColor ? "bg-mti-gray-anti-flash" : progressColorClass[color],
|
!useColor ? "bg-mti-gray-anti-flash" : progressColorClass[color],
|
||||||
useColor && "bg-opacity-20",
|
useColor && "bg-opacity-20",
|
||||||
)}>
|
)}>
|
||||||
|
{mark && (
|
||||||
|
<div style={{left: `${mark}%`}} className={clsx("w-3 h-2 bg-mti-gray-davy/60 absolute -translate-x-1/2 top-0 z-20 cursor-pointer")} />
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
style={{width: `${percentage}%`}}
|
style={{width: `${percentage}%`}}
|
||||||
className={clsx("absolute transition-all duration-300 ease-in-out top-0 left-0 h-full overflow-hidden", progressColorClass[color])}
|
className={clsx("absolute transition-all duration-300 ease-in-out top-0 left-0 h-full overflow-hidden", progressColorClass[color])}
|
||||||
|
|||||||
61
src/components/Low/Select.tsx
Normal file
61
src/components/Low/Select.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import {ComponentProps, useEffect, useState} from "react";
|
||||||
|
import ReactSelect from "react-select";
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
[key: string]: any;
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
defaultValue?: Option;
|
||||||
|
value?: Option | null;
|
||||||
|
options: Option[];
|
||||||
|
disabled?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
onChange: (value: Option | null) => void;
|
||||||
|
isClearable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Select({value, defaultValue, options, placeholder, disabled, onChange, isClearable}: Props) {
|
||||||
|
const [target, setTarget] = useState<HTMLElement>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (document) setTarget(document.body);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactSelect
|
||||||
|
className={clsx(
|
||||||
|
"placeholder:text-mti-gray-cool border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none",
|
||||||
|
disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
|
||||||
|
)}
|
||||||
|
options={options}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
menuPortalTarget={target}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
styles={{
|
||||||
|
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||||
|
control: (styles) => ({
|
||||||
|
...styles,
|
||||||
|
paddingLeft: "4px",
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
":focus": {
|
||||||
|
outline: "none",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
option: (styles, state) => ({
|
||||||
|
...styles,
|
||||||
|
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||||
|
color: state.isFocused ? "black" : styles.color,
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
isDisabled={disabled}
|
||||||
|
isClearable={isClearable}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
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,12 +1,12 @@
|
|||||||
import {User} from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import {Dialog, Transition} from "@headlessui/react";
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {useRouter} from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import {Fragment} from "react";
|
import { Fragment } from "react";
|
||||||
import {BsShield, BsShieldFill, BsXLg} from "react-icons/bs";
|
import { BsXLg } from "react-icons/bs";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -15,7 +15,7 @@ interface Props {
|
|||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MobileMenu({isOpen, onClose, path, user}: Props) {
|
export default function MobileMenu({ isOpen, onClose, path, user }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
@@ -34,7 +34,8 @@ export default function MobileMenu({isOpen, onClose, path, user}: Props) {
|
|||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="ease-in duration-200"
|
leave="ease-in duration-200"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0">
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
|
|
||||||
@@ -47,42 +48,65 @@ export default function MobileMenu({isOpen, onClose, path, user}: Props) {
|
|||||||
enterTo="opacity-100 scale-100"
|
enterTo="opacity-100 scale-100"
|
||||||
leave="ease-in duration-200"
|
leave="ease-in duration-200"
|
||||||
leaveFrom="opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="opacity-0 scale-95">
|
leaveTo="opacity-0 scale-95"
|
||||||
<Dialog.Panel className="w-full h-screen transform overflow-hidden bg-white text-left align-middle shadow-xl transition-all text-black flex flex-col gap-8">
|
>
|
||||||
<Dialog.Title as="header" className="w-full px-8 py-2 -md:flex justify-between items-center md:hidden shadow-sm">
|
<Dialog.Panel className="flex h-screen w-full transform flex-col gap-8 overflow-hidden bg-white text-left align-middle text-black shadow-xl transition-all">
|
||||||
|
<Dialog.Title
|
||||||
|
as="header"
|
||||||
|
className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden"
|
||||||
|
>
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<Image src="/logo_title.png" alt="EnCoach logo" width={69} height={69} />
|
<Image
|
||||||
|
src="/logo_title.png"
|
||||||
|
alt="EnCoach logo"
|
||||||
|
width={69}
|
||||||
|
height={69}
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="cursor-pointer" onClick={onClose} tabIndex={0}>
|
<div
|
||||||
<BsXLg className="text-2xl text-mti-purple-light" onClick={onClose} />
|
className="cursor-pointer"
|
||||||
|
onClick={onClose}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<BsXLg
|
||||||
|
className="text-mti-purple-light text-2xl"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<div className="flex flex-col h-full gap-6 px-8 text-lg">
|
<div className="flex h-full flex-col gap-6 px-8 text-lg">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"transition ease-in-out duration-300 w-fit",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
path === "/" &&
|
||||||
)}>
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
|
)}
|
||||||
|
>
|
||||||
Dashboard
|
Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
{(user.type === "student" || user.type === "teacher" || user.type === "developer") && (
|
{(user.type === "student" ||
|
||||||
|
user.type === "teacher" ||
|
||||||
|
user.type === "developer") && (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
href="/exam"
|
href="/exam"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"transition ease-in-out duration-300 w-fit",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/exam" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
path === "/exam" &&
|
||||||
)}>
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
|
)}
|
||||||
|
>
|
||||||
Exams
|
Exams
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/exercises"
|
href="/exercises"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"transition ease-in-out duration-300 w-fit",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/exercises" &&
|
path === "/exercises" &&
|
||||||
"text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
Exercises
|
Exercises
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
@@ -90,52 +114,80 @@ export default function MobileMenu({isOpen, onClose, path, user}: Props) {
|
|||||||
<Link
|
<Link
|
||||||
href="/stats"
|
href="/stats"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"transition ease-in-out duration-300 w-fit",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/stats" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
path === "/stats" &&
|
||||||
)}>
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
|
)}
|
||||||
|
>
|
||||||
Stats
|
Stats
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/record"
|
href="/record"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"transition ease-in-out duration-300 w-fit",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/record" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
path === "/record" &&
|
||||||
)}>
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
|
)}
|
||||||
|
>
|
||||||
Record
|
Record
|
||||||
</Link>
|
</Link>
|
||||||
{["admin", "developer", "agent", "corporate"].includes(user.type) && (
|
{["admin", "developer", "agent", "corporate"].includes(
|
||||||
|
user.type,
|
||||||
|
) && (
|
||||||
<Link
|
<Link
|
||||||
href="/payment-record"
|
href="/payment-record"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"transition ease-in-out duration-300 w-fit",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/payment-record" &&
|
path === "/payment-record" &&
|
||||||
"text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
Payment Record
|
Payment Record
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{["admin", "developer", "corporate", "teacher"].includes(user.type) && (
|
{["admin", "developer", "corporate", "teacher"].includes(
|
||||||
|
user.type,
|
||||||
|
) && (
|
||||||
<Link
|
<Link
|
||||||
href="/settings"
|
href="/settings"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"transition ease-in-out duration-300 w-fit",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/settings" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
path === "/settings" &&
|
||||||
)}>
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
|
)}
|
||||||
|
>
|
||||||
Settings
|
Settings
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
{["admin", "developer", "agent"].includes(user.type) && (
|
||||||
|
<Link
|
||||||
|
href="/tickets"
|
||||||
|
className={clsx(
|
||||||
|
"w-fit transition duration-300 ease-in-out",
|
||||||
|
path === "/tickets" &&
|
||||||
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Tickets
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<Link
|
<Link
|
||||||
href="/profile"
|
href="/profile"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"transition ease-in-out duration-300 w-fit",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/profile" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
path === "/profile" &&
|
||||||
)}>
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
|
)}
|
||||||
|
>
|
||||||
Profile
|
Profile
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
className={clsx("transition ease-in-out duration-300 w-fit justify-self-end cursor-pointer")}
|
className={clsx(
|
||||||
onClick={logout}>
|
"w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out",
|
||||||
|
)}
|
||||||
|
onClick={logout}
|
||||||
|
>
|
||||||
Logout
|
Logout
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ interface Props {
|
|||||||
export default function Modal({isOpen, title, onClose, children}: Props) {
|
export default function Modal({isOpen, title, onClose, children}: Props) {
|
||||||
return (
|
return (
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
<Dialog as="div" className="relative z-10" onClose={onClose}>
|
<Dialog as="div" className="relative z-[200]" onClose={onClose}>
|
||||||
<Transition.Child
|
<Transition.Child
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="ease-out duration-300"
|
enter="ease-out duration-300"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Link from "next/link";
|
|||||||
import FocusLayer from "@/components/FocusLayer";
|
import FocusLayer from "@/components/FocusLayer";
|
||||||
import {preventNavigation} from "@/utils/navigation.disabled";
|
import {preventNavigation} from "@/utils/navigation.disabled";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {BsList} from "react-icons/bs";
|
import {BsList, BsQuestionCircle, BsQuestionCircleFill} from "react-icons/bs";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import MobileMenu from "./MobileMenu";
|
import MobileMenu from "./MobileMenu";
|
||||||
@@ -12,6 +12,10 @@ import {Type} from "@/interfaces/user";
|
|||||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
import {isUserFromCorporate} from "@/utils/groups";
|
import {isUserFromCorporate} from "@/utils/groups";
|
||||||
|
import Button from "./Low/Button";
|
||||||
|
import Modal from "./Modal";
|
||||||
|
import Input from "./Low/Input";
|
||||||
|
import TicketSubmission from "./High/TicketSubmission";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -25,9 +29,9 @@ interface Props {
|
|||||||
export default function Navbar({user, path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
export default function Navbar({user, path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
|
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
|
||||||
|
const [isTicketOpen, setIsTicketOpen] = useState(false);
|
||||||
|
|
||||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const expirationDateColor = (date: Date) => {
|
const expirationDateColor = (date: Date) => {
|
||||||
const momentDate = moment(date);
|
const momentDate = moment(date);
|
||||||
@@ -48,44 +52,59 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user.type !== "student" && user.type !== "teacher") setDisablePaymentPage(false);
|
if (user.type !== "student" && user.type !== "teacher") return setDisablePaymentPage(false);
|
||||||
isUserFromCorporate(user.id).then((result) => setDisablePaymentPage(result));
|
isUserFromCorporate(user.id).then((result) => setDisablePaymentPage(result));
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Modal isOpen={isTicketOpen} onClose={() => setIsTicketOpen(false)} title="Submit a ticket">
|
||||||
|
<TicketSubmission user={user} page={window.location.href} onClose={() => setIsTicketOpen(false)} />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{user && <MobileMenu path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />}
|
{user && <MobileMenu path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />}
|
||||||
<header className="w-full bg-transparent py-2 md:py-4 -md:justify-between md:gap-12 flex items-center relative -md:px-4">
|
<header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4">
|
||||||
<Link href={disableNavigation ? "" : "/"} className=" md:px-8 flex gap-8 items-center">
|
<Link href={disableNavigation ? "" : "/"} className=" flex items-center gap-8 md:px-8">
|
||||||
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" />
|
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" />
|
||||||
<h1 className="font-bold text-2xl w-1/6 -md:hidden">EnCoach</h1>
|
<h1 className="-md:hidden w-1/6 text-2xl font-bold">EnCoach</h1>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex justify-end -md:items-center gap-4 md:w-5/6 md:mr-8">
|
<div className="flex items-center justify-end gap-4 md:mr-8 md:w-5/6">
|
||||||
|
{/* OPEN TICKET SYSTEM */}
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
"border-mti-purple-light tooltip tooltip-bottom flex h-8 w-8 flex-col items-center justify-center rounded-full border p-1",
|
||||||
|
"hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white",
|
||||||
|
)}
|
||||||
|
data-tip="Submit a help/feedback ticket"
|
||||||
|
onClick={() => setIsTicketOpen(true)}>
|
||||||
|
<BsQuestionCircleFill />
|
||||||
|
</button>
|
||||||
|
|
||||||
{showExpirationDate() && (
|
{showExpirationDate() && (
|
||||||
<Link
|
<Link
|
||||||
href={disablePaymentPage ? "/payment" : ""}
|
href={disablePaymentPage ? "/payment" : ""}
|
||||||
data-tip="Expiry date"
|
data-tip="Expiry date"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"py-2 px-6 w-fit flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
"flex w-fit cursor-pointer justify-center rounded-full border px-6 py-2 text-sm font-normal focus:outline-none",
|
||||||
"transition duration-300 ease-in-out tooltip tooltip-bottom",
|
"tooltip tooltip-bottom transition duration-300 ease-in-out",
|
||||||
!user.subscriptionExpirationDate
|
!user.subscriptionExpirationDate
|
||||||
? "bg-mti-green-ultralight border-mti-green-light"
|
? "bg-mti-green-ultralight border-mti-green-light"
|
||||||
: expirationDateColor(user.subscriptionExpirationDate),
|
: expirationDateColor(user.subscriptionExpirationDate),
|
||||||
"bg-white border-mti-gray-platinum",
|
"border-mti-gray-platinum bg-white",
|
||||||
)}>
|
)}>
|
||||||
{!user.subscriptionExpirationDate && "Unlimited"}
|
{!user.subscriptionExpirationDate && "Unlimited"}
|
||||||
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
|
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<Link href={disableNavigation ? "" : "/profile"} className="flex gap-6 items-center justify-end -md:hidden">
|
<Link href={disableNavigation ? "" : "/profile"} className="-md:hidden flex items-center justify-end gap-6">
|
||||||
<img src={user.profilePicture} alt={user.name} className="w-10 h-10 rounded-full object-cover" />
|
<img src={user.profilePicture} alt={user.name} className="h-10 w-10 rounded-full object-cover" />
|
||||||
<span className="text-right -md:hidden">
|
<span className="-md:hidden text-right">
|
||||||
{user.type === "corporate" ? `${user.corporateInformation?.companyInformation.name} |` : ""} {user.name} |{" "}
|
{user.type === "corporate" ? `${user.corporateInformation?.companyInformation.name} |` : ""} {user.name} |{" "}
|
||||||
{USER_TYPE_LABELS[user.type]}
|
{USER_TYPE_LABELS[user.type]}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}>
|
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}>
|
||||||
<BsList className="text-mti-purple-light w-8 h-8" />
|
<BsList className="text-mti-purple-light h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ interface Props {
|
|||||||
price: number;
|
price: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
duration_unit: DurationUnit;
|
duration_unit: DurationUnit;
|
||||||
|
loadScript?: boolean;
|
||||||
setIsLoading: (isLoading: boolean) => void;
|
setIsLoading: (isLoading: boolean) => void;
|
||||||
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
|
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PayPalPayment({clientID, price, currency, duration, duration_unit, setIsLoading, onSuccess}: Props) {
|
export default function PayPalPayment({clientID, price, currency, duration, duration_unit, loadScript, setIsLoading, onSuccess}: Props) {
|
||||||
const createOrder = async (data: CreateOrderData, actions: CreateOrderActions): Promise<string> => {
|
const createOrder = async (data: CreateOrderData, actions: CreateOrderActions): Promise<string> => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ export default function PayPalPayment({clientID, price, currency, duration, dura
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return loadScript ? (
|
||||||
<PayPalScriptProvider
|
<PayPalScriptProvider
|
||||||
options={{
|
options={{
|
||||||
clientId: clientID,
|
clientId: clientID,
|
||||||
@@ -60,7 +61,17 @@ export default function PayPalPayment({clientID, price, currency, duration, dura
|
|||||||
createOrder={createOrder}
|
createOrder={createOrder}
|
||||||
onApprove={onApprove}
|
onApprove={onApprove}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
onError={onError}></PayPalButtons>
|
onError={onError}
|
||||||
|
/>
|
||||||
</PayPalScriptProvider>
|
</PayPalScriptProvider>
|
||||||
|
) : (
|
||||||
|
<PayPalButtons
|
||||||
|
className="w-full"
|
||||||
|
style={{layout: "vertical"}}
|
||||||
|
createOrder={createOrder}
|
||||||
|
onApprove={onApprove}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface Props {
|
|||||||
icon: ReactElement;
|
icon: ReactElement;
|
||||||
value: string | number;
|
value: string | number;
|
||||||
label: string;
|
label: string;
|
||||||
|
tooltip?: string;
|
||||||
}[];
|
}[];
|
||||||
children?: ReactElement;
|
children?: ReactElement;
|
||||||
}
|
}
|
||||||
@@ -48,7 +49,10 @@ export default function ProfileSummary({user, items}: Props) {
|
|||||||
<div className="flex justify-between w-full mt-8 -md:hidden">
|
<div className="flex justify-between w-full mt-8 -md:hidden">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<div className="flex gap-4 items-center" key={item.label}>
|
<div className="flex gap-4 items-center" key={item.label}>
|
||||||
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
<div
|
||||||
|
className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl relative group tooltip tooltip-bottom"
|
||||||
|
data-tip={item.tooltip}
|
||||||
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {IconType} from "react-icons";
|
import { IconType } from "react-icons";
|
||||||
import {MdSpaceDashboard} from "react-icons/md";
|
import { MdSpaceDashboard } from "react-icons/md";
|
||||||
import {
|
import {
|
||||||
BsFileEarmarkText,
|
BsFileEarmarkText,
|
||||||
BsClockHistory,
|
BsClockHistory,
|
||||||
@@ -11,18 +11,19 @@ import {
|
|||||||
BsShieldFill,
|
BsShieldFill,
|
||||||
BsCloudFill,
|
BsCloudFill,
|
||||||
BsCurrencyDollar,
|
BsCurrencyDollar,
|
||||||
|
BsClipboardData,
|
||||||
} from "react-icons/bs";
|
} from "react-icons/bs";
|
||||||
import {RiLogoutBoxFill} from "react-icons/ri";
|
import { RiLogoutBoxFill } from "react-icons/ri";
|
||||||
import {SlPencil} from "react-icons/sl";
|
import { SlPencil } from "react-icons/sl";
|
||||||
import {FaAward} from "react-icons/fa";
|
import { FaAward } from "react-icons/fa";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {useRouter} from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import FocusLayer from "@/components/FocusLayer";
|
import FocusLayer from "@/components/FocusLayer";
|
||||||
import {preventNavigation} from "@/utils/navigation.disabled";
|
import { preventNavigation } from "@/utils/navigation.disabled";
|
||||||
import {useState} from "react";
|
import { useState } from "react";
|
||||||
import usePreferencesStore from "@/stores/preferencesStore";
|
import usePreferencesStore from "@/stores/preferencesStore";
|
||||||
import {Type} from "@/interfaces/user";
|
import { Type } from "@/interfaces/user";
|
||||||
interface Props {
|
interface Props {
|
||||||
path: string;
|
path: string;
|
||||||
navDisabled?: boolean;
|
navDisabled?: boolean;
|
||||||
@@ -41,24 +42,45 @@ interface NavProps {
|
|||||||
isMinimized?: boolean;
|
isMinimized?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false}: NavProps) => (
|
const Nav = ({
|
||||||
|
Icon,
|
||||||
|
label,
|
||||||
|
path,
|
||||||
|
keyPath,
|
||||||
|
disabled = false,
|
||||||
|
isMinimized = false,
|
||||||
|
}: NavProps) => (
|
||||||
<Link
|
<Link
|
||||||
href={!disabled ? keyPath : ""}
|
href={!disabled ? keyPath : ""}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"p-4 rounded-full flex gap-4 items-center cursor-pointer text-gray-500 hover:bg-mti-purple-light hover:text-white",
|
"flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white",
|
||||||
"transition-all duration-300 ease-in-out",
|
"transition-all duration-300 ease-in-out",
|
||||||
|
disabled
|
||||||
|
? "hover:bg-mti-gray-dim cursor-not-allowed"
|
||||||
|
: "hover:bg-mti-purple-light cursor-pointer",
|
||||||
path === keyPath && "bg-mti-purple-light text-white",
|
path === keyPath && "bg-mti-purple-light text-white",
|
||||||
isMinimized ? "w-fit" : "w-full min-w-[200px] 2xl:min-w-[220px] px-8",
|
isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]",
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
<Icon size={24} />
|
<Icon size={24} />
|
||||||
{!isMinimized && <span className="text-lg font-semibold">{label}</span>}
|
{!isMinimized && <span className="text-lg font-semibold">{label}</span>}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function Sidebar({path, navDisabled = false, focusMode = false, userType, onFocusLayerMouseEnter, className}: Props) {
|
export default function Sidebar({
|
||||||
|
path,
|
||||||
|
navDisabled = false,
|
||||||
|
focusMode = false,
|
||||||
|
userType,
|
||||||
|
onFocusLayerMouseEnter,
|
||||||
|
className,
|
||||||
|
}: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
|
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [
|
||||||
|
state.isSidebarMinimized,
|
||||||
|
state.toggleSidebarMinimized,
|
||||||
|
]);
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
axios.post("/api/logout").finally(() => {
|
axios.post("/api/logout").finally(() => {
|
||||||
@@ -71,13 +93,23 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"h-full flex bg-transparent flex-col justify-between px-4 py-4 pb-8 relative",
|
"relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8",
|
||||||
isMinimized ? "w-fit" : "w-1/6 -xl:w-fit",
|
isMinimized ? "w-fit" : "-xl:w-fit w-1/6",
|
||||||
className,
|
className,
|
||||||
)}>
|
)}
|
||||||
<div className="xl:flex -xl:hidden flex-col gap-3">
|
>
|
||||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
|
<div className="-xl:hidden flex-col gap-3 xl:flex">
|
||||||
{(userType === "student" || userType === "teacher" || userType === "developer") && (
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={MdSpaceDashboard}
|
||||||
|
label="Dashboard"
|
||||||
|
path={path}
|
||||||
|
keyPath="/"
|
||||||
|
isMinimized={isMinimized}
|
||||||
|
/>
|
||||||
|
{(userType === "student" ||
|
||||||
|
userType === "teacher" ||
|
||||||
|
userType === "developer") && (
|
||||||
<>
|
<>
|
||||||
<Nav
|
<Nav
|
||||||
disabled={disableNavigation}
|
disabled={disableNavigation}
|
||||||
@@ -97,9 +129,25 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
|
<Nav
|
||||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
|
disabled={disableNavigation}
|
||||||
{["admin", "developer", "agent", "corporate"].includes(userType || "") && (
|
Icon={BsGraphUp}
|
||||||
|
label="Stats"
|
||||||
|
path={path}
|
||||||
|
keyPath="/stats"
|
||||||
|
isMinimized={isMinimized}
|
||||||
|
/>
|
||||||
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={BsClockHistory}
|
||||||
|
label="Record"
|
||||||
|
path={path}
|
||||||
|
keyPath="/record"
|
||||||
|
isMinimized={isMinimized}
|
||||||
|
/>
|
||||||
|
{["admin", "developer", "agent", "corporate"].includes(
|
||||||
|
userType || "",
|
||||||
|
) && (
|
||||||
<Nav
|
<Nav
|
||||||
disabled={disableNavigation}
|
disabled={disableNavigation}
|
||||||
Icon={BsCurrencyDollar}
|
Icon={BsCurrencyDollar}
|
||||||
@@ -109,7 +157,9 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
isMinimized={isMinimized}
|
isMinimized={isMinimized}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{["admin", "developer", "corporate", "teacher"].includes(userType || "") && (
|
{["admin", "developer", "corporate", "teacher"].includes(
|
||||||
|
userType || "",
|
||||||
|
) && (
|
||||||
<Nav
|
<Nav
|
||||||
disabled={disableNavigation}
|
disabled={disableNavigation}
|
||||||
Icon={BsShieldFill}
|
Icon={BsShieldFill}
|
||||||
@@ -119,6 +169,16 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
isMinimized={isMinimized}
|
isMinimized={isMinimized}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{["admin", "developer", "agent"].includes(userType || "") && (
|
||||||
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={BsClipboardData}
|
||||||
|
label="Tickets"
|
||||||
|
path={path}
|
||||||
|
keyPath="/tickets"
|
||||||
|
isMinimized={isMinimized}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{userType === "developer" && (
|
{userType === "developer" && (
|
||||||
<Nav
|
<Nav
|
||||||
disabled={disableNavigation}
|
disabled={disableNavigation}
|
||||||
@@ -130,45 +190,106 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="xl:hidden -xl:flex flex-col gap-3">
|
<div className="-xl:flex flex-col gap-3 xl:hidden">
|
||||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} />
|
<Nav
|
||||||
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} />
|
disabled={disableNavigation}
|
||||||
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
|
Icon={MdSpaceDashboard}
|
||||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
|
label="Dashboard"
|
||||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
|
path={path}
|
||||||
|
keyPath="/"
|
||||||
|
isMinimized={true}
|
||||||
|
/>
|
||||||
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={BsFileEarmarkText}
|
||||||
|
label="Exams"
|
||||||
|
path={path}
|
||||||
|
keyPath="/exam"
|
||||||
|
isMinimized={true}
|
||||||
|
/>
|
||||||
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={BsPencil}
|
||||||
|
label="Exercises"
|
||||||
|
path={path}
|
||||||
|
keyPath="/exercises"
|
||||||
|
isMinimized={true}
|
||||||
|
/>
|
||||||
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={BsGraphUp}
|
||||||
|
label="Stats"
|
||||||
|
path={path}
|
||||||
|
keyPath="/stats"
|
||||||
|
isMinimized={true}
|
||||||
|
/>
|
||||||
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={BsClockHistory}
|
||||||
|
label="Record"
|
||||||
|
path={path}
|
||||||
|
keyPath="/record"
|
||||||
|
isMinimized={true}
|
||||||
|
/>
|
||||||
{userType !== "student" && (
|
{userType !== "student" && (
|
||||||
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized={true} />
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={BsShieldFill}
|
||||||
|
label="Settings"
|
||||||
|
path={path}
|
||||||
|
keyPath="/settings"
|
||||||
|
isMinimized={true}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{userType === "developer" && (
|
{userType === "developer" && (
|
||||||
<Nav disabled={disableNavigation} Icon={BsCloudFill} label="Generation" path={path} keyPath="/generation" isMinimized={true} />
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={BsCloudFill}
|
||||||
|
label="Generation"
|
||||||
|
path={path}
|
||||||
|
keyPath="/generation"
|
||||||
|
isMinimized={true}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-0 bottom-12 fixed">
|
<div className="fixed bottom-12 flex flex-col gap-0">
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={1}
|
tabIndex={1}
|
||||||
onClick={toggleMinimize}
|
onClick={toggleMinimize}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"p-4 rounded-full flex gap-4 items-center cursor-pointer text-black hover:text-mti-rose -xl:hidden transition duration-300 ease-in-out",
|
"hover:text-mti-rose -xl:hidden flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
|
||||||
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
||||||
)}>
|
)}
|
||||||
{isMinimized ? <BsChevronBarRight size={24} /> : <BsChevronBarLeft size={24} />}
|
>
|
||||||
{!isMinimized && <span className="text-lg font-medium">Minimize</span>}
|
{isMinimized ? (
|
||||||
|
<BsChevronBarRight size={24} />
|
||||||
|
) : (
|
||||||
|
<BsChevronBarLeft size={24} />
|
||||||
|
)}
|
||||||
|
{!isMinimized && (
|
||||||
|
<span className="text-lg font-medium">Minimize</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={1}
|
tabIndex={1}
|
||||||
onClick={focusMode ? () => {} : logout}
|
onClick={focusMode ? () => {} : logout}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"p-4 rounded-full flex gap-4 items-center cursor-pointer text-black hover:text-mti-rose transition duration-300 ease-in-out",
|
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
|
||||||
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
<RiLogoutBoxFill size={24} />
|
<RiLogoutBoxFill size={24} />
|
||||||
{!isMinimized && <span className="text-lg font-medium -xl:hidden">Log Out</span>}
|
{!isMinimized && (
|
||||||
|
<span className="-xl:hidden text-lg font-medium">Log Out</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
{focusMode && (
|
||||||
|
<FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import axios from "axios";
|
|||||||
import {speakingReverseMarking} from "@/utils/score";
|
import {speakingReverseMarking} from "@/utils/score";
|
||||||
import {Tab} from "@headlessui/react";
|
import {Tab} from "@headlessui/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
|
||||||
|
|
||||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||||
|
|
||||||
@@ -22,9 +24,10 @@ export default function InteractiveSpeaking({
|
|||||||
onBack,
|
onBack,
|
||||||
}: InteractiveSpeakingExercise & CommonProps) {
|
}: InteractiveSpeakingExercise & CommonProps) {
|
||||||
const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
|
const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
|
||||||
|
const [diffNumber, setDiffNumber] = useState<0 | 1 | 2 | 3>(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userSolutions && userSolutions.length > 0) {
|
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
|
||||||
Promise.all(userSolutions[0].solution.map((x) => axios.post(`/api/speaking`, {path: x.answer}, {responseType: "arraybuffer"}))).then(
|
Promise.all(userSolutions[0].solution.map((x) => axios.post(`/api/speaking`, {path: x.answer}, {responseType: "arraybuffer"}))).then(
|
||||||
(values) => {
|
(values) => {
|
||||||
setSolutionsURL(
|
setSolutionsURL(
|
||||||
@@ -42,6 +45,44 @@ export default function InteractiveSpeaking({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Modal title={`Correction (Prompt ${diffNumber})`} isOpen={diffNumber !== 0} onClose={() => setDiffNumber(0)}>
|
||||||
|
<>
|
||||||
|
{userSolutions &&
|
||||||
|
userSolutions.length > 0 &&
|
||||||
|
diffNumber !== 0 &&
|
||||||
|
userSolutions[0].evaluation &&
|
||||||
|
userSolutions[0].evaluation[`transcript_${diffNumber}`] &&
|
||||||
|
userSolutions[0].evaluation[`fixed_text_${diffNumber}`] && (
|
||||||
|
<div className="w-full h-full rounded-xl overflow-hidden flex flex-col mt-4">
|
||||||
|
<div className="w-full grid grid-cols-2 bg-neutral-100">
|
||||||
|
<span className="p-3 font-medium text-lg border-r border-r-neutral-200">Transcript</span>
|
||||||
|
<span className="p-3 font-medium text-lg border-l border-l-neutral-200">Recommended Improvements</span>
|
||||||
|
</div>
|
||||||
|
<ReactDiffViewer
|
||||||
|
styles={{
|
||||||
|
contentText: {
|
||||||
|
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
|
||||||
|
padding: "32px 28px",
|
||||||
|
},
|
||||||
|
marker: {display: "none"},
|
||||||
|
diffRemoved: {padding: "32px 28px"},
|
||||||
|
diffAdded: {padding: "32px 28px"},
|
||||||
|
|
||||||
|
wordRemoved: {padding: "0px", display: "initial"},
|
||||||
|
wordAdded: {padding: "0px", display: "initial"},
|
||||||
|
wordDiff: {padding: "0px", display: "initial"},
|
||||||
|
}}
|
||||||
|
oldValue={userSolutions[0].evaluation[`transcript_${diffNumber}`]?.replaceAll("\\n", "\n")}
|
||||||
|
newValue={userSolutions[0].evaluation[`fixed_text_${diffNumber}`]?.replaceAll("\\n", "\n")}
|
||||||
|
splitView
|
||||||
|
hideLineNumbers
|
||||||
|
showDiffOnly={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<div className="flex flex-col h-full w-full gap-8 mb-20">
|
<div className="flex flex-col h-full w-full gap-8 mb-20">
|
||||||
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
@@ -67,10 +108,23 @@ export default function InteractiveSpeaking({
|
|||||||
{solutionsURL.map((x, index) => (
|
{solutionsURL.map((x, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex flex-col gap-4">
|
||||||
<div className="flex gap-8 items-center justify-center py-8">
|
<div className="flex gap-8 items-center justify-center py-8">
|
||||||
<Waveform audio={x} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
<Waveform audio={x} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||||
</div>
|
</div>
|
||||||
|
{userSolutions &&
|
||||||
|
userSolutions.length > 0 &&
|
||||||
|
userSolutions[0].evaluation &&
|
||||||
|
userSolutions[0].evaluation[`transcript_${(index + 1) as 1 | 2 | 3}`] &&
|
||||||
|
userSolutions[0].evaluation[`fixed_text_${(index + 1) as 1 | 2 | 3}`] && (
|
||||||
|
<Button
|
||||||
|
className="w-full max-w-[180px] !py-2 self-center"
|
||||||
|
color="pink"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDiffNumber((index + 1) as 1 | 2 | 3)}>
|
||||||
|
View Correction
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -185,7 +239,11 @@ export default function InteractiveSpeaking({
|
|||||||
onNext({
|
onNext({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: userSolutions,
|
solutions: userSolutions,
|
||||||
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
score: {
|
||||||
|
total: 100,
|
||||||
|
missing: 0,
|
||||||
|
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
|
||||||
|
},
|
||||||
type,
|
type,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,18 @@ import axios from "axios";
|
|||||||
import {speakingReverseMarking} from "@/utils/score";
|
import {speakingReverseMarking} from "@/utils/score";
|
||||||
import {Tab} from "@headlessui/react";
|
import {Tab} from "@headlessui/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
import {BsQuestionCircleFill} from "react-icons/bs";
|
||||||
|
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
|
||||||
|
|
||||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||||
|
|
||||||
export default function Speaking({id, type, title, video_url, text, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
|
export default function Speaking({id, type, title, video_url, text, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
|
||||||
const [solutionURL, setSolutionURL] = useState<string>();
|
const [solutionURL, setSolutionURL] = useState<string>();
|
||||||
|
const [showDiff, setShowDiff] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userSolutions && userSolutions.length > 0) {
|
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
|
||||||
axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => {
|
axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => {
|
||||||
const blob = new Blob([data], {type: "audio/wav"});
|
const blob = new Blob([data], {type: "audio/wav"});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -27,6 +31,42 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Modal title="Correction" isOpen={showDiff} onClose={() => setShowDiff(false)}>
|
||||||
|
<>
|
||||||
|
{userSolutions &&
|
||||||
|
userSolutions.length > 0 &&
|
||||||
|
userSolutions[0].evaluation?.transcript_1 &&
|
||||||
|
userSolutions[0].evaluation?.fixed_text_1 && (
|
||||||
|
<div className="w-full h-full rounded-xl overflow-hidden flex flex-col mt-4">
|
||||||
|
<div className="w-full grid grid-cols-2 bg-neutral-100">
|
||||||
|
<span className="p-3 font-medium text-lg border-r border-r-neutral-200">Transcript</span>
|
||||||
|
<span className="p-3 font-medium text-lg border-l border-l-neutral-200">Recommended Improvements</span>
|
||||||
|
</div>
|
||||||
|
<ReactDiffViewer
|
||||||
|
styles={{
|
||||||
|
contentText: {
|
||||||
|
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
|
||||||
|
padding: "32px 28px",
|
||||||
|
},
|
||||||
|
marker: {display: "none"},
|
||||||
|
diffRemoved: {padding: "32px 28px"},
|
||||||
|
diffAdded: {padding: "32px 28px"},
|
||||||
|
|
||||||
|
wordRemoved: {padding: "0px", display: "initial"},
|
||||||
|
wordAdded: {padding: "0px", display: "initial"},
|
||||||
|
wordDiff: {padding: "0px", display: "initial"},
|
||||||
|
}}
|
||||||
|
oldValue={userSolutions[0].evaluation.transcript_1.replaceAll("\\n", "\n")}
|
||||||
|
newValue={userSolutions[0].evaluation.fixed_text_1.replaceAll("\\n", "\n")}
|
||||||
|
splitView
|
||||||
|
hideLineNumbers
|
||||||
|
showDiffOnly={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<div className="flex flex-col h-full w-full gap-8 mb-20">
|
<div className="flex flex-col h-full w-full gap-8 mb-20">
|
||||||
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
|
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
@@ -65,10 +105,19 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full h-full flex flex-col gap-8">
|
<div className="w-full h-full flex flex-col gap-8 relative">
|
||||||
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center relative">
|
||||||
<div className="flex gap-8 items-center justify-center py-8">
|
<div className="flex gap-8 items-center justify-center py-8">
|
||||||
{solutionURL && <Waveform audio={solutionURL} waveColor="#FCDDEC" progressColor="#EF5DA8" />}
|
{solutionURL && <Waveform audio={solutionURL} waveColor="#FCDDEC" progressColor="#EF5DA8" />}
|
||||||
|
|
||||||
|
{userSolutions &&
|
||||||
|
userSolutions.length > 0 &&
|
||||||
|
userSolutions[0].evaluation?.transcript_1 &&
|
||||||
|
userSolutions[0].evaluation?.fixed_text_1 && (
|
||||||
|
<Button className="w-full max-w-[180px] !py-2" color="pink" variant="outline" onClick={() => setShowDiff(true)}>
|
||||||
|
View Correction
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||||
@@ -152,7 +201,11 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
onNext({
|
onNext({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: userSolutions,
|
solutions: userSolutions,
|
||||||
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
score: {
|
||||||
|
total: 100,
|
||||||
|
missing: 0,
|
||||||
|
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
|
||||||
|
},
|
||||||
type,
|
type,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{showDiff && (
|
{showDiff && userSolutions[0].evaluation && (
|
||||||
<>
|
<>
|
||||||
<span>Correction:</span>
|
<span>Correction:</span>
|
||||||
<div className="w-full h-full max-h-[320px] overflow-y-scroll scrollbar-hide cursor-text border-2 overflow-x-hidden border-mti-gray-platinum bg-white rounded-3xl">
|
<div className="w-full h-full max-h-[320px] overflow-y-scroll scrollbar-hide cursor-text border-2 overflow-x-hidden border-mti-gray-platinum bg-white rounded-3xl">
|
||||||
@@ -191,7 +191,11 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
onNext({
|
onNext({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: userSolutions,
|
solutions: userSolutions,
|
||||||
score: {total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
score: {
|
||||||
|
total: 100,
|
||||||
|
missing: 0,
|
||||||
|
correct: userSolutions[0]?.evaluation ? writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
|
||||||
|
},
|
||||||
type,
|
type,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,7 +243,9 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
options={CURRENCIES_OPTIONS}
|
options={CURRENCIES_OPTIONS}
|
||||||
value={CURRENCIES_OPTIONS.find((c) => c.value === paymentCurrency)}
|
value={CURRENCIES_OPTIONS.find((c) => c.value === paymentCurrency)}
|
||||||
onChange={(value) => setPaymentCurrency(value?.value)}
|
onChange={(value) => setPaymentCurrency(value?.value)}
|
||||||
|
menuPortalTarget={document?.body}
|
||||||
styles={{
|
styles={{
|
||||||
|
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||||
control: (styles) => ({
|
control: (styles) => ({
|
||||||
...styles,
|
...styles,
|
||||||
paddingLeft: "4px",
|
paddingLeft: "4px",
|
||||||
@@ -282,8 +284,10 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
value: referralAgent,
|
value: referralAgent,
|
||||||
label: referralAgentLabel,
|
label: referralAgentLabel,
|
||||||
}}
|
}}
|
||||||
|
menuPortalTarget={document?.body}
|
||||||
onChange={(value) => setReferralAgent(value?.value)}
|
onChange={(value) => setReferralAgent(value?.value)}
|
||||||
styles={{
|
styles={{
|
||||||
|
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||||
control: (styles) => ({
|
control: (styles) => ({
|
||||||
...styles,
|
...styles,
|
||||||
paddingLeft: "4px",
|
paddingLeft: "4px",
|
||||||
@@ -314,7 +318,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
type="number"
|
type="number"
|
||||||
defaultValue={commissionValue || 0}
|
defaultValue={commissionValue || 0}
|
||||||
className="col-span-3"
|
className="col-span-3"
|
||||||
disabled={disabled}
|
disabled={disabled || loggedInUser.type === "agent"}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -520,6 +524,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
<Select
|
<Select
|
||||||
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
options={USER_STATUS_OPTIONS}
|
options={USER_STATUS_OPTIONS}
|
||||||
|
menuPortalTarget={document?.body}
|
||||||
value={USER_STATUS_OPTIONS.find((o) => o.value === status)}
|
value={USER_STATUS_OPTIONS.find((o) => o.value === status)}
|
||||||
onChange={(value) => setStatus(value?.value as typeof user.status)}
|
onChange={(value) => setStatus(value?.value as typeof user.status)}
|
||||||
styles={{
|
styles={{
|
||||||
@@ -532,6 +537,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
outline: "none",
|
outline: "none",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||||
option: (styles, state) => ({
|
option: (styles, state) => ({
|
||||||
...styles,
|
...styles,
|
||||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||||
@@ -546,6 +552,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
<Select
|
<Select
|
||||||
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
options={USER_TYPE_OPTIONS}
|
options={USER_TYPE_OPTIONS}
|
||||||
|
menuPortalTarget={document?.body}
|
||||||
value={USER_TYPE_OPTIONS.find((o) => o.value === type)}
|
value={USER_TYPE_OPTIONS.find((o) => o.value === type)}
|
||||||
onChange={(value) => setType(value?.value as typeof user.type)}
|
onChange={(value) => setType(value?.value as typeof user.type)}
|
||||||
styles={{
|
styles={{
|
||||||
@@ -558,6 +565,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
outline: "none",
|
outline: "none",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||||
option: (styles, state) => ({
|
option: (styles, state) => ({
|
||||||
...styles,
|
...styles,
|
||||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||||
@@ -574,17 +582,17 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
|
|
||||||
<div className="flex gap-4 justify-between mt-4 w-full">
|
<div className="flex gap-4 justify-between mt-4 w-full">
|
||||||
<div className="self-start flex gap-4 justify-start items-center w-full">
|
<div className="self-start flex gap-4 justify-start items-center w-full">
|
||||||
{onViewCorporate && (
|
{onViewCorporate && ["student", "teacher"].includes(user.type) && (
|
||||||
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewCorporate}>
|
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewCorporate}>
|
||||||
View Corporate
|
View Corporate
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onViewStudents && (
|
{onViewStudents && ["corporate", "teacher"].includes(user.type) && (
|
||||||
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewStudents}>
|
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewStudents}>
|
||||||
View Students
|
View Students
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onViewTeachers && (
|
{onViewTeachers && ["student", "corporate"].includes(user.type) && (
|
||||||
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewTeachers}>
|
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewTeachers}>
|
||||||
View Teachers
|
View Teachers
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"type": "service_account",
|
"type": "service_account",
|
||||||
"project_id": "mti-ielts",
|
"project_id": "storied-phalanx-349916",
|
||||||
"private_key_id": "22b783a14c760d1215a8d1f5de0fa40a33a840e7",
|
"private_key_id": "c9e05f6fe413b1031a71f981160075ff4b044444",
|
||||||
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDoNkd7s/izUBRb\nlmJYWl0xk4X9wEVJU4LKA4HPeha8RFDse4T4suVP08oCP9ODSXF5A83+IqXNMs/N\na7PtFABBAx433JrB7I4NsAUrDSjI4LeYEIqh6YzHsQvBU53HAmPChX525S4i0IBy\ncNnyXut0nmlHz5ZwCPXgqg4eN44C+m0f7sxzivcnPth/zLupnMiDAHFZrxQolWO2\n6JfozMWGw0TmCkUxngzeGBMVYmsGiKRIxEi3MWeuwjYjGO4nR1krEUlcpjCbx4UX\nxYXicJb17HOs9LTcSh9bpDWZPHKXR48hxd2cMLr+XQzw7Otwu2p8fEUOJ+CiTyNz\nlkN9p7OhAgMBAAECggEAB5DsMZdGu1X4wdazr+AK4RCG2UKkZ0wbqvgkCMX4O2xo\n7BmmtqFCmEAk+P+KJWEVW81wTu9jUl0tWOrBVzBThUrEF2seVkL+SmshsfpI6cmr\npb5lO/sTgZau1L7kGU3GQRpvKVHUl+EODFyJt2xZFOjL8qFsjAw4sbgsw1aJT6a4\nFilm6Gapi1qSKOPSlXVmi0NJ9DUtNbKaQK8/coqEJRizeXs9MORvzyKQaV8PBmWI\noEnkxahKOD48U2kmI7rT9/YsCuaP2BlGdLxvANXLjAKcrDccVZkYEH82tPtCicED\noow3i956HPdWSXQgUOU65MfGccjOmqGaGa4zUTICyQKBgQD6zLMwL9YS+n9EKZaK\nEbzRybN2d+eKbXyDJzkDi6FnSGVre2ndShsimoOtwZDLmOF/XhN79YOLJVbI124p\npAWO+WxAfe9Xy3iFEBmL4kSREA873Sd8EN5OfYS2DsN7IbjZkoaLuM8QlyXL9ZRS\nBJDVGjx+wFKRjnClcBNbVMMXiQKBgQDtBumKZS0ZCtJuBeuwLGJ1ZJtYECykIrsD\nUtQ7zxwXJzPGqZ2c5JLpHdDm/bb9nllpLsh4SpDRqxFa2H2FF8x5KWaS7JQUsS8e\ner6x5wUt6wAJqV/ZvttVrLZCa8VYn+K7bTANnkPNJZHTqBTJbxkXMDTtkwWXUN2z\nQP3N9lodWQKBgFBHiewYw9ubV3WIImnbt6cne0ymoPUMitioi3V5Epcu81fuTzrI\nZ9sxvoi19xVUwIm2oWICerLlptvvKZImsKjNajtSlHRz6wYc2zCNowkULOwqpGLw\nO1jAkOR94VDewH7UikDbTVywJSceWvXOBFZSaZ7hDQ0OnTw3ndqUTUaRAoGAd2BG\n2PPyDa28o7sJpBYGlJdSAb1LrnLre1YJHAJIZITS99hPUEhykUP6BYx80CkjYO01\n/BeZ7m9Y80cbmJ+O1Or8BT1vqyg90f0B8/mlSyYTQ8pxQupz7ydoN/WtU+BawgjQ\n7drqzPSCCHab2YPBwEMANTMZ2sbYkcJG0aekZSkCgYBbnFJm8kUy57isxHyvrci+\nR30KQl2Y9okPytF8PpLH+yNjLDoduTOHL/hZoFC0M4Gklx4wPKpsEhImIrWmG9VC\n0UrQC6TT1WoY6/S3YehVmTXo/nBPD1XTUcbF/xxUrWDjmMjnt1IlXBbIzUPD3U4P\niRXzHnXb7yi+/iRxSDts2w==\n-----END PRIVATE KEY-----\n",
|
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDdgavFB63nMHyb\n38ncwijTrUmqU9UyzNJ8wlZCWAWuoz25Gng988fkKNDXnHY+ap9esHyNYg9IdSA7\nAuZeHpzTZmKiWZzFWq61KWSTgIn1JwKHGHJJdmVhTYfCe9I51cFLa5q2lTFzJ0ce\nbP7/X/7kw53odgva+M8AhDTbe60akpemgZc+LFwO0Abm7erH2HiNyjoNZzNw525L\n933PCaQwhZan04s1u0oRdVlBIBwMk+J0ojgVEpUiJOzF7gkN+UpDXujalLYdlR4q\nhkGgScXQhDYJkECC3GuvOnEo1YXGNjW9D73S6sSH+Lvqta4wW1+sTn0kB6goiQBI\n7cA1G6x3AgMBAAECggEAZPMwAX/adb7XS4LWUNH8IVyccg/63kgSteErxtiu3kRv\nYOj7W+C6fPVNGLap/RBCybjNSvIh3PfkVICh1MtG1eGXmj4VAKyvaskOmVq/hQbe\nVAuEKo7W7V2UPcKIsOsGSQUlYYjlHIIOG4O5Q1HQrRmp4cPK62Txkl6uaEkZPz4u\nbvIK2BJI8aHRwxE3Phw09blwlLqQQQ8nrhK29x5puaN+ft++IlzIOVsLz+n4kTdB\n6qkG/dhenn3K8o3+NkmSN6eNRbdJd36zXTo4Oatbvqb7r0E8vYn/3Llawo2X75zn\nec7jMHrOmcwtiu9H3PsrTWtzdSjxPHy0UtEn1HWK4QKBgQD+c/V8tAvbaUGVoZf6\ntKtDSKF6IHuY2vUO33v950mVdjrTursqOG2d+SLfSnKpc+sjDlj7/S5u4uRP+qUN\ng1rb2U7oIA7tsDa2ZTSkIx6HkPUzS+fBOxELLrbgMoJ2RLzgkiPhS95YgXJ/rYG5\nWQTehzCT5roes0RvtgM0gl3EhQKBgQDe2m7PRIU4g3RJ8HTx92B4ja8W9FVCYDG5\nPOAdZB8WB6Bvu4BJHBDLr8vDi930pKj+vYObRqBDQuILW4t8wZQJ834dnoq6EpUz\nhbVEURVBP4A/nEHrQHfq0Lp+cxThy2rw7obRQOLPETtC7p3WFgSHT6PRTcpGzCCX\n+76a30yrywKBgC/5JNtyBppDaf4QDVtTHMb+tpMT9LmI7pLzR6lDJfhr5gNtPURk\nhyY1hoGaw6t3E2n0lopL3alCVdFObDfz//lbKylQggAGLQqOYjJf/K2KgvA862Df\nBgOZtxjl7PrnUsT0SJd9elotbazsxXxwcB6UVnBMG+MV4V0+b7RCr/MRAoGBAIfp\nTcVIs7roqOZjKN9dEE/VkR/9uXW2tvyS/NfP9Ql5c0ZRYwazgCbJOwsyZRZLyek6\naWYsp5b91mA435QhdwiuoI6t30tmA+qdNBTLIpxdfvjMcoNoGPpzfBmcU/L1HW58\n+mnqGalRiAPlBQvI99ASKQWAXMnaulIWrYNEhj0LAoGBALi+QZ2pp+hDeC59ezWr\nbP1zbbONceHKGgJcevChP2k1OJyIOIqmBYeTuM4cPc5ofZYQNaMC31cs8SVeSRX1\nNTxQZmvCjMyTe/WYWYNFXdgkVz4egFXbeochCGzMYo57HV1PCkPBrARRZO8OfdDD\n8sDu//ohb7nCzceEI0DnWs13\n-----END PRIVATE KEY-----\n",
|
||||||
"client_email": "firebase-adminsdk-dyg6p@mti-ielts.iam.gserviceaccount.com",
|
"client_email": "firebase-adminsdk-3ml0u@storied-phalanx-349916.iam.gserviceaccount.com",
|
||||||
"client_id": "104980563453519094431",
|
"client_id": "114163760341944984396",
|
||||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||||
"token_uri": "https://oauth2.googleapis.com/token",
|
"token_uri": "https://oauth2.googleapis.com/token",
|
||||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||||
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-dyg6p%40mti-ielts.iam.gserviceaccount.com",
|
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-3ml0u%40storied-phalanx-349916.iam.gserviceaccount.com",
|
||||||
"universe_domain": "googleapis.com"
|
"universe_domain": "googleapis.com"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const PERMISSIONS = {
|
|||||||
developer: ["developer"],
|
developer: ["developer"],
|
||||||
},
|
},
|
||||||
deleteUser: {
|
deleteUser: {
|
||||||
student: ["teacher", "corporate", "developer", "admin"],
|
student: ["corporate", "developer", "admin"],
|
||||||
teacher: ["corporate", "developer", "admin"],
|
teacher: ["corporate", "developer", "admin"],
|
||||||
corporate: ["admin", "developer"],
|
corporate: ["admin", "developer"],
|
||||||
admin: ["developer", "admin"],
|
admin: ["developer", "admin"],
|
||||||
@@ -18,8 +18,8 @@ export const PERMISSIONS = {
|
|||||||
developer: ["developer"],
|
developer: ["developer"],
|
||||||
},
|
},
|
||||||
updateUser: {
|
updateUser: {
|
||||||
student: ["teacher", "corporate", "developer", "admin"],
|
student: ["developer", "admin"],
|
||||||
teacher: ["corporate", "developer", "admin"],
|
teacher: ["developer", "admin"],
|
||||||
corporate: ["admin", "developer"],
|
corporate: ["admin", "developer"],
|
||||||
admin: ["developer", "admin"],
|
admin: ["developer", "admin"],
|
||||||
agent: ["developer", "admin"],
|
agent: ["developer", "admin"],
|
||||||
|
|||||||
@@ -7,13 +7,22 @@ import UserList from "@/pages/(admin)/Lists/UserList";
|
|||||||
import {dateSorter} from "@/utils";
|
import {dateSorter} from "@/utils";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {BsArrowLeft, BsBriefcaseFill, BsGlobeCentralSouthAsia, BsPerson, BsPersonFill, BsPencilSquare, BsBank, BsCurrencyDollar} from "react-icons/bs";
|
import {
|
||||||
|
BsArrowLeft,
|
||||||
|
BsBriefcaseFill,
|
||||||
|
BsGlobeCentralSouthAsia,
|
||||||
|
BsPerson,
|
||||||
|
BsPersonFill,
|
||||||
|
BsPencilSquare,
|
||||||
|
BsBank,
|
||||||
|
BsCurrencyDollar,
|
||||||
|
} from "react-icons/bs";
|
||||||
import UserCard from "@/components/UserCard";
|
import UserCard from "@/components/UserCard";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
import IconCard from "./IconCard";
|
import IconCard from "./IconCard";
|
||||||
import useFilterStore from "@/stores/listFilterStore";
|
import useFilterStore from "@/stores/listFilterStore";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import usePaymentStatusUsers from '@/hooks/usePaymentStatusUsers';
|
import usePaymentStatusUsers from "@/hooks/usePaymentStatusUsers";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -27,7 +36,7 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
const {stats} = useStats(user.id);
|
const {stats} = useStats(user.id);
|
||||||
const {users, reload} = useUsers();
|
const {users, reload} = useUsers();
|
||||||
const {groups} = useGroups();
|
const {groups} = useGroups();
|
||||||
const { pending, done } = usePaymentStatusUsers();
|
const {pending, done} = usePaymentStatusUsers();
|
||||||
|
|
||||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -148,7 +157,7 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const CorporatePaidStatusList = ({ paid }: {paid: Boolean}) => {
|
const CorporatePaidStatusList = ({paid}: {paid: Boolean}) => {
|
||||||
const list = paid ? done : pending;
|
const list = paid ? done : pending;
|
||||||
const filter = (x: User) => x.type === "corporate" && list.includes(x.id);
|
const filter = (x: User) => x.type === "corporate" && list.includes(x.id);
|
||||||
|
|
||||||
@@ -161,7 +170,9 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
<BsArrowLeft className="text-xl" />
|
<BsArrowLeft className="text-xl" />
|
||||||
<span>Back</span>
|
<span>Back</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-semibold">{paid ? 'Payment Done' : 'Pending Payment'} ({list.length})</h2>
|
<h2 className="text-2xl font-semibold">
|
||||||
|
{paid ? "Payment Done" : "Pending Payment"} ({list.length})
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<UserList user={user} filters={[filter]} />
|
<UserList user={user} filters={[filter]} />
|
||||||
</>
|
</>
|
||||||
@@ -290,13 +301,7 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
}
|
}
|
||||||
color="rose"
|
color="rose"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard onClick={() => setPage("paymentdone")} Icon={BsCurrencyDollar} label="Payment Done" value={done.length} color="purple" />
|
||||||
onClick={() => setPage("paymentdone")}
|
|
||||||
Icon={BsCurrencyDollar}
|
|
||||||
label="Payment Done"
|
|
||||||
value={done.length}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("paymentpending")}
|
onClick={() => setPage("paymentpending")}
|
||||||
Icon={BsCurrencyDollar}
|
Icon={BsCurrencyDollar}
|
||||||
@@ -323,7 +328,9 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{users
|
||||||
.filter((x) => x.type === "corporate")
|
.filter((x) => x.type === "corporate")
|
||||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
.sort((a, b) => {
|
||||||
|
return dateSorter(a, b, "desc", "registrationDate");
|
||||||
|
})
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} {...x} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,75 +1,111 @@
|
|||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {Module} from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import {Assignment} from "@/interfaces/results";
|
import { Assignment } from "@/interfaces/results";
|
||||||
import {calculateBandScore} from "@/utils/score";
|
import { calculateBandScore } from "@/utils/score";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
import {
|
||||||
|
BsBook,
|
||||||
|
BsClipboard,
|
||||||
|
BsHeadphones,
|
||||||
|
BsMegaphone,
|
||||||
|
BsPen,
|
||||||
|
} from "react-icons/bs";
|
||||||
import { usePDFDownload } from "@/hooks/usePDFDownload";
|
import { usePDFDownload } from "@/hooks/usePDFDownload";
|
||||||
|
import { uniqBy } from "lodash";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
allowDownload?: boolean;
|
allowDownload?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AssignmentCard({id, name, assigner, startDate, endDate, assignees, results, exams, onClick, allowDownload}: Assignment & Props) {
|
export default function AssignmentCard({
|
||||||
const {users} = useUsers();
|
id,
|
||||||
|
name,
|
||||||
|
assigner,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
assignees,
|
||||||
|
results,
|
||||||
|
exams,
|
||||||
|
onClick,
|
||||||
|
allowDownload,
|
||||||
|
}: Assignment & Props) {
|
||||||
|
const { users } = useUsers();
|
||||||
const renderPdfIcon = usePDFDownload("assignments");
|
const renderPdfIcon = usePDFDownload("assignments");
|
||||||
|
|
||||||
const calculateAverageModuleScore = (module: Module) => {
|
const calculateAverageModuleScore = (module: Module) => {
|
||||||
const resultModuleBandScores = results.map((r) => {
|
const resultModuleBandScores = results.map((r) => {
|
||||||
const moduleStats = r.stats.filter((s) => s.module === module);
|
const moduleStats = r.stats.filter((s) => s.module === module);
|
||||||
|
|
||||||
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
|
const correct = moduleStats.reduce(
|
||||||
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
|
(acc, curr) => acc + curr.score.correct,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const total = moduleStats.reduce(
|
||||||
|
(acc, curr) => acc + curr.score.total,
|
||||||
|
0,
|
||||||
|
);
|
||||||
return calculateBandScore(correct, total, module, r.type);
|
return calculateBandScore(correct, total, module, r.type);
|
||||||
});
|
});
|
||||||
|
|
||||||
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / results.length;
|
return resultModuleBandScores.length === 0
|
||||||
|
? -1
|
||||||
|
: resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
|
||||||
|
results.length;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="w-[350px] h-fit flex flex-col gap-6 bg-white border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
|
className="border-mti-gray-platinum flex h-fit w-[350px] cursor-pointer flex-col gap-6 rounded-xl border bg-white p-4 transition duration-300 ease-in-out hover:drop-shadow"
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex flex-row justify-between">
|
<div className="flex flex-row justify-between">
|
||||||
<h3 className="font-semibold text-xl">{name}</h3>
|
<h3 className="text-xl font-semibold">{name}</h3>
|
||||||
{allowDownload && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
|
{allowDownload &&
|
||||||
|
renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
color={results.length / assignees.length < 0.5 ? "red" : "purple"}
|
color={results.length / assignees.length < 0.5 ? "red" : "purple"}
|
||||||
percentage={(results.length / assignees.length) * 100}
|
percentage={(results.length / assignees.length) * 100}
|
||||||
label={`${results.length}/${assignees.length}`}
|
label={`${results.length}/${assignees.length}`}
|
||||||
className="h-5"
|
className="h-5"
|
||||||
textClassName={results.length / assignees.length < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"}
|
textClassName={
|
||||||
|
results.length / assignees.length < 0.5
|
||||||
|
? "!text-mti-gray-dim font-light"
|
||||||
|
: "text-white"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="flex gap-1 justify-between">
|
<span className="flex justify-between gap-1">
|
||||||
<span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span>
|
<span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
|
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
</span>
|
</span>
|
||||||
<div className="grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2">
|
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
||||||
{exams.map(({module}) => (
|
{uniqBy(exams, (x) => x.module).map(({ module }) => (
|
||||||
<div
|
<div
|
||||||
key={module}
|
key={module}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl",
|
"-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||||
module === "reading" && "bg-ielts-reading",
|
module === "reading" && "bg-ielts-reading",
|
||||||
module === "listening" && "bg-ielts-listening",
|
module === "listening" && "bg-ielts-listening",
|
||||||
module === "writing" && "bg-ielts-writing",
|
module === "writing" && "bg-ielts-writing",
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
module === "level" && "bg-ielts-level",
|
module === "level" && "bg-ielts-level",
|
||||||
)}>
|
)}
|
||||||
{module === "reading" && <BsBook className="w-4 h-4" />}
|
>
|
||||||
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||||
{module === "writing" && <BsPen className="w-4 h-4" />}
|
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||||
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||||
{module === "level" && <BsClipboard className="w-4 h-4" />}
|
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||||
|
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||||
{calculateAverageModuleScore(module) > -1 && (
|
{calculateAverageModuleScore(module) > -1 && (
|
||||||
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
|
<span className="text-sm">
|
||||||
|
{calculateAverageModuleScore(module).toFixed(1)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {toast} from "react-toastify";
|
|||||||
import {uuidv4} from "@firebase/util";
|
import {uuidv4} from "@firebase/util";
|
||||||
import {Assignment} from "@/interfaces/results";
|
import {Assignment} from "@/interfaces/results";
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
|
import {InstructorGender, Variant} from "@/interfaces/exam";
|
||||||
|
import Select from "@/components/Low/Select";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isCreating: boolean;
|
isCreating: boolean;
|
||||||
@@ -34,12 +36,12 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
const [assignees, setAssignees] = useState<string[]>(assignment?.assignees || []);
|
const [assignees, setAssignees] = useState<string[]>(assignment?.assignees || []);
|
||||||
const [name, setName] = useState(assignment?.name || generate({minLength: 6, maxLength: 8, min: 2, max: 3, join: " ", formatter: capitalize}));
|
const [name, setName] = useState(assignment?.name || generate({minLength: 6, maxLength: 8, min: 2, max: 3, join: " ", formatter: capitalize}));
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [startDate, setStartDate] = useState<Date | null>(
|
const [startDate, setStartDate] = useState<Date | null>(assignment ? moment(assignment.startDate).toDate() : new Date());
|
||||||
assignment ? moment(assignment.startDate).toDate() : moment().hours(0).minutes(0).add(1, "day").toDate(),
|
|
||||||
);
|
|
||||||
const [endDate, setEndDate] = useState<Date | null>(
|
const [endDate, setEndDate] = useState<Date | null>(
|
||||||
assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(),
|
assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(),
|
||||||
);
|
);
|
||||||
|
const [variant, setVariant] = useState<Variant>("full");
|
||||||
|
const [instructorGender, setInstructorGender] = useState<InstructorGender>(assignment?.instructorGender || "varied");
|
||||||
// creates a new exam for each assignee or just one exam for all assignees
|
// creates a new exam for each assignee or just one exam for all assignees
|
||||||
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
|
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
|
||||||
|
|
||||||
@@ -62,6 +64,8 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
endDate,
|
endDate,
|
||||||
selectedModules,
|
selectedModules,
|
||||||
generateMultiple,
|
generateMultiple,
|
||||||
|
variant,
|
||||||
|
instructorGender,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`);
|
toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`);
|
||||||
@@ -200,7 +204,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
)}
|
)}
|
||||||
popperClassName="!z-20"
|
popperClassName="!z-20"
|
||||||
filterDate={(date) => moment(date).isAfter(new Date())}
|
filterTime={(date) => moment(date).isSameOrAfter(new Date())}
|
||||||
dateFormat="dd/MM/yyyy HH:mm"
|
dateFormat="dd/MM/yyyy HH:mm"
|
||||||
selected={startDate}
|
selected={startDate}
|
||||||
showTimeSelect
|
showTimeSelect
|
||||||
@@ -216,7 +220,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
)}
|
)}
|
||||||
popperClassName="!z-20"
|
popperClassName="!z-20"
|
||||||
filterDate={(date) => moment(date).isAfter(startDate)}
|
filterTime={(date) => moment(date).isAfter(startDate)}
|
||||||
dateFormat="dd/MM/yyyy HH:mm"
|
dateFormat="dd/MM/yyyy HH:mm"
|
||||||
selected={endDate}
|
selected={endDate}
|
||||||
showTimeSelect
|
showTimeSelect
|
||||||
@@ -225,6 +229,20 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor's Gender</label>
|
||||||
|
<Select
|
||||||
|
value={{value: instructorGender, label: capitalize(instructorGender)}}
|
||||||
|
onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)}
|
||||||
|
disabled={!selectedModules.includes("speaking") || !!assignment}
|
||||||
|
options={[
|
||||||
|
{value: "male", label: "Male"},
|
||||||
|
{value: "female", label: "Female"},
|
||||||
|
{value: "varied", label: "Varied"},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section className="w-full flex flex-col gap-3">
|
<section className="w-full flex flex-col gap-3">
|
||||||
<span className="font-semibold">Assignees ({assignees.length} selected)</span>
|
<span className="font-semibold">Assignees ({assignees.length} selected)</span>
|
||||||
<div className="flex gap-4 overflow-x-scroll scrollbar-hide">
|
<div className="flex gap-4 overflow-x-scroll scrollbar-hide">
|
||||||
@@ -281,7 +299,10 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div className="flex gap-4 w-full justify-end">
|
<div className="flex flex-col gap-4 w-full items-end">
|
||||||
|
<Checkbox isChecked={variant === "full"} onChange={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
|
||||||
|
Full length exams
|
||||||
|
</Checkbox>
|
||||||
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}>
|
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}>
|
||||||
Generate different exams
|
Generate different exams
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {Module} from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import {Assignment} from "@/interfaces/results";
|
import { Assignment } from "@/interfaces/results";
|
||||||
import {Stat, User} from "@/interfaces/user";
|
import { Stat, User } from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {getExamById} from "@/utils/exams";
|
import { getExamById } from "@/utils/exams";
|
||||||
import {sortByModule} from "@/utils/moduleUtils";
|
import { sortByModule } from "@/utils/moduleUtils";
|
||||||
import {calculateBandScore} from "@/utils/score";
|
import { calculateBandScore } from "@/utils/score";
|
||||||
import {convertToUserSolutions} from "@/utils/stats";
|
import { convertToUserSolutions } from "@/utils/stats";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize, uniqBy} from "lodash";
|
import { capitalize, uniqBy } from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {useRouter} from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
import {
|
||||||
|
BsBook,
|
||||||
|
BsClipboard,
|
||||||
|
BsHeadphones,
|
||||||
|
BsMegaphone,
|
||||||
|
BsPen,
|
||||||
|
} from "react-icons/bs";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -21,8 +27,8 @@ interface Props {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
||||||
const {users} = useUsers();
|
const { users } = useUsers();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const setExams = useExamStore((state) => state.setExams);
|
const setExams = useExamStore((state) => state.setExams);
|
||||||
@@ -43,16 +49,29 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
const resultModuleBandScores = assignment.results.map((r) => {
|
const resultModuleBandScores = assignment.results.map((r) => {
|
||||||
const moduleStats = r.stats.filter((s) => s.module === module);
|
const moduleStats = r.stats.filter((s) => s.module === module);
|
||||||
|
|
||||||
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
|
const correct = moduleStats.reduce(
|
||||||
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
|
(acc, curr) => acc + curr.score.correct,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const total = moduleStats.reduce(
|
||||||
|
(acc, curr) => acc + curr.score.total,
|
||||||
|
0,
|
||||||
|
);
|
||||||
return calculateBandScore(correct, total, module, r.type);
|
return calculateBandScore(correct, total, module, r.type);
|
||||||
});
|
});
|
||||||
|
|
||||||
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length;
|
return resultModuleBandScores.length === 0
|
||||||
|
? -1
|
||||||
|
: resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
|
||||||
|
assignment.results.length;
|
||||||
};
|
};
|
||||||
|
|
||||||
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => {
|
const aggregateScoresByModule = (
|
||||||
const scores: {[key in Module]: {total: number; missing: number; correct: number}} = {
|
stats: Stat[],
|
||||||
|
): { module: Module; total: number; missing: number; correct: number }[] => {
|
||||||
|
const scores: {
|
||||||
|
[key in Module]: { total: number; missing: number; correct: number };
|
||||||
|
} = {
|
||||||
reading: {
|
reading: {
|
||||||
total: 0,
|
total: 0,
|
||||||
correct: 0,
|
correct: 0,
|
||||||
@@ -90,13 +109,25 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
|
|
||||||
return Object.keys(scores)
|
return Object.keys(scores)
|
||||||
.filter((x) => scores[x as Module].total > 0)
|
.filter((x) => scores[x as Module].total > 0)
|
||||||
.map((x) => ({module: x as Module, ...scores[x as Module]}));
|
.map((x) => ({ module: x as Module, ...scores[x as Module] }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => {
|
const customContent = (
|
||||||
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
|
stats: Stat[],
|
||||||
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
|
user: string,
|
||||||
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
|
focus: "academic" | "general",
|
||||||
|
) => {
|
||||||
|
const correct = stats.reduce(
|
||||||
|
(accumulator, current) => accumulator + current.score.correct,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const total = stats.reduce(
|
||||||
|
(accumulator, current) => accumulator + current.score.total,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const aggregatedScores = aggregateScoresByModule(stats).filter(
|
||||||
|
(x) => x.total > 0,
|
||||||
|
);
|
||||||
|
|
||||||
const aggregatedLevels = aggregatedScores.map((x) => ({
|
const aggregatedLevels = aggregatedScores.map((x) => ({
|
||||||
module: x.module,
|
module: x.module,
|
||||||
@@ -106,7 +137,9 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
const timeSpent = stats[0].timeSpent;
|
const timeSpent = stats[0].timeSpent;
|
||||||
|
|
||||||
const selectExam = () => {
|
const selectExam = () => {
|
||||||
const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam));
|
const examPromises = uniqBy(stats, "exam").map((stat) =>
|
||||||
|
getExamById(stat.module, stat.exam),
|
||||||
|
);
|
||||||
|
|
||||||
Promise.all(examPromises).then((exams) => {
|
Promise.all(examPromises).then((exams) => {
|
||||||
if (exams.every((x) => !!x)) {
|
if (exams.every((x) => !!x)) {
|
||||||
@@ -126,13 +159,17 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
<div className="w-full flex justify-between -md:items-center 2xl:items-center">
|
<div className="-md:items-center flex w-full justify-between 2xl:items-center">
|
||||||
<div className="flex md:flex-col 2xl:flex-row md:gap-1 -md:gap-2 2xl:gap-2 -md:items-center 2xl:items-center">
|
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
|
||||||
<span className="font-medium">{formatTimestamp(stats[0].date.toString())}</span>
|
<span className="font-medium">
|
||||||
|
{formatTimestamp(stats[0].date.toString())}
|
||||||
|
</span>
|
||||||
{timeSpent && (
|
{timeSpent && (
|
||||||
<>
|
<>
|
||||||
<span className="md:hidden 2xl:flex">• </span>
|
<span className="md:hidden 2xl:flex">• </span>
|
||||||
<span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span>
|
<span className="text-sm">
|
||||||
|
{Math.floor(timeSpent / 60)} minutes
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -141,30 +178,37 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
correct / total >= 0.7 && "text-mti-purple",
|
correct / total >= 0.7 && "text-mti-purple",
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
||||||
correct / total < 0.3 && "text-mti-rose",
|
correct / total < 0.3 && "text-mti-rose",
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
Level{" "}
|
Level{" "}
|
||||||
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
|
{(
|
||||||
|
aggregatedLevels.reduce(
|
||||||
|
(accumulator, current) => accumulator + current.level,
|
||||||
|
0,
|
||||||
|
) / aggregatedLevels.length
|
||||||
|
).toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full flex flex-col gap-1">
|
<div className="flex w-full flex-col gap-1">
|
||||||
<div className="grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2">
|
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
||||||
{aggregatedLevels.map(({module, level}) => (
|
{aggregatedLevels.map(({ module, level }) => (
|
||||||
<div
|
<div
|
||||||
key={module}
|
key={module}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl",
|
"-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||||
module === "reading" && "bg-ielts-reading",
|
module === "reading" && "bg-ielts-reading",
|
||||||
module === "listening" && "bg-ielts-listening",
|
module === "listening" && "bg-ielts-listening",
|
||||||
module === "writing" && "bg-ielts-writing",
|
module === "writing" && "bg-ielts-writing",
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
module === "level" && "bg-ielts-level",
|
module === "level" && "bg-ielts-level",
|
||||||
)}>
|
)}
|
||||||
{module === "reading" && <BsBook className="w-4 h-4" />}
|
>
|
||||||
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||||
{module === "writing" && <BsPen className="w-4 h-4" />}
|
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||||
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||||
{module === "level" && <BsClipboard className="w-4 h-4" />}
|
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||||
|
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||||
<span className="text-sm">{level.toFixed(1)}</span>
|
<span className="text-sm">{level.toFixed(1)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -184,25 +228,31 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
<div
|
<div
|
||||||
key={user}
|
key={user}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex flex-col gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:hidden",
|
"border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
|
||||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
correct / total >= 0.3 &&
|
||||||
|
correct / total < 0.7 &&
|
||||||
|
"hover:border-mti-red",
|
||||||
correct / total < 0.3 && "hover:border-mti-rose",
|
correct / total < 0.3 && "hover:border-mti-rose",
|
||||||
)}
|
)}
|
||||||
onClick={selectExam}
|
onClick={selectExam}
|
||||||
role="button">
|
role="button"
|
||||||
|
>
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
key={user}
|
key={user}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex flex-col gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:tooltip md:hidden",
|
"border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden",
|
||||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
correct / total >= 0.3 &&
|
||||||
|
correct / total < 0.7 &&
|
||||||
|
"hover:border-mti-red",
|
||||||
correct / total < 0.3 && "hover:border-mti-rose",
|
correct / total < 0.3 && "hover:border-mti-rose",
|
||||||
)}
|
)}
|
||||||
data-tip="Your screen size is too small to view previous exams."
|
data-tip="Your screen size is too small to view previous exams."
|
||||||
role="button">
|
role="button"
|
||||||
|
>
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -211,20 +261,33 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}>
|
<Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}>
|
||||||
<div className="mt-4 flex flex-col w-full gap-4">
|
<div className="mt-4 flex w-full flex-col gap-4">
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
color="purple"
|
color="purple"
|
||||||
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
|
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
|
||||||
className="h-6"
|
className="h-6"
|
||||||
textClassName={
|
textClassName={
|
||||||
(assignment?.results.length || 0) / (assignment?.assignees.length || 1) < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"
|
(assignment?.results.length || 0) /
|
||||||
|
(assignment?.assignees.length || 1) <
|
||||||
|
0.5
|
||||||
|
? "!text-mti-gray-dim font-light"
|
||||||
|
: "text-white"
|
||||||
|
}
|
||||||
|
percentage={
|
||||||
|
((assignment?.results.length || 0) /
|
||||||
|
(assignment?.assignees.length || 1)) *
|
||||||
|
100
|
||||||
}
|
}
|
||||||
percentage={((assignment?.results.length || 0) / (assignment?.assignees.length || 1)) * 100}
|
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-8 items-start">
|
<div className="flex items-start gap-8">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span>
|
<span>
|
||||||
<span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span>
|
Start Date:{" "}
|
||||||
|
{moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span>
|
<span>
|
||||||
Assignees:{" "}
|
Assignees:{" "}
|
||||||
@@ -236,26 +299,32 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xl font-bold">Average Scores</span>
|
<span className="text-xl font-bold">Average Scores</span>
|
||||||
<div className="grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2">
|
<div className="-md:mt-2 flex w-full items-center gap-4">
|
||||||
{assignment?.exams.map(({module}) => (
|
{assignment &&
|
||||||
|
uniqBy(assignment.exams, (x) => x.module).map(({ module }) => (
|
||||||
<div
|
<div
|
||||||
data-tip={capitalize(module)}
|
data-tip={capitalize(module)}
|
||||||
key={module}
|
key={module}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl tooltip",
|
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||||
module === "reading" && "bg-ielts-reading",
|
module === "reading" && "bg-ielts-reading",
|
||||||
module === "listening" && "bg-ielts-listening",
|
module === "listening" && "bg-ielts-listening",
|
||||||
module === "writing" && "bg-ielts-writing",
|
module === "writing" && "bg-ielts-writing",
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
module === "level" && "bg-ielts-level",
|
module === "level" && "bg-ielts-level",
|
||||||
)}>
|
)}
|
||||||
{module === "reading" && <BsBook className="w-4 h-4" />}
|
>
|
||||||
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||||
{module === "writing" && <BsPen className="w-4 h-4" />}
|
{module === "listening" && (
|
||||||
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
<BsHeadphones className="h-4 w-4" />
|
||||||
{module === "level" && <BsClipboard className="w-4 h-4" />}
|
)}
|
||||||
|
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||||
|
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||||
|
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||||
{calculateAverageModuleScore(module) > -1 && (
|
{calculateAverageModuleScore(module) > -1 && (
|
||||||
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
|
<span className="text-sm">
|
||||||
|
{calculateAverageModuleScore(module).toFixed(1)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -263,15 +332,20 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xl font-bold">
|
<span className="text-xl font-bold">
|
||||||
Results ({assignment?.results.length}/{assignment?.assignees.length})
|
Results ({assignment?.results.length}/{assignment?.assignees.length}
|
||||||
|
)
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
{assignment && assignment?.results.length > 0 && (
|
{assignment && assignment?.results.length > 0 && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 w-full gap-4 xl:gap-6">
|
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
|
||||||
{assignment.results.map((r) => customContent(r.stats, r.user, r.type))}
|
{assignment.results.map((r) =>
|
||||||
|
customContent(r.stats, r.user, r.type),
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{assignment && assignment?.results.length === 0 && <span className="font-semibold ml-1">No results yet...</span>}
|
{assignment && assignment?.results.length === 0 && (
|
||||||
|
<span className="ml-1 font-semibold">No results yet...</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useStats from "@/hooks/useStats";
|
import useStats from "@/hooks/useStats";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {Group, Stat, User} from "@/interfaces/user";
|
import {CorporateUser, Group, Stat, User} from "@/interfaces/user";
|
||||||
import UserList from "@/pages/(admin)/Lists/UserList";
|
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||||
import {dateSorter} from "@/utils";
|
import {dateSorter} from "@/utils";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
@@ -20,6 +20,9 @@ import {
|
|||||||
BsPersonFillGear,
|
BsPersonFillGear,
|
||||||
BsPersonGear,
|
BsPersonGear,
|
||||||
BsPencilSquare,
|
BsPencilSquare,
|
||||||
|
BsPersonBadge,
|
||||||
|
BsPersonCheck,
|
||||||
|
BsPeople,
|
||||||
} from "react-icons/bs";
|
} from "react-icons/bs";
|
||||||
import UserCard from "@/components/UserCard";
|
import UserCard from "@/components/UserCard";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
@@ -31,9 +34,10 @@ import IconCard from "./IconCard";
|
|||||||
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
||||||
import useFilterStore from "@/stores/listFilterStore";
|
import useFilterStore from "@/stores/listFilterStore";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
|
import useCodes from "@/hooks/useCodes";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: CorporateUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CorporateDashboard({user}: Props) {
|
export default function CorporateDashboard({user}: Props) {
|
||||||
@@ -43,6 +47,7 @@ export default function CorporateDashboard({user}: Props) {
|
|||||||
|
|
||||||
const {stats} = useStats();
|
const {stats} = useStats();
|
||||||
const {users, reload} = useUsers();
|
const {users, reload} = useUsers();
|
||||||
|
const {codes} = useCodes(user.id);
|
||||||
const {groups} = useGroups(user.id);
|
const {groups} = useGroups(user.id);
|
||||||
|
|
||||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||||
@@ -187,7 +192,13 @@ export default function CorporateDashboard({user}: Props) {
|
|||||||
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard onClick={() => setPage("groups")} Icon={BsPersonAdd} label="Groups" value={groups.length} color="purple" />
|
<IconCard onClick={() => setPage("groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
|
||||||
|
<IconCard
|
||||||
|
Icon={BsPersonCheck}
|
||||||
|
label="User Balance"
|
||||||
|
value={`${codes.length}/${user.corporateInformation?.companyInformation?.userAmount || 0}`}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsClock}
|
Icon={BsClock}
|
||||||
label="Expiration Date"
|
label="Expiration Date"
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ interface Props {
|
|||||||
label: string;
|
label: string;
|
||||||
value: string | number;
|
value: string | number;
|
||||||
color: "purple" | "rose" | "red";
|
color: "purple" | "rose" | "red";
|
||||||
|
tooltip?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function IconCard({Icon, label, value, color, onClick}: Props) {
|
export default function IconCard({Icon, label, value, color, tooltip, onClick}: Props) {
|
||||||
const colorClasses: {[key in typeof color]: string} = {
|
const colorClasses: {[key in typeof color]: string} = {
|
||||||
purple: "text-mti-purple-light",
|
purple: "text-mti-purple-light",
|
||||||
red: "text-mti-red-light",
|
red: "text-mti-red-light",
|
||||||
@@ -19,7 +20,11 @@ export default function IconCard({Icon, label, value, color, onClick}: Props) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center text-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
className={clsx(
|
||||||
|
"bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center text-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300",
|
||||||
|
tooltip && "tooltip tooltip-bottom",
|
||||||
|
)}
|
||||||
|
data-tip={tooltip}>
|
||||||
<Icon className={clsx("text-6xl", colorClasses[color])} />
|
<Icon className={clsx("text-6xl", colorClasses[color])} />
|
||||||
<span className="flex flex-col gap-1 items-center text-xl">
|
<span className="flex flex-col gap-1 items-center text-xl">
|
||||||
<span className="text-lg">{label}</span>
|
<span className="text-lg">{label}</span>
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
|
import InviteCard from "@/components/Medium/InviteCard";
|
||||||
import PayPalPayment from "@/components/PayPalPayment";
|
import PayPalPayment from "@/components/PayPalPayment";
|
||||||
import ProfileSummary from "@/components/ProfileSummary";
|
import ProfileSummary from "@/components/ProfileSummary";
|
||||||
import useAssignments from "@/hooks/useAssignments";
|
import useAssignments from "@/hooks/useAssignments";
|
||||||
|
import useInvites from "@/hooks/useInvites";
|
||||||
import useStats from "@/hooks/useStats";
|
import useStats from "@/hooks/useStats";
|
||||||
|
import useUsers from "@/hooks/useUsers";
|
||||||
|
import {Invite} from "@/interfaces/invite";
|
||||||
import {Assignment} from "@/interfaces/results";
|
import {Assignment} from "@/interfaces/results";
|
||||||
import {CorporateUser, User} from "@/interfaces/user";
|
import {CorporateUser, User} from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
@@ -31,7 +35,9 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
|
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
|
||||||
|
|
||||||
const {stats} = useStats(user.id);
|
const {stats} = useStats(user.id);
|
||||||
|
const {users} = useUsers();
|
||||||
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id});
|
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id});
|
||||||
|
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user.id});
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -42,7 +48,7 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
const setAssignment = useExamStore((state) => state.setAssignment);
|
const setAssignment = useExamStore((state) => state.setAssignment);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getUserCorporate("IXdh9EQziAVXXh0jOiC5cPVlgS82").then(setCorporateUserToShow);
|
getUserCorporate(user.id).then(setCorporateUserToShow);
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const startAssignment = (assignment: Assignment) => {
|
const startAssignment = (assignment: Assignment) => {
|
||||||
@@ -69,7 +75,7 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{corporateUserToShow && (
|
{corporateUserToShow && (
|
||||||
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
|
<div className="absolute right-4 top-4 rounded-lg bg-neutral-200 px-2 py-1">
|
||||||
Linked to: <b>{corporateUserToShow?.corporateInformation?.companyInformation.name || corporateUserToShow.name}</b>
|
Linked to: <b>{corporateUserToShow?.corporateInformation?.companyInformation.name || corporateUserToShow.name}</b>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -77,40 +83,42 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
user={user}
|
user={user}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
icon: <BsFileEarmarkText className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
|
||||||
value: Object.keys(groupBySession(stats)).length,
|
value: Object.keys(groupBySession(stats)).length,
|
||||||
label: "Exams",
|
label: "Exams",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
icon: <BsPencil className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
|
||||||
value: stats.length,
|
value: stats.length,
|
||||||
label: "Exercises",
|
label: "Exercises",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BsStar className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
icon: <BsStar className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
|
||||||
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
|
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
|
||||||
label: "Average Score",
|
label: "Average Score",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Bio */}
|
||||||
<section className="flex flex-col gap-1 md:gap-3">
|
<section className="flex flex-col gap-1 md:gap-3">
|
||||||
<span className="font-bold text-lg">Bio</span>
|
<span className="text-lg font-bold">Bio</span>
|
||||||
<span className="text-mti-gray-taupe">
|
<span className="text-mti-gray-taupe">
|
||||||
{user.bio || "Your bio will appear here, you can change it by clicking on your name in the top right corner."}
|
{user.bio || "Your bio will appear here, you can change it by clicking on your name in the top right corner."}
|
||||||
</span>
|
</span>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Assignments */}
|
||||||
<section className="flex flex-col gap-1 md:gap-3">
|
<section className="flex flex-col gap-1 md:gap-3">
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex items-center gap-4">
|
||||||
<div
|
<div
|
||||||
onClick={reloadAssignments}
|
onClick={reloadAssignments}
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out">
|
||||||
<span className="font-bold text-lg text-mti-black">Assignments</span>
|
<span className="text-mti-black text-lg font-bold">Assignments</span>
|
||||||
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
|
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-mti-gray-taupe flex gap-8 overflow-x-scroll scrollbar-hide">
|
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
||||||
{assignments.filter((a) => moment(a.endDate).isSameOrAfter(moment())).length === 0 &&
|
{assignments.filter((a) => moment(a.endDate).isSameOrAfter(moment())).length === 0 &&
|
||||||
"Assignments will appear here. It seems that for now there are no assignments for you."}
|
"Assignments will appear here. It seems that for now there are no assignments for you."}
|
||||||
{assignments
|
{assignments
|
||||||
@@ -119,20 +127,20 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
.map((assignment) => (
|
.map((assignment) => (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"border border-mti-gray-anti-flash rounded-xl flex flex-col gap-6 p-4 min-w-[300px]",
|
"border-mti-gray-anti-flash flex min-w-[300px] flex-col gap-6 rounded-xl border p-4",
|
||||||
assignment.results.map((r) => r.user).includes(user.id) && "border-mti-green-light",
|
assignment.results.map((r) => r.user).includes(user.id) && "border-mti-green-light",
|
||||||
)}
|
)}
|
||||||
key={assignment.id}>
|
key={assignment.id}>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h3 className="font-semibold text-xl text-mti-black/90">{assignment.name}</h3>
|
<h3 className="text-mti-black/90 text-xl font-semibold">{assignment.name}</h3>
|
||||||
<span className="flex gap-1 justify-between">
|
<span className="flex justify-between gap-1">
|
||||||
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
|
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span>
|
<span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between w-full items-center">
|
<div className="flex w-full items-center justify-between">
|
||||||
<div className="grid grid-cols-2 gap-2 place-items-center justify-center w-fit min-w-[104px] -md:mt-2">
|
<div className="-md:mt-2 grid w-fit min-w-[104px] grid-cols-2 place-items-center justify-center gap-2">
|
||||||
{assignment.exams
|
{assignment.exams
|
||||||
.filter((e) => e.assignee === user.id)
|
.filter((e) => e.assignee === user.id)
|
||||||
.map((e) => e.module)
|
.map((e) => e.module)
|
||||||
@@ -142,36 +150,36 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
key={module}
|
key={module}
|
||||||
data-tip={capitalize(module)}
|
data-tip={capitalize(module)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl tooltip",
|
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||||
module === "reading" && "bg-ielts-reading",
|
module === "reading" && "bg-ielts-reading",
|
||||||
module === "listening" && "bg-ielts-listening",
|
module === "listening" && "bg-ielts-listening",
|
||||||
module === "writing" && "bg-ielts-writing",
|
module === "writing" && "bg-ielts-writing",
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
module === "level" && "bg-ielts-level",
|
module === "level" && "bg-ielts-level",
|
||||||
)}>
|
)}>
|
||||||
{module === "reading" && <BsBook className="w-4 h-4" />}
|
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||||
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||||
{module === "writing" && <BsPen className="w-4 h-4" />}
|
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||||
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||||
{module === "level" && <BsClipboard className="w-4 h-4" />}
|
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{!assignment.results.map((r) => r.user).includes(user.id) && (
|
{!assignment.results.map((r) => r.user).includes(user.id) && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="tooltip w-full md:hidden h-full flex items-center justify-end pl-8"
|
className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden"
|
||||||
data-tip="Your screen size is too small to perform an assignment">
|
data-tip="Your screen size is too small to perform an assignment">
|
||||||
<Button
|
<Button
|
||||||
disabled={moment(assignment.startDate).isAfter(moment())}
|
disabled={moment(assignment.startDate).isAfter(moment())}
|
||||||
className="w-full h-full !rounded-xl"
|
className="h-full w-full !rounded-xl"
|
||||||
variant="outline">
|
variant="outline">
|
||||||
Start
|
Start
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
disabled={moment(assignment.startDate).isAfter(moment())}
|
disabled={moment(assignment.startDate).isAfter(moment())}
|
||||||
className="w-full max-w-[50%] h-full !rounded-xl -md:hidden"
|
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
|
||||||
onClick={() => startAssignment(assignment)}
|
onClick={() => startAssignment(assignment)}
|
||||||
variant="outline">
|
variant="outline">
|
||||||
Start
|
Start
|
||||||
@@ -182,7 +190,7 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => router.push("/record")}
|
onClick={() => router.push("/record")}
|
||||||
color="green"
|
color="green"
|
||||||
className="w-full max-w-[50%] h-full !rounded-xl -md:hidden"
|
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
|
||||||
variant="outline">
|
variant="outline">
|
||||||
Submitted
|
Submitted
|
||||||
</Button>
|
</Button>
|
||||||
@@ -193,23 +201,43 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
</span>
|
</span>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="flex flex-col gap-3">
|
{/* Invites */}
|
||||||
<span className="font-bold text-lg">Score History</span>
|
{invites.length > 0 && (
|
||||||
<div className="grid -md:grid-rows-4 md:grid-cols-2 gap-6">
|
<section className="flex flex-col gap-1 md:gap-3">
|
||||||
{MODULE_ARRAY.map((module) => (
|
<div className="flex items-center gap-4">
|
||||||
<div className="border border-mti-gray-anti-flash rounded-xl flex flex-col gap-2 p-4" key={module}>
|
<div
|
||||||
<div className="flex gap-2 md:gap-3 items-center">
|
onClick={reloadInvites}
|
||||||
<div className="w-8 h-8 md:w-12 md:h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg md:rounded-xl">
|
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out">
|
||||||
{module === "reading" && <BsBook className="text-ielts-reading w-4 h-4 md:w-5 md:h-5" />}
|
<span className="text-mti-black text-lg font-bold">Invites</span>
|
||||||
{module === "listening" && <BsHeadphones className="text-ielts-listening w-4 h-4 md:w-5 md:h-5" />}
|
<BsArrowRepeat className={clsx("text-xl", isInvitesLoading && "animate-spin")} />
|
||||||
{module === "writing" && <BsPen className="text-ielts-writing w-4 h-4 md:w-5 md:h-5" />}
|
|
||||||
{module === "speaking" && <BsMegaphone className="text-ielts-speaking w-4 h-4 md:w-5 md:h-5" />}
|
|
||||||
{module === "level" && <BsClipboard className="text-ielts-level w-4 h-4 md:w-5 md:h-5" />}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between w-full">
|
</div>
|
||||||
<span className="font-bold md:font-extrabold text-sm">{capitalize(module)}</span>
|
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
||||||
<span className="text-sm font-normal text-mti-gray-dim">
|
{invites.map((invite) => (
|
||||||
Level {user.levels[module] || 0} / Level {user.desiredLevels[module] || 9}
|
<InviteCard key={invite.id} invite={invite} users={users} reload={reloadInvites} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Score History */}
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<span className="text-lg font-bold">Score History</span>
|
||||||
|
<div className="-md:grid-rows-4 grid gap-6 md:grid-cols-2">
|
||||||
|
{MODULE_ARRAY.map((module) => (
|
||||||
|
<div className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4" key={module}>
|
||||||
|
<div className="flex items-center gap-2 md:gap-3">
|
||||||
|
<div className="bg-mti-gray-smoke flex h-8 w-8 items-center justify-center rounded-lg md:h-12 md:w-12 md:rounded-xl">
|
||||||
|
{module === "reading" && <BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" />}
|
||||||
|
{module === "listening" && <BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />}
|
||||||
|
{module === "writing" && <BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" />}
|
||||||
|
{module === "speaking" && <BsMegaphone className="text-ielts-speaking h-4 w-4 md:h-5 md:w-5" />}
|
||||||
|
{module === "level" && <BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full justify-between">
|
||||||
|
<span className="text-sm font-bold md:font-extrabold">{capitalize(module)}</span>
|
||||||
|
<span className="text-mti-gray-dim text-sm font-normal">
|
||||||
|
Level {user.levels[module] || 0} / Level 9 (Desired Level: {user.desiredLevels[module] || 9})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,8 +245,10 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
<ProgressBar
|
<ProgressBar
|
||||||
color={module}
|
color={module}
|
||||||
label=""
|
label=""
|
||||||
percentage={Math.round((user.levels[module] * 100) / user.desiredLevels[module])}
|
mark={Math.round((user.desiredLevels[module] * 100) / 9)}
|
||||||
className="w-full h-2"
|
markLabel={`Desired Level: ${user.desiredLevels[module]}`}
|
||||||
|
percentage={Math.round((user.levels[module] * 100) / 9)}
|
||||||
|
className="h-2 w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
BsEnvelopePaper,
|
BsEnvelopePaper,
|
||||||
BsGlobeCentralSouthAsia,
|
BsGlobeCentralSouthAsia,
|
||||||
BsPaperclip,
|
BsPaperclip,
|
||||||
|
BsPeople,
|
||||||
BsPerson,
|
BsPerson,
|
||||||
BsPersonAdd,
|
BsPersonAdd,
|
||||||
BsPersonFill,
|
BsPersonFill,
|
||||||
@@ -150,8 +151,9 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AssignmentsPage = () => {
|
const AssignmentsPage = () => {
|
||||||
const activeFilter = (a: Assignment) => moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment());
|
const activeFilter = (a: Assignment) =>
|
||||||
const pastFilter = (a: Assignment) => moment(a.endDate).isBefore(moment());
|
moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()) && a.assignees.length > a.results.length;
|
||||||
|
const pastFilter = (a: Assignment) => moment(a.endDate).isBefore(moment()) || a.assignees.length === a.results.length;
|
||||||
const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
|
const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -271,7 +273,7 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard Icon={BsPersonAdd} label="Groups" value={groups.length} color="purple" onClick={() => setPage("groups")} />
|
<IconCard Icon={BsPeople} label="Groups" value={groups.length} color="purple" onClick={() => setPage("groups")} />
|
||||||
<div
|
<div
|
||||||
onClick={() => setPage("assignments")}
|
onClick={() => setPage("assignments")}
|
||||||
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
||||||
|
|||||||
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 (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center gap-1">
|
<div className="flex flex-col items-center justify-center gap-1">
|
||||||
<span className="text-xl font-bold">{levelStr}</span>
|
<span className="text-xl font-bold">{levelStr}</span>
|
||||||
<span className="text-xl">{grade}</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -86,7 +85,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-full min-h-full h-fit flex flex-col items-center justify-between gap-8">
|
<div className="flex h-fit min-h-full w-full flex-col items-center justify-between gap-8">
|
||||||
<ModuleTitle
|
<ModuleTitle
|
||||||
module={selectedModule}
|
module={selectedModule}
|
||||||
totalExercises={getTotalExercises()}
|
totalExercises={getTotalExercises()}
|
||||||
@@ -99,10 +98,10 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
<div
|
<div
|
||||||
onClick={() => setSelectedModule("reading")}
|
onClick={() => setSelectedModule("reading")}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-reading hover:text-white",
|
"hover:bg-ielts-reading flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
|
||||||
selectedModule === "reading" ? "bg-ielts-reading text-white" : "bg-mti-gray-smoke text-ielts-reading",
|
selectedModule === "reading" ? "bg-ielts-reading text-white" : "bg-mti-gray-smoke text-ielts-reading",
|
||||||
)}>
|
)}>
|
||||||
<BsBook className="w-6 h-6" />
|
<BsBook className="h-6 w-6" />
|
||||||
<span className="font-semibold">Reading</span>
|
<span className="font-semibold">Reading</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -110,10 +109,10 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
<div
|
<div
|
||||||
onClick={() => setSelectedModule("listening")}
|
onClick={() => setSelectedModule("listening")}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-listening hover:text-white",
|
"hover:bg-ielts-listening flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
|
||||||
selectedModule === "listening" ? "bg-ielts-listening text-white" : "bg-mti-gray-smoke text-ielts-listening",
|
selectedModule === "listening" ? "bg-ielts-listening text-white" : "bg-mti-gray-smoke text-ielts-listening",
|
||||||
)}>
|
)}>
|
||||||
<BsHeadphones className="w-6 h-6" />
|
<BsHeadphones className="h-6 w-6" />
|
||||||
<span className="font-semibold">Listening</span>
|
<span className="font-semibold">Listening</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -121,10 +120,10 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
<div
|
<div
|
||||||
onClick={() => setSelectedModule("writing")}
|
onClick={() => setSelectedModule("writing")}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-writing hover:text-white",
|
"hover:bg-ielts-writing flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
|
||||||
selectedModule === "writing" ? "bg-ielts-writing text-white" : "bg-mti-gray-smoke text-ielts-writing",
|
selectedModule === "writing" ? "bg-ielts-writing text-white" : "bg-mti-gray-smoke text-ielts-writing",
|
||||||
)}>
|
)}>
|
||||||
<BsPen className="w-6 h-6" />
|
<BsPen className="h-6 w-6" />
|
||||||
<span className="font-semibold">Writing</span>
|
<span className="font-semibold">Writing</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -132,10 +131,10 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
<div
|
<div
|
||||||
onClick={() => setSelectedModule("speaking")}
|
onClick={() => setSelectedModule("speaking")}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-speaking hover:text-white",
|
"hover:bg-ielts-speaking flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
|
||||||
selectedModule === "speaking" ? "bg-ielts-speaking text-white" : "bg-mti-gray-smoke text-ielts-speaking",
|
selectedModule === "speaking" ? "bg-ielts-speaking text-white" : "bg-mti-gray-smoke text-ielts-speaking",
|
||||||
)}>
|
)}>
|
||||||
<BsMegaphone className="w-6 h-6" />
|
<BsMegaphone className="h-6 w-6" />
|
||||||
<span className="font-semibold">Speaking</span>
|
<span className="font-semibold">Speaking</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -143,18 +142,18 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
<div
|
<div
|
||||||
onClick={() => setSelectedModule("level")}
|
onClick={() => setSelectedModule("level")}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-level hover:text-white",
|
"hover:bg-ielts-level flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
|
||||||
selectedModule === "level" ? "bg-ielts-level text-white" : "bg-mti-gray-smoke text-ielts-level",
|
selectedModule === "level" ? "bg-ielts-level text-white" : "bg-mti-gray-smoke text-ielts-level",
|
||||||
)}>
|
)}>
|
||||||
<BsClipboard className="w-6 h-6" />
|
<BsClipboard className="h-6 w-6" />
|
||||||
<span className="font-semibold">Level</span>
|
<span className="font-semibold">Level</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="w-fit h-fit absolute top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 animate-pulse flex flex-col gap-12 items-center">
|
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
|
||||||
<span className={clsx("loading loading-infinity w-32", moduleColors[selectedModule].progress)} />
|
<span className={clsx("loading loading-infinity w-32", moduleColors[selectedModule].progress)} />
|
||||||
<span className={clsx("font-bold text-2xl text-center", moduleColors[selectedModule].progress)}>
|
<span className={clsx("text-center text-2xl font-bold", moduleColors[selectedModule].progress)}>
|
||||||
Evaluating your answers, please be patient...
|
Evaluating your answers, please be patient...
|
||||||
<br />
|
<br />
|
||||||
You can also check it later on your records page!
|
You can also check it later on your records page!
|
||||||
@@ -162,17 +161,21 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isLoading && (
|
{!isLoading && (
|
||||||
<div className="w-full flex gap-9 mt-32 items-center justify-between mb-20">
|
<div className="mb-20 mt-32 flex w-full items-center justify-between gap-9">
|
||||||
<span className="max-w-3xl">{moduleResultText(selectedModule, bandScore)}</span>
|
<span className="max-w-3xl">{moduleResultText(selectedModule, bandScore)}</span>
|
||||||
<div className="flex gap-9 px-16">
|
<div className="flex gap-9 px-16">
|
||||||
<div
|
<div
|
||||||
className={clsx("radial-progress overflow-hidden", moduleColors[selectedModule].progress)}
|
className={clsx("radial-progress overflow-hidden", moduleColors[selectedModule].progress)}
|
||||||
style={
|
style={
|
||||||
{"--value": (selectedScore.correct / selectedScore.total) * 100, "--thickness": "12px", "--size": "13rem"} as any
|
{
|
||||||
|
"--value": (selectedScore.correct / selectedScore.total) * 100,
|
||||||
|
"--thickness": "12px",
|
||||||
|
"--size": "13rem",
|
||||||
|
} as any
|
||||||
}>
|
}>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-48 h-48 rounded-full flex flex-col items-center justify-center",
|
"flex h-48 w-48 flex-col items-center justify-center rounded-full",
|
||||||
moduleColors[selectedModule].inner,
|
moduleColors[selectedModule].inner,
|
||||||
)}>
|
)}>
|
||||||
<span className="text-xl">Level</span>
|
<span className="text-xl">Level</span>
|
||||||
@@ -181,7 +184,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="w-3 h-3 bg-mti-red-light rounded-full mt-1" />
|
<div className="bg-mti-red-light mt-1 h-3 w-3 rounded-full" />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-mti-red-light">
|
<span className="text-mti-red-light">
|
||||||
{(((selectedScore.total - selectedScore.missing) / selectedScore.total) * 100).toFixed(0)}%
|
{(((selectedScore.total - selectedScore.missing) / selectedScore.total) * 100).toFixed(0)}%
|
||||||
@@ -190,14 +193,14 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="w-3 h-3 bg-mti-purple-light rounded-full mt-1" />
|
<div className="bg-mti-purple-light mt-1 h-3 w-3 rounded-full" />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-mti-purple-light">{selectedScore.correct.toString().padStart(2, "0")}</span>
|
<span className="text-mti-purple-light">{selectedScore.correct.toString().padStart(2, "0")}</span>
|
||||||
<span className="text-lg">Correct</span>
|
<span className="text-lg">Correct</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="w-3 h-3 bg-mti-rose-light rounded-full mt-1" />
|
<div className="bg-mti-rose-light mt-1 h-3 w-3 rounded-full" />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-mti-rose-light">
|
<span className="text-mti-rose-light">
|
||||||
{(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")}
|
{(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")}
|
||||||
@@ -212,28 +215,28 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isLoading && (
|
{!isLoading && (
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
<div className="absolute bottom-8 left-0 flex w-full justify-between gap-8 self-end px-8">
|
||||||
<div className="flex gap-8">
|
<div className="flex gap-8">
|
||||||
<div className="w-fit flex flex-col items-center gap-1 cursor-pointer">
|
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
className="w-11 h-11 rounded-full bg-mti-purple-light hover:bg-mti-purple flex items-center justify-center transition duration-300 ease-in-out">
|
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
||||||
<BsArrowCounterclockwise className="text-white w-7 h-7" />
|
<BsArrowCounterclockwise className="h-7 w-7 text-white" />
|
||||||
</button>
|
</button>
|
||||||
<span>Play Again</span>
|
<span>Play Again</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-fit flex flex-col items-center gap-1 cursor-pointer">
|
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={onViewResults}
|
onClick={onViewResults}
|
||||||
className="w-11 h-11 rounded-full bg-mti-purple-light hover:bg-mti-purple flex items-center justify-center transition duration-300 ease-in-out">
|
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
||||||
<BsEyeFill className="text-white w-7 h-7" />
|
<BsEyeFill className="h-7 w-7 text-white" />
|
||||||
</button>
|
</button>
|
||||||
<span>Review Answers</span>
|
<span>Review Answers</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link href="/" className="max-w-[200px] w-full self-end">
|
<Link href="/" className="w-full max-w-[200px] self-end">
|
||||||
<Button color="purple" className="max-w-[200px] self-end w-full">
|
<Button color="purple" className="w-full max-w-[200px] self-end">
|
||||||
Dashboard
|
Dashboard
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
|
|||||||
const [questionIndex, setQuestionIndex] = useState(0);
|
const [questionIndex, setQuestionIndex] = useState(0);
|
||||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||||
const [exerciseIndex, setExerciseIndex] = useState(0);
|
const [exerciseIndex, setExerciseIndex] = useState(0);
|
||||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
|
|
||||||
|
|
||||||
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
|
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||||
|
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentQuestionIndex(0);
|
setCurrentQuestionIndex(0);
|
||||||
@@ -38,7 +38,7 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
|
|||||||
|
|
||||||
const nextExercise = (solution?: UserSolution) => {
|
const nextExercise = (solution?: UserSolution) => {
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
}
|
}
|
||||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
|
|||||||
|
|
||||||
const previousExercise = (solution?: UserSolution) => {
|
const previousExercise = (solution?: UserSolution) => {
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exerciseIndex > 0) {
|
if (exerciseIndex > 0) {
|
||||||
@@ -91,7 +91,7 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
|
|||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
exerciseIndex < exam.exercises.length &&
|
exerciseIndex < exam.exercises.length &&
|
||||||
!showSolutions &&
|
!showSolutions &&
|
||||||
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
|
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
exerciseIndex < exam.exercises.length &&
|
exerciseIndex < exam.exercises.length &&
|
||||||
showSolutions &&
|
showSolutions &&
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import AudioPlayer from "@/components/Low/AudioPlayer";
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
|
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {defaultUserSolutions} from "@/utils/exams";
|
|
||||||
import {countExercises} from "@/utils/moduleUtils";
|
import {countExercises} from "@/utils/moduleUtils";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -16,24 +15,35 @@ interface Props {
|
|||||||
onFinish: (userSolutions: UserSolution[]) => void;
|
onFinish: (userSolutions: UserSolution[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INSTRUCTIONS_AUDIO_SRC =
|
||||||
|
"https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_recordings%2Fgeneric_intro.mp3?alt=media&token=9b9cfdb8-e90d-40d1-854b-51c4378a5c4b";
|
||||||
|
|
||||||
export default function Listening({exam, showSolutions = false, onFinish}: Props) {
|
export default function Listening({exam, showSolutions = false, onFinish}: Props) {
|
||||||
const [questionIndex, setQuestionIndex] = useState(0);
|
const [questionIndex, setQuestionIndex] = useState(0);
|
||||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||||
const [exerciseIndex, setExerciseIndex] = useState(showSolutions ? 0 : -1);
|
|
||||||
const [partIndex, setPartIndex] = useState(0);
|
|
||||||
const [timesListened, setTimesListened] = useState(0);
|
const [timesListened, setTimesListened] = useState(0);
|
||||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(
|
|
||||||
exam.parts.flatMap((x) => x.exercises).map((x) => defaultUserSolutions(x, exam)),
|
|
||||||
);
|
|
||||||
const [showBlankModal, setShowBlankModal] = useState(false);
|
const [showBlankModal, setShowBlankModal] = useState(false);
|
||||||
|
|
||||||
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
|
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||||
|
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
||||||
|
const {partIndex, setPartIndex} = useExamStore((state) => state);
|
||||||
|
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
||||||
|
|
||||||
|
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showSolutions) return setExerciseIndex(-1);
|
||||||
|
}, [setExerciseIndex, showSolutions]);
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// if (exam.variant !== "partial") setPartIndex(-1);
|
||||||
|
// }, [exam.variant, setPartIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded && exerciseIndex === -1) {
|
if (hasExamEnded && exerciseIndex === -1) {
|
||||||
setExerciseIndex((prev) => prev + 1);
|
setExerciseIndex(exerciseIndex + 1);
|
||||||
}
|
}
|
||||||
}, [hasExamEnded, exerciseIndex]);
|
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentQuestionIndex(0);
|
setCurrentQuestionIndex(0);
|
||||||
@@ -49,18 +59,19 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
|||||||
};
|
};
|
||||||
|
|
||||||
const nextExercise = (solution?: UserSolution) => {
|
const nextExercise = (solution?: UserSolution) => {
|
||||||
|
scrollToTop();
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
}
|
}
|
||||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
||||||
setExerciseIndex((prev) => prev + 1);
|
setExerciseIndex(exerciseIndex + 1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
|
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
|
||||||
setPartIndex((prev) => prev + 1);
|
setPartIndex(partIndex + 1);
|
||||||
setExerciseIndex(showSolutions ? 0 : -1);
|
setExerciseIndex(showSolutions ? 0 : -1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -89,11 +100,12 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
|||||||
};
|
};
|
||||||
|
|
||||||
const previousExercise = (solution?: UserSolution) => {
|
const previousExercise = (solution?: UserSolution) => {
|
||||||
|
scrollToTop();
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
}
|
}
|
||||||
|
|
||||||
setExerciseIndex((prev) => prev - 1);
|
setExerciseIndex(exerciseIndex - 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getExercise = () => {
|
const getExercise = () => {
|
||||||
@@ -104,6 +116,17 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderAudioInstructionsPlayer = () => (
|
||||||
|
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
|
||||||
|
<div className="flex flex-col w-full gap-2">
|
||||||
|
<h4 className="text-xl font-semibold">Please listen to the instructions audio attentively.</h4>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
|
||||||
|
<AudioPlayer key={partIndex} src={INSTRUCTIONS_AUDIO_SRC} color="listening" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const renderAudioPlayer = () => (
|
const renderAudioPlayer = () => (
|
||||||
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
|
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
|
||||||
<div className="flex flex-col w-full gap-2">
|
<div className="flex flex-col w-full gap-2">
|
||||||
@@ -133,7 +156,9 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
|||||||
<div className="flex flex-col h-full w-full gap-8 justify-between">
|
<div className="flex flex-col h-full w-full gap-8 justify-between">
|
||||||
<ModuleTitle
|
<ModuleTitle
|
||||||
exerciseIndex={
|
exerciseIndex={
|
||||||
(exam.parts
|
partIndex === -1
|
||||||
|
? 0
|
||||||
|
: (exam.parts
|
||||||
.flatMap((x) => x.exercises)
|
.flatMap((x) => x.exercises)
|
||||||
.findIndex(
|
.findIndex(
|
||||||
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
|
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
|
||||||
@@ -147,24 +172,37 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
|||||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||||
disableTimer={showSolutions}
|
disableTimer={showSolutions}
|
||||||
/>
|
/>
|
||||||
{renderAudioPlayer()}
|
{/* Audio Player for the Instructions */}
|
||||||
|
{partIndex === -1 && renderAudioInstructionsPlayer()}
|
||||||
|
|
||||||
|
{/* Part's audio player */}
|
||||||
|
{partIndex > -1 && renderAudioPlayer()}
|
||||||
|
|
||||||
|
{/* Exercise renderer */}
|
||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
|
partIndex > -1 &&
|
||||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||||
!showSolutions &&
|
!showSolutions &&
|
||||||
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
|
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||||
|
|
||||||
|
{/* Solution renderer */}
|
||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
|
partIndex > -1 &&
|
||||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||||
showSolutions &&
|
showSolutions &&
|
||||||
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||||
</div>
|
</div>
|
||||||
{exerciseIndex === -1 && partIndex > 0 && (
|
|
||||||
|
{exerciseIndex === -1 && partIndex > -1 && exam.variant !== "partial" && (
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<Button
|
<Button
|
||||||
color="purple"
|
color="purple"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (partIndex === 0) return setPartIndex(-1);
|
||||||
|
|
||||||
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
|
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
|
||||||
setPartIndex((prev) => prev - 1);
|
setPartIndex(partIndex - 1);
|
||||||
}}
|
}}
|
||||||
className="max-w-[200px] w-full">
|
className="max-w-[200px] w-full">
|
||||||
Back
|
Back
|
||||||
@@ -175,7 +213,13 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{exerciseIndex === -1 && partIndex === 0 && (
|
|
||||||
|
{partIndex === -1 && exam.variant !== "partial" && (
|
||||||
|
<Button color="purple" onClick={() => setPartIndex(0)} className="max-w-[200px] self-end w-full justify-self-end">
|
||||||
|
Start now
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{exerciseIndex === -1 && partIndex === 0 && exam.variant === "partial" && (
|
||||||
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end">
|
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end">
|
||||||
Start now
|
Start now
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -83,15 +83,20 @@ function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: s
|
|||||||
export default function Reading({exam, showSolutions = false, onFinish}: Props) {
|
export default function Reading({exam, showSolutions = false, onFinish}: Props) {
|
||||||
const [questionIndex, setQuestionIndex] = useState(0);
|
const [questionIndex, setQuestionIndex] = useState(0);
|
||||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||||
const [exerciseIndex, setExerciseIndex] = useState(showSolutions ? 0 : -1);
|
|
||||||
const [partIndex, setPartIndex] = useState(0);
|
|
||||||
const [showTextModal, setShowTextModal] = useState(false);
|
const [showTextModal, setShowTextModal] = useState(false);
|
||||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(
|
|
||||||
exam.parts.flatMap((x) => x.exercises).map((x) => defaultUserSolutions(x, exam)),
|
|
||||||
);
|
|
||||||
const [showBlankModal, setShowBlankModal] = useState(false);
|
const [showBlankModal, setShowBlankModal] = useState(false);
|
||||||
|
|
||||||
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
|
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||||
|
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
||||||
|
const {partIndex, setPartIndex} = useExamStore((state) => state);
|
||||||
|
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
||||||
|
const setStoreQuestionIndex = useExamStore((state) => state.setQuestionIndex);
|
||||||
|
|
||||||
|
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showSolutions) setExerciseIndex(-1);
|
||||||
|
}, [setExerciseIndex, showSolutions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const listener = (e: KeyboardEvent) => {
|
const listener = (e: KeyboardEvent) => {
|
||||||
@@ -113,9 +118,9 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded && exerciseIndex === -1) {
|
if (hasExamEnded && exerciseIndex === -1) {
|
||||||
setExerciseIndex((prev) => prev + 1);
|
setExerciseIndex(exerciseIndex + 1);
|
||||||
}
|
}
|
||||||
}, [hasExamEnded, exerciseIndex]);
|
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
||||||
|
|
||||||
const confirmFinishModule = (keepGoing?: boolean) => {
|
const confirmFinishModule = (keepGoing?: boolean) => {
|
||||||
if (!keepGoing) {
|
if (!keepGoing) {
|
||||||
@@ -127,18 +132,20 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
};
|
};
|
||||||
|
|
||||||
const nextExercise = (solution?: UserSolution) => {
|
const nextExercise = (solution?: UserSolution) => {
|
||||||
|
scrollToTop();
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
}
|
}
|
||||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
||||||
|
setStoreQuestionIndex(0);
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
||||||
setExerciseIndex((prev) => prev + 1);
|
setExerciseIndex(exerciseIndex + 1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
|
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
|
||||||
setPartIndex((prev) => prev + 1);
|
setPartIndex(partIndex + 1);
|
||||||
setExerciseIndex(showSolutions ? 0 : -1);
|
setExerciseIndex(showSolutions ? 0 : -1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -167,11 +174,13 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
};
|
};
|
||||||
|
|
||||||
const previousExercise = (solution?: UserSolution) => {
|
const previousExercise = (solution?: UserSolution) => {
|
||||||
|
scrollToTop();
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
}
|
}
|
||||||
|
setStoreQuestionIndex(0);
|
||||||
|
|
||||||
setExerciseIndex((prev) => prev - 1);
|
setExerciseIndex(exerciseIndex - 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getExercise = () => {
|
const getExercise = () => {
|
||||||
@@ -206,14 +215,17 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
<>
|
<>
|
||||||
<div className="flex flex-col h-full w-full gap-8">
|
<div className="flex flex-col h-full w-full gap-8">
|
||||||
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
||||||
<TextModal {...exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />
|
{partIndex > -1 && <TextModal {...exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />}
|
||||||
<ModuleTitle
|
<ModuleTitle
|
||||||
minTimer={exam.minTimer}
|
minTimer={exam.minTimer}
|
||||||
exerciseIndex={
|
exerciseIndex={
|
||||||
(exam.parts
|
(exam.parts
|
||||||
.flatMap((x) => x.exercises)
|
.flatMap((x) => x.exercises)
|
||||||
.findIndex(
|
.findIndex(
|
||||||
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
|
(x) =>
|
||||||
|
x.id ===
|
||||||
|
exam.parts[partIndex > -1 ? partIndex : 0].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]
|
||||||
|
?.id,
|
||||||
) || 0) +
|
) || 0) +
|
||||||
(exerciseIndex === -1 ? 0 : 1) +
|
(exerciseIndex === -1 ? 0 : 1) +
|
||||||
questionIndex +
|
questionIndex +
|
||||||
@@ -225,17 +237,21 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
label={exerciseIndex === -1 ? undefined : convertCamelCaseToReadable(exam.parts[partIndex].exercises[exerciseIndex].type)}
|
label={exerciseIndex === -1 ? undefined : convertCamelCaseToReadable(exam.parts[partIndex].exercises[exerciseIndex].type)}
|
||||||
/>
|
/>
|
||||||
<div className={clsx("mb-20 w-full", exerciseIndex > -1 && "grid grid-cols-2 gap-4")}>
|
<div className={clsx("mb-20 w-full", exerciseIndex > -1 && "grid grid-cols-2 gap-4")}>
|
||||||
{renderText()}
|
{partIndex > -1 && renderText()}
|
||||||
|
|
||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
|
partIndex > -1 &&
|
||||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||||
!showSolutions &&
|
!showSolutions &&
|
||||||
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
|
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||||
|
|
||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
|
partIndex > -1 &&
|
||||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||||
showSolutions &&
|
showSolutions &&
|
||||||
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||||
</div>
|
</div>
|
||||||
{exerciseIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
|
{exerciseIndex > -1 && partIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
|
||||||
<Button
|
<Button
|
||||||
color="purple"
|
color="purple"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -252,7 +268,7 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
|
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
|
||||||
setPartIndex((prev) => prev - 1);
|
setPartIndex(partIndex - 1);
|
||||||
}}
|
}}
|
||||||
className="max-w-[200px] w-full">
|
className="max-w-[200px] w-full">
|
||||||
Back
|
Back
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {Module} from "@/interfaces";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
import {BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
import {BsArrowRepeat, BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
||||||
import {totalExamsByModule} from "@/utils/stats";
|
import {totalExamsByModule} from "@/utils/stats";
|
||||||
import useStats from "@/hooks/useStats";
|
import useStats from "@/hooks/useStats";
|
||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
@@ -12,53 +12,78 @@ import {calculateAverageLevel} from "@/utils/score";
|
|||||||
import {sortByModuleName} from "@/utils/moduleUtils";
|
import {sortByModuleName} from "@/utils/moduleUtils";
|
||||||
import {capitalize} from "lodash";
|
import {capitalize} from "lodash";
|
||||||
import ProfileSummary from "@/components/ProfileSummary";
|
import ProfileSummary from "@/components/ProfileSummary";
|
||||||
|
import {Variant} from "@/interfaces/exam";
|
||||||
|
import useSessions, {Session} from "@/hooks/useSessions";
|
||||||
|
import SessionCard from "@/components/Medium/SessionCard";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import moment from "moment";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
page: "exercises" | "exams";
|
page: "exercises" | "exams";
|
||||||
onStart: (modules: Module[], avoidRepeated: boolean) => void;
|
onStart: (modules: Module[], avoidRepeated: boolean, variant: Variant) => void;
|
||||||
disableSelection?: boolean;
|
disableSelection?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Selection({user, page, onStart, disableSelection = false}: Props) {
|
export default function Selection({user, page, onStart, disableSelection = false}: Props) {
|
||||||
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
|
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
|
||||||
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
|
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
|
||||||
|
const [variant, setVariant] = useState<Variant>("full");
|
||||||
|
|
||||||
const {stats} = useStats(user?.id);
|
const {stats} = useStats(user?.id);
|
||||||
|
const {sessions, isLoading, reload} = useSessions(user.id);
|
||||||
|
|
||||||
|
const state = useExamStore((state) => state);
|
||||||
|
|
||||||
const toggleModule = (module: Module) => {
|
const toggleModule = (module: Module) => {
|
||||||
const modules = selectedModules.filter((x) => x !== module);
|
const modules = selectedModules.filter((x) => x !== module);
|
||||||
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module]));
|
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module]));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadSession = async (session: Session) => {
|
||||||
|
state.setSelectedModules(session.selectedModules);
|
||||||
|
state.setExam(session.exam);
|
||||||
|
state.setExams(session.exams);
|
||||||
|
state.setSessionId(session.sessionId);
|
||||||
|
state.setAssignment(session.assignment);
|
||||||
|
state.setExerciseIndex(session.exerciseIndex);
|
||||||
|
state.setPartIndex(session.partIndex);
|
||||||
|
state.setModuleIndex(session.moduleIndex);
|
||||||
|
state.setTimeSpent(session.timeSpent);
|
||||||
|
state.setUserSolutions(session.userSolutions);
|
||||||
|
state.setShowSolutions(false);
|
||||||
|
state.setQuestionIndex(session.questionIndex);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-full h-full relative flex flex-col gap-8 md:gap-16">
|
<div className="relative flex h-full w-full flex-col gap-8 md:gap-16">
|
||||||
{user && (
|
{user && (
|
||||||
<ProfileSummary
|
<ProfileSummary
|
||||||
user={user}
|
user={user}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
icon: <BsBook className="text-ielts-reading w-6 h-6 md:w-8 md:h-8" />,
|
icon: <BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />,
|
||||||
label: "Reading",
|
label: "Reading",
|
||||||
value: totalExamsByModule(stats, "reading"),
|
value: totalExamsByModule(stats, "reading"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BsHeadphones className="text-ielts-listening w-6 h-6 md:w-8 md:h-8" />,
|
icon: <BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />,
|
||||||
label: "Listening",
|
label: "Listening",
|
||||||
value: totalExamsByModule(stats, "listening"),
|
value: totalExamsByModule(stats, "listening"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BsPen className="text-ielts-writing w-6 h-6 md:w-8 md:h-8" />,
|
icon: <BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />,
|
||||||
label: "Writing",
|
label: "Writing",
|
||||||
value: totalExamsByModule(stats, "writing"),
|
value: totalExamsByModule(stats, "writing"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BsMegaphone className="text-ielts-speaking w-6 h-6 md:w-8 md:h-8" />,
|
icon: <BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />,
|
||||||
label: "Speaking",
|
label: "Speaking",
|
||||||
value: totalExamsByModule(stats, "speaking"),
|
value: totalExamsByModule(stats, "speaking"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BsClipboard className="text-ielts-level w-6 h-6 md:w-8 md:h-8" />,
|
icon: <BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />,
|
||||||
label: "Level",
|
label: "Level",
|
||||||
value: totalExamsByModule(stats, "level"),
|
value: totalExamsByModule(stats, "level"),
|
||||||
},
|
},
|
||||||
@@ -67,7 +92,7 @@ export default function Selection({user, page, onStart, disableSelection = false
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="flex flex-col gap-3">
|
<section className="flex flex-col gap-3">
|
||||||
<span className="font-bold text-lg">About {capitalize(page)}</span>
|
<span className="text-lg font-bold">About {capitalize(page)}</span>
|
||||||
<span className="text-mti-gray-taupe">
|
<span className="text-mti-gray-taupe">
|
||||||
{page === "exercises" && (
|
{page === "exercises" && (
|
||||||
<>
|
<>
|
||||||
@@ -92,133 +117,171 @@ export default function Selection({user, page, onStart, disableSelection = false
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</section>
|
</section>
|
||||||
<section className="w-full flex -lg:flex-col -lg:items-center -lg:gap-12 justify-between gap-8 mt-8">
|
|
||||||
|
{sessions.length > 0 && (
|
||||||
|
<section className="flex flex-col gap-3 md:gap-3">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
onClick={reload}
|
||||||
|
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out">
|
||||||
|
<span className="text-mti-black text-lg font-bold">Unfinished Sessions</span>
|
||||||
|
<BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
||||||
|
{sessions
|
||||||
|
.sort((a, b) => moment(b.date).diff(moment(a.date)))
|
||||||
|
.map((session) => (
|
||||||
|
<SessionCard session={session} key={session.sessionId} reload={reload} loadSession={loadSession} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8">
|
||||||
<div
|
<div
|
||||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
|
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||||
selectedModules.includes("reading") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
selectedModules.includes("reading") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||||
)}>
|
)}>
|
||||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-0 -translate-y-1/2">
|
<div className="bg-ielts-reading absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||||
<BsBook className="text-white w-7 h-7" />
|
<BsBook className="h-7 w-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold">Reading:</span>
|
<span className="font-semibold">Reading:</span>
|
||||||
<p className="text-center text-xs">
|
<p className="text-left text-xs">
|
||||||
Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
|
Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
|
||||||
</p>
|
</p>
|
||||||
{!selectedModules.includes("reading") && !selectedModules.includes("level") && !disableSelection && (
|
{!selectedModules.includes("reading") && !selectedModules.includes("level") && !disableSelection && (
|
||||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||||
)}
|
)}
|
||||||
{(selectedModules.includes("reading") || disableSelection) && (
|
{(selectedModules.includes("reading") || disableSelection) && (
|
||||||
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
|
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||||
)}
|
)}
|
||||||
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
|
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
|
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||||
selectedModules.includes("listening") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
selectedModules.includes("listening") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||||
)}>
|
)}>
|
||||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-0 -translate-y-1/2">
|
<div className="bg-ielts-listening absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||||
<BsHeadphones className="text-white w-7 h-7" />
|
<BsHeadphones className="h-7 w-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold">Listening:</span>
|
<span className="font-semibold">Listening:</span>
|
||||||
<p className="text-center text-xs">
|
<p className="text-left text-xs">
|
||||||
Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
|
Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
|
||||||
</p>
|
</p>
|
||||||
{!selectedModules.includes("listening") && !selectedModules.includes("level") && !disableSelection && (
|
{!selectedModules.includes("listening") && !selectedModules.includes("level") && !disableSelection && (
|
||||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||||
)}
|
)}
|
||||||
{(selectedModules.includes("listening") || disableSelection) && (
|
{(selectedModules.includes("listening") || disableSelection) && (
|
||||||
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
|
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||||
)}
|
)}
|
||||||
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
|
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
|
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||||
selectedModules.includes("writing") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
selectedModules.includes("writing") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||||
)}>
|
)}>
|
||||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-0 -translate-y-1/2">
|
<div className="bg-ielts-writing absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||||
<BsPen className="text-white w-7 h-7" />
|
<BsPen className="h-7 w-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold">Writing:</span>
|
<span className="font-semibold">Writing:</span>
|
||||||
<p className="text-center text-xs">
|
<p className="text-left text-xs">
|
||||||
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
|
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
|
||||||
</p>
|
</p>
|
||||||
{!selectedModules.includes("writing") && !selectedModules.includes("level") && !disableSelection && (
|
{!selectedModules.includes("writing") && !selectedModules.includes("level") && !disableSelection && (
|
||||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||||
)}
|
)}
|
||||||
{(selectedModules.includes("writing") || disableSelection) && (
|
{(selectedModules.includes("writing") || disableSelection) && (
|
||||||
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
|
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||||
)}
|
)}
|
||||||
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
|
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
|
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||||
selectedModules.includes("speaking") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
selectedModules.includes("speaking") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||||
)}>
|
)}>
|
||||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-0 -translate-y-1/2">
|
<div className="bg-ielts-speaking absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||||
<BsMegaphone className="text-white w-7 h-7" />
|
<BsMegaphone className="h-7 w-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold">Speaking:</span>
|
<span className="font-semibold">Speaking:</span>
|
||||||
<p className="text-center text-xs">
|
<p className="text-left text-xs">
|
||||||
You'll have access to interactive dialogs, pronunciation exercises and speech recordings.
|
You'll have access to interactive dialogs, pronunciation exercises and speech recordings.
|
||||||
</p>
|
</p>
|
||||||
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && !disableSelection && (
|
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && !disableSelection && (
|
||||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||||
)}
|
)}
|
||||||
{(selectedModules.includes("speaking") || disableSelection) && (
|
{(selectedModules.includes("speaking") || disableSelection) && (
|
||||||
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
|
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||||
)}
|
)}
|
||||||
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
|
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
||||||
</div>
|
</div>
|
||||||
{!disableSelection && (
|
{!disableSelection && (
|
||||||
<div
|
<div
|
||||||
onClick={selectedModules.length === 0 || selectedModules.includes("level") ? () => toggleModule("level") : undefined}
|
onClick={selectedModules.length === 0 || selectedModules.includes("level") ? () => toggleModule("level") : undefined}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||||
selectedModules.includes("level") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
selectedModules.includes("level") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||||
)}>
|
)}>
|
||||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-0 -translate-y-1/2">
|
<div className="bg-ielts-level absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||||
<BsClipboard className="text-white w-7 h-7" />
|
<BsClipboard className="h-7 w-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold">Level:</span>
|
<span className="font-semibold">Level:</span>
|
||||||
<p className="text-center text-xs">You'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 && (
|
{!selectedModules.includes("level") && selectedModules.length === 0 && !disableSelection && (
|
||||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||||
)}
|
)}
|
||||||
{(selectedModules.includes("level") || disableSelection) && (
|
{(selectedModules.includes("level") || disableSelection) && (
|
||||||
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
|
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||||
)}
|
)}
|
||||||
{!selectedModules.includes("level") && selectedModules.length > 0 && (
|
{!selectedModules.includes("level") && selectedModules.length > 0 && (
|
||||||
<BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />
|
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
<div className="flex w-full -md:flex-col -md:gap-4 -md:justify-center md:justify-between items-center">
|
<div className="-md:flex-col -md:gap-4 -md:justify-center flex w-full items-center md:justify-between">
|
||||||
|
<div className="flex w-full flex-col items-center gap-3">
|
||||||
<div
|
<div
|
||||||
className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer tooltip w-full -md:justify-center"
|
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
|
||||||
data-tip="If possible, the platform will choose exams not yet done"
|
|
||||||
onClick={() => setAvoidRepeatedExams((prev) => !prev)}>
|
onClick={() => setAvoidRepeatedExams((prev) => !prev)}>
|
||||||
<input type="checkbox" className="hidden" />
|
<input type="checkbox" className="hidden" />
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
|
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
avoidRepeatedExams && "!bg-mti-purple-light ",
|
avoidRepeatedExams && "!bg-mti-purple-light ",
|
||||||
)}>
|
)}>
|
||||||
<BsCheck color="white" className="w-full h-full" />
|
<BsCheck color="white" className="h-full w-full" />
|
||||||
|
</div>
|
||||||
|
<span className="tooltip" data-tip="If possible, the platform will choose exams not yet done.">
|
||||||
|
Avoid Repeated Questions
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
|
||||||
|
onClick={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
|
||||||
|
<input type="checkbox" className="hidden" />
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
variant === "full" && "!bg-mti-purple-light ",
|
||||||
|
)}>
|
||||||
|
<BsCheck color="white" className="h-full w-full" />
|
||||||
|
</div>
|
||||||
|
<span>Full length exams</span>
|
||||||
</div>
|
</div>
|
||||||
<span>Avoid Repeated Questions</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="tooltip w-full" data-tip={`Your screen size is too small to do ${page}`}>
|
<div className="tooltip w-full" data-tip={`Your screen size is too small to do ${page}`}>
|
||||||
<Button color="purple" className="px-12 w-full max-w-xs md:hidden" disabled>
|
<Button color="purple" className="w-full max-w-xs px-12 md:hidden" disabled>
|
||||||
Start Exam
|
Start Exam
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -227,10 +290,11 @@ export default function Selection({user, page, onStart, disableSelection = false
|
|||||||
onStart(
|
onStart(
|
||||||
!disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"],
|
!disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"],
|
||||||
avoidRepeatedExams,
|
avoidRepeatedExams,
|
||||||
|
variant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
color="purple"
|
color="purple"
|
||||||
className="px-12 w-full max-w-xs md:self-end -md:hidden"
|
className="-md:hidden w-full max-w-xs px-12 md:self-end"
|
||||||
disabled={selectedModules.length === 0 && !disableSelection}>
|
disabled={selectedModules.length === 0 && !disableSelection}>
|
||||||
Start Exam
|
Start Exam
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -22,22 +22,26 @@ interface Props {
|
|||||||
export default function Speaking({exam, showSolutions = false, onFinish}: Props) {
|
export default function Speaking({exam, showSolutions = false, onFinish}: Props) {
|
||||||
const [questionIndex, setQuestionIndex] = useState(0);
|
const [questionIndex, setQuestionIndex] = useState(0);
|
||||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||||
const [exerciseIndex, setExerciseIndex] = useState(0);
|
|
||||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
|
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||||
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
|
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
||||||
|
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
||||||
|
|
||||||
|
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentQuestionIndex(0);
|
setCurrentQuestionIndex(0);
|
||||||
}, [questionIndex]);
|
}, [questionIndex]);
|
||||||
|
|
||||||
const nextExercise = (solution?: UserSolution) => {
|
const nextExercise = (solution?: UserSolution) => {
|
||||||
|
scrollToTop();
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
}
|
}
|
||||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.exercises.length) {
|
if (exerciseIndex + 1 < exam.exercises.length) {
|
||||||
setExerciseIndex((prev) => prev + 1);
|
setExerciseIndex(exerciseIndex + 1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,12 +59,13 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
|||||||
};
|
};
|
||||||
|
|
||||||
const previousExercise = (solution?: UserSolution) => {
|
const previousExercise = (solution?: UserSolution) => {
|
||||||
|
scrollToTop();
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exerciseIndex > 0) {
|
if (exerciseIndex > 0) {
|
||||||
setExerciseIndex((prev) => prev - 1);
|
setExerciseIndex(exerciseIndex - 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -86,7 +91,7 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
|||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
exerciseIndex < exam.exercises.length &&
|
exerciseIndex < exam.exercises.length &&
|
||||||
!showSolutions &&
|
!showSolutions &&
|
||||||
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
|
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
exerciseIndex < exam.exercises.length &&
|
exerciseIndex < exam.exercises.length &&
|
||||||
showSolutions &&
|
showSolutions &&
|
||||||
|
|||||||
@@ -19,18 +19,20 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Writing({exam, showSolutions = false, onFinish}: Props) {
|
export default function Writing({exam, showSolutions = false, onFinish}: Props) {
|
||||||
const [exerciseIndex, setExerciseIndex] = useState(0);
|
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
|
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
||||||
|
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
||||||
|
|
||||||
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
|
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||||
|
|
||||||
const nextExercise = (solution?: UserSolution) => {
|
const nextExercise = (solution?: UserSolution) => {
|
||||||
|
scrollToTop();
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.exercises.length) {
|
if (exerciseIndex + 1 < exam.exercises.length) {
|
||||||
setExerciseIndex((prev) => prev + 1);
|
setExerciseIndex(exerciseIndex + 1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,12 +50,13 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
|
|||||||
};
|
};
|
||||||
|
|
||||||
const previousExercise = (solution?: UserSolution) => {
|
const previousExercise = (solution?: UserSolution) => {
|
||||||
|
scrollToTop();
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exerciseIndex > 0) {
|
if (exerciseIndex > 0) {
|
||||||
setExerciseIndex((prev) => prev - 1);
|
setExerciseIndex(exerciseIndex - 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,7 +81,7 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
|
|||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
exerciseIndex < exam.exercises.length &&
|
exerciseIndex < exam.exercises.length &&
|
||||||
!showSolutions &&
|
!showSolutions &&
|
||||||
renderExercise(getExercise(), nextExercise, previousExercise)}
|
renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
|
||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
exerciseIndex < exam.exercises.length &&
|
exerciseIndex < exam.exercises.length &&
|
||||||
showSolutions &&
|
showSolutions &&
|
||||||
|
|||||||
@@ -1,24 +1,17 @@
|
|||||||
/* eslint-disable jsx-a11y/alt-text */
|
/* eslint-disable jsx-a11y/alt-text */
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { View, Text, Image } from "@react-pdf/renderer";
|
import {View, Text, Image} from "@react-pdf/renderer";
|
||||||
import { styles } from "../styles";
|
import {styles} from "../styles";
|
||||||
import { ModuleScore } from "@/interfaces/module.scores";
|
import {ModuleScore} from "@/interfaces/module.scores";
|
||||||
|
|
||||||
export const RadialResult = ({
|
export const RadialResult = ({module, score, total, png}: ModuleScore) => (
|
||||||
module,
|
|
||||||
score,
|
|
||||||
total,
|
|
||||||
png,
|
|
||||||
}: ModuleScore) => (
|
|
||||||
<View style={[styles.textFont, styles.radialContainer]}>
|
<View style={[styles.textFont, styles.radialContainer]}>
|
||||||
<Text style={[styles.textColor, styles.textBold, { fontSize: 10 }]}>
|
<Text style={[styles.textColor, styles.textBold, {fontSize: 10}]}>{module}</Text>
|
||||||
{module}
|
|
||||||
</Text>
|
|
||||||
<Image src={png} style={styles.image64}></Image>
|
<Image src={png} style={styles.image64}></Image>
|
||||||
<View style={[styles.textColor, styles.radialResultContainer]}>
|
<View style={[styles.textColor, styles.radialResultContainer]}>
|
||||||
<Text style={styles.textBold}>{score}</Text>
|
<Text style={styles.textBold}>{score.toFixed(2)}</Text>
|
||||||
<Text style={{ fontSize: 8 }}>out of {total}</Text>
|
<Text style={{fontSize: 8}}>out of {total}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { View, StyleSheet } from "@react-pdf/renderer";
|
import {View, StyleSheet} from "@react-pdf/renderer";
|
||||||
import { ModuleScore } from "@/interfaces/module.scores";
|
import {ModuleScore} from "@/interfaces/module.scores";
|
||||||
import { RadialResult } from "./radial.result";
|
import {RadialResult} from "./radial.result";
|
||||||
interface Props {
|
interface Props {
|
||||||
testDetails: ModuleScore[];
|
testDetails: ModuleScore[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const customStyles = StyleSheet.create({
|
const customStyles = StyleSheet.create({
|
||||||
container: { display: "flex", flexDirection: "row", gap: 30 },
|
container: {display: "flex", flexDirection: "row", gap: 30},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SkillExamDetails = ({ testDetails }: Props) => (
|
export const SkillExamDetails = ({testDetails}: Props) => (
|
||||||
<View style={customStyles.container}>
|
<View style={customStyles.container}>
|
||||||
{testDetails.map((detail) => {
|
{testDetails.map((detail) => {
|
||||||
const { module } = detail;
|
const {module} = detail;
|
||||||
return <RadialResult key={module} {...detail} />;
|
return <RadialResult key={module} {...detail} />;
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -112,11 +112,6 @@ const GroupTestReport = ({
|
|||||||
Candidate Information:
|
Candidate Information:
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.textMargin}>
|
<View style={styles.textMargin}>
|
||||||
<Text style={defaultTextStyle}>Name: {name}</Text>
|
|
||||||
<Text style={defaultTextStyle}>ID: {id}</Text>
|
|
||||||
<Text style={defaultTextStyle}>Email: {email}</Text>
|
|
||||||
<Text style={defaultTextStyle}>Gender: {gender}</Text>
|
|
||||||
<Text style={defaultTextStyle}>Passport ID: {passportId}</Text>
|
|
||||||
<Text style={defaultTextStyle}>
|
<Text style={defaultTextStyle}>
|
||||||
Total Number of Students: {numberOfStudents}
|
Total Number of Students: {numberOfStudents}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -242,10 +237,10 @@ const GroupTestReport = ({
|
|||||||
Sr
|
Sr
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={customStyles.tableCell}>Candidate Name</Text>
|
<Text style={customStyles.tableCell}>Candidate Name</Text>
|
||||||
<Text style={customStyles.tableCell}>Email ID</Text>
|
<Text style={customStyles.tableCell}>
|
||||||
<Text style={[customStyles.tableCell, { maxWidth: "48px" }]}>
|
Passport ID
|
||||||
Gender
|
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text style={customStyles.tableCell}>Email ID</Text>
|
||||||
<Text style={[customStyles.tableCell, { maxWidth: "64px" }]}>
|
<Text style={[customStyles.tableCell, { maxWidth: "64px" }]}>
|
||||||
Date of test
|
Date of test
|
||||||
</Text>
|
</Text>
|
||||||
@@ -255,7 +250,19 @@ const GroupTestReport = ({
|
|||||||
{showLevel && <Text style={customStyles.tableCell}>Level</Text>}
|
{showLevel && <Text style={customStyles.tableCell}>Level</Text>}
|
||||||
</View>
|
</View>
|
||||||
{studentsData.map(
|
{studentsData.map(
|
||||||
({ id, name, email, gender, date, result, level }, index) => (
|
(
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
gender,
|
||||||
|
date,
|
||||||
|
result,
|
||||||
|
level,
|
||||||
|
passportId: studentPassportId,
|
||||||
|
},
|
||||||
|
index
|
||||||
|
) => (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
customStyles.tableRow,
|
customStyles.tableRow,
|
||||||
@@ -273,10 +280,8 @@ const GroupTestReport = ({
|
|||||||
{index + 1}
|
{index + 1}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={customStyles.tableCell}>{name}</Text>
|
<Text style={customStyles.tableCell}>{name}</Text>
|
||||||
|
<Text style={customStyles.tableCell}>{studentPassportId}</Text>
|
||||||
<Text style={customStyles.tableCell}>{email}</Text>
|
<Text style={customStyles.tableCell}>{email}</Text>
|
||||||
<Text style={[customStyles.tableCell, { maxWidth: "48px" }]}>
|
|
||||||
{gender}
|
|
||||||
</Text>
|
|
||||||
<Text style={[customStyles.tableCell, { maxWidth: "64px" }]}>
|
<Text style={[customStyles.tableCell, { maxWidth: "64px" }]}>
|
||||||
{date}
|
{date}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import React from "react";
|
|||||||
import { styles } from "./styles";
|
import { styles } from "./styles";
|
||||||
import { View, Text } from "@react-pdf/renderer";
|
import { View, Text } from "@react-pdf/renderer";
|
||||||
|
|
||||||
const TestReportFooter = () => (
|
interface Props {
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TestReportFooter = ({ userId }: Props) => (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
@@ -25,10 +29,15 @@ const TestReportFooter = () => (
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View>
|
<View>
|
||||||
<Text style={styles.textBold}>Confidential – <Text style={[styles.textFont, styles.textNormal]}>circulated for concern people</Text></Text>
|
<Text style={styles.textBold}>
|
||||||
|
Confidential –{" "}
|
||||||
|
<Text style={[styles.textFont, styles.textNormal]}>
|
||||||
|
circulated for concern people
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View style={{ paddingTop: 10 }}>
|
<View>
|
||||||
<Text style={styles.textBold}>Declaration</Text>
|
<Text style={styles.textBold}>Declaration</Text>
|
||||||
<Text style={{ paddingTop: 5 }}>
|
<Text style={{ paddingTop: 5 }}>
|
||||||
We hereby declare that exam results on our platform, assessed by AI, are
|
We hereby declare that exam results on our platform, assessed by AI, are
|
||||||
@@ -40,6 +49,22 @@ const TestReportFooter = () => (
|
|||||||
continuously enhance our system to ensure accuracy and reliability.
|
continuously enhance our system to ensure accuracy and reliability.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<View style={{ paddingTop: 4 }}>
|
||||||
|
<Text style={styles.textBold}>
|
||||||
|
PDF Version:{" "}
|
||||||
|
<Text style={[styles.textFont, styles.textNormal]}>
|
||||||
|
{process.env.PDF_VERSION}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{userId && (
|
||||||
|
<View>
|
||||||
|
<Text style={styles.textBold}>
|
||||||
|
User ID:{" "}
|
||||||
|
<Text style={[styles.textFont, styles.textNormal]}>{userId}</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
<View style={[styles.textColor, { paddingTop: 5 }]}>
|
<View style={[styles.textColor, { paddingTop: 5 }]}>
|
||||||
<Text style={styles.textUnderline}>info@encoach.com</Text>
|
<Text style={styles.textUnderline}>info@encoach.com</Text>
|
||||||
<Text>https://encoach.com</Text>
|
<Text>https://encoach.com</Text>
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ const TestReport = ({
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View style={[{ paddingTop: 30 }, styles.separator]}></View>
|
<View style={[{ paddingTop: 30 }, styles.separator]}></View>
|
||||||
<TestReportFooter />
|
<TestReportFooter userId={id}/>
|
||||||
</Page>
|
</Page>
|
||||||
<Page style={styles.body}>
|
<Page style={styles.body}>
|
||||||
<View>
|
<View>
|
||||||
@@ -165,7 +165,7 @@ const TestReport = ({
|
|||||||
</View>
|
</View>
|
||||||
<View style={[{ paddingBottom: 30 }, styles.separator]}></View>
|
<View style={[{ paddingBottom: 30 }, styles.separator]}></View>
|
||||||
<View style={{ flexGrow: 1 }}></View>
|
<View style={{ flexGrow: 1 }}></View>
|
||||||
<TestReportFooter />
|
<TestReportFooter userId={id}/>
|
||||||
</Page>
|
</Page>
|
||||||
</Document>
|
</Document>
|
||||||
);
|
);
|
||||||
|
|||||||
21
src/hooks/useCodes.tsx
Normal file
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,8 @@
|
|||||||
import {Module} from ".";
|
import {Module} from ".";
|
||||||
|
|
||||||
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
|
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
|
||||||
export type Variant = "diagnostic" | "partial";
|
export type Variant = "full" | "partial";
|
||||||
|
export type InstructorGender = "male" | "female" | "varied";
|
||||||
|
|
||||||
export interface ReadingExam {
|
export interface ReadingExam {
|
||||||
parts: ReadingPart[];
|
parts: ReadingPart[];
|
||||||
@@ -82,6 +83,7 @@ export interface SpeakingExam {
|
|||||||
minTimer: number;
|
minTimer: number;
|
||||||
isDiagnostic: boolean;
|
isDiagnostic: boolean;
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
|
instructorGender: InstructorGender;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Exercise =
|
export type Exercise =
|
||||||
@@ -103,13 +105,24 @@ export interface Evaluation {
|
|||||||
|
|
||||||
interface InteractiveSpeakingEvaluation extends Evaluation {
|
interface InteractiveSpeakingEvaluation extends Evaluation {
|
||||||
perfect_answer_1?: string;
|
perfect_answer_1?: string;
|
||||||
|
transcript_1?: string;
|
||||||
|
fixed_text_1?: string;
|
||||||
perfect_answer_2?: string;
|
perfect_answer_2?: string;
|
||||||
|
transcript_2?: string;
|
||||||
|
fixed_text_2?: string;
|
||||||
perfect_answer_3?: string;
|
perfect_answer_3?: string;
|
||||||
|
transcript_3?: string;
|
||||||
|
fixed_text_3?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpeakingEvaluation extends CommonEvaluation {
|
||||||
|
perfect_answer_1?: string;
|
||||||
|
transcript_1?: string;
|
||||||
|
fixed_text_1?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CommonEvaluation extends Evaluation {
|
interface CommonEvaluation extends Evaluation {
|
||||||
perfect_answer?: string;
|
perfect_answer?: string;
|
||||||
perfect_answer_1?: string;
|
|
||||||
fixed_text?: string;
|
fixed_text?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +154,7 @@ export interface SpeakingExercise {
|
|||||||
userSolutions: {
|
userSolutions: {
|
||||||
id: string;
|
id: string;
|
||||||
solution: string;
|
solution: string;
|
||||||
evaluation?: CommonEvaluation;
|
evaluation?: SpeakingEvaluation;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +166,7 @@ export interface InteractiveSpeakingExercise {
|
|||||||
prompts: {text: string; video_url: string}[];
|
prompts: {text: string; video_url: string}[];
|
||||||
userSolutions: {
|
userSolutions: {
|
||||||
id: string;
|
id: string;
|
||||||
solution: {question: string; answer: string}[];
|
solution: {questionIndex: number; question: string; answer: string}[];
|
||||||
evaluation?: InteractiveSpeakingEvaluation;
|
evaluation?: InteractiveSpeakingEvaluation;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|||||||
5
src/interfaces/invite.ts
Normal file
5
src/interfaces/invite.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export interface Invite {
|
||||||
|
id: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
@@ -4,13 +4,13 @@ export interface ModuleScore {
|
|||||||
score: number;
|
score: number;
|
||||||
total: number;
|
total: number;
|
||||||
code: Module;
|
code: Module;
|
||||||
module: Module | 'Overall';
|
module: Module | "Overall";
|
||||||
png?: string,
|
png?: string;
|
||||||
evaluation?: string,
|
evaluation?: string;
|
||||||
suggestions?: string,
|
suggestions?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StudentData {
|
export interface StudentData {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -19,4 +19,5 @@ export interface ModuleScore {
|
|||||||
result: string;
|
result: string;
|
||||||
level?: string;
|
level?: string;
|
||||||
bandScore: number;
|
bandScore: number;
|
||||||
}
|
passportId?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
|
import {InstructorGender} from "./exam";
|
||||||
import {Stat} from "./user";
|
import {Stat} from "./user";
|
||||||
|
|
||||||
export type UserResults = {[key in Module]: ModuleResult};
|
export type UserResults = {[key in Module]: ModuleResult};
|
||||||
@@ -19,7 +20,8 @@ export interface Assignment {
|
|||||||
type: "academic" | "general";
|
type: "academic" | "general";
|
||||||
stats: Stat[];
|
stats: Stat[];
|
||||||
}[];
|
}[];
|
||||||
exams: {id: string; module: Module, assignee: string}[];
|
exams: {id: string; module: Module; assignee: string}[];
|
||||||
|
instructorGender?: InstructorGender;
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
endDate: Date;
|
endDate: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
34
src/interfaces/ticket.ts
Normal file
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",
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {Module} from ".";
|
import {Module} from ".";
|
||||||
|
import {InstructorGender} from "./exam";
|
||||||
|
|
||||||
export type User = StudentUser | TeacherUser | CorporateUser | AgentUser | AdminUser | DeveloperUser;
|
export type User = StudentUser | TeacherUser | CorporateUser | AgentUser | AdminUser | DeveloperUser;
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ export interface BasicUser {
|
|||||||
|
|
||||||
export interface StudentUser extends BasicUser {
|
export interface StudentUser extends BasicUser {
|
||||||
type: "student";
|
type: "student";
|
||||||
|
preferredGender?: InstructorGender;
|
||||||
demographicInformation?: DemographicInformation;
|
demographicInformation?: DemographicInformation;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +50,7 @@ export interface AdminUser extends BasicUser {
|
|||||||
|
|
||||||
export interface DeveloperUser extends BasicUser {
|
export interface DeveloperUser extends BasicUser {
|
||||||
type: "developer";
|
type: "developer";
|
||||||
|
preferredGender?: InstructorGender;
|
||||||
demographicInformation?: DemographicInformation;
|
demographicInformation?: DemographicInformation;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,5 +130,16 @@ export interface Group {
|
|||||||
disableEditing?: boolean;
|
disableEditing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Code {
|
||||||
|
code: string;
|
||||||
|
creator: string;
|
||||||
|
expiryDate: Date;
|
||||||
|
type: Type;
|
||||||
|
userId?: string;
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
passport_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent";
|
export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent";
|
||||||
export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent"];
|
export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent"];
|
||||||
|
|||||||
@@ -1,25 +1,27 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {Type, User} from "@/interfaces/user";
|
import { Type, User } from "@/interfaces/user";
|
||||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize, uniqBy} from "lodash";
|
import { capitalize, uniqBy } from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {useEffect, useState} from "react";
|
import { useEffect, useState } from "react";
|
||||||
import ReactDatePicker from "react-datepicker";
|
import ReactDatePicker from "react-datepicker";
|
||||||
import {toast} from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import ShortUniqueId from "short-unique-id";
|
import ShortUniqueId from "short-unique-id";
|
||||||
import {useFilePicker} from "use-file-picker";
|
import { useFilePicker } from "use-file-picker";
|
||||||
import readXlsxFile from "read-excel-file";
|
import readXlsxFile from "read-excel-file";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import {BsQuestionCircleFill} from "react-icons/bs";
|
import { BsFileEarmarkEaselFill, BsQuestionCircleFill } from "react-icons/bs";
|
||||||
|
|
||||||
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
const EMAIL_REGEX = new RegExp(
|
||||||
|
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/,
|
||||||
|
);
|
||||||
|
|
||||||
const USER_TYPE_PERMISSIONS: {[key in Type]: Type[]} = {
|
const USER_TYPE_PERMISSIONS: { [key in Type]: Type[] } = {
|
||||||
student: [],
|
student: [],
|
||||||
teacher: [],
|
teacher: [],
|
||||||
agent: [],
|
agent: [],
|
||||||
@@ -28,17 +30,19 @@ const USER_TYPE_PERMISSIONS: {[key in Type]: Type[]} = {
|
|||||||
developer: ["student", "teacher", "agent", "corporate", "admin", "developer"],
|
developer: ["student", "teacher", "agent", "corporate", "admin", "developer"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function BatchCodeGenerator({user}: {user: User}) {
|
export default function BatchCodeGenerator({ user }: { user: User }) {
|
||||||
const [infos, setInfos] = useState<{email: string; name: string; passport_id: string}[]>([]);
|
const [infos, setInfos] = useState<
|
||||||
|
{ email: string; name: string; passport_id: string }[]
|
||||||
|
>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
|
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
|
||||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||||
const [type, setType] = useState<Type>("student");
|
const [type, setType] = useState<Type>("student");
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
|
|
||||||
const {users} = useUsers();
|
const { users } = useUsers();
|
||||||
|
|
||||||
const {openFilePicker, filesContent, clear} = useFilePicker({
|
const { openFilePicker, filesContent, clear } = useFilePicker({
|
||||||
accept: ".xlsx",
|
accept: ".xlsx",
|
||||||
multiple: false,
|
multiple: false,
|
||||||
readAs: "ArrayBuffer",
|
readAs: "ArrayBuffer",
|
||||||
@@ -47,6 +51,7 @@ export default function BatchCodeGenerator({user}: {user: User}) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user && (user.type === "corporate" || user.type === "teacher")) {
|
if (user && (user.type === "corporate" || user.type === "teacher")) {
|
||||||
setExpiryDate(user.subscriptionExpirationDate || null);
|
setExpiryDate(user.subscriptionExpirationDate || null);
|
||||||
|
setIsExpiryDateEnabled(!!user.subscriptionExpirationDate);
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
@@ -58,15 +63,23 @@ export default function BatchCodeGenerator({user}: {user: User}) {
|
|||||||
if (filesContent.length > 0) {
|
if (filesContent.length > 0) {
|
||||||
const file = filesContent[0];
|
const file = filesContent[0];
|
||||||
readXlsxFile(file.content).then((rows) => {
|
readXlsxFile(file.content).then((rows) => {
|
||||||
|
try {
|
||||||
const information = uniqBy(
|
const information = uniqBy(
|
||||||
rows
|
rows
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
const [firstName, lastName, country, passport_id, email, phone] = row as string[];
|
const [
|
||||||
return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email)
|
firstName,
|
||||||
|
lastName,
|
||||||
|
country,
|
||||||
|
passport_id,
|
||||||
|
email,
|
||||||
|
...phone
|
||||||
|
] = row as string[];
|
||||||
|
return EMAIL_REGEX.test(email.toString().trim())
|
||||||
? {
|
? {
|
||||||
email: email.toString(),
|
email: email.toString().trim().toLowerCase(),
|
||||||
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
|
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
|
||||||
passport_id: passport_id.toString(),
|
passport_id: passport_id?.toString().trim() || undefined,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
})
|
})
|
||||||
@@ -82,53 +95,128 @@ export default function BatchCodeGenerator({user}: {user: User}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setInfos(information);
|
setInfos(information);
|
||||||
|
} catch {
|
||||||
|
toast.error(
|
||||||
|
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
|
||||||
|
);
|
||||||
|
return clear();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [filesContent]);
|
}, [filesContent]);
|
||||||
|
|
||||||
const generateCode = (type: Type) => {
|
const generateAndInvite = async () => {
|
||||||
|
const newUsers = infos.filter(
|
||||||
|
(x) => !users.map((u) => u.email).includes(x.email),
|
||||||
|
);
|
||||||
|
const existingUsers = infos
|
||||||
|
.filter((x) => users.map((u) => u.email).includes(x.email))
|
||||||
|
.map((i) => users.find((u) => u.email === i.email))
|
||||||
|
.filter((x) => !!x && x.type === "student") as User[];
|
||||||
|
|
||||||
|
const newUsersSentence =
|
||||||
|
newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined;
|
||||||
|
const existingUsersSentence =
|
||||||
|
existingUsers.length > 0
|
||||||
|
? `invite ${existingUsers.length} registered student(s)`
|
||||||
|
: undefined;
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
Promise.all(
|
||||||
|
existingUsers.map(
|
||||||
|
async (u) =>
|
||||||
|
await axios.post(`/api/invites`, { to: u.id, from: user.id }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then(() =>
|
||||||
|
toast.success(
|
||||||
|
`Successfully invited ${existingUsers.length} registered student(s)!`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.finally(() => {
|
||||||
|
if (newUsers.length === 0) setIsLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newUsers.length > 0) generateCode(type, newUsers);
|
||||||
|
setInfos([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateCode = (type: Type, informations: typeof infos) => {
|
||||||
const uid = new ShortUniqueId();
|
const uid = new ShortUniqueId();
|
||||||
const codes = infos.map(() => uid.randomUUID(6));
|
const codes = informations.map(() => uid.randomUUID(6));
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.post("/api/code", {type, codes, infos: infos, expiryDate})
|
.post<{ ok: boolean; valid?: number; reason?: string }>("/api/code", {
|
||||||
.then(({data, status}) => {
|
type,
|
||||||
|
codes,
|
||||||
|
infos: informations,
|
||||||
|
expiryDate,
|
||||||
|
})
|
||||||
|
.then(({ data, status }) => {
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
toast.success(`Successfully generated ${capitalize(type)} codes and they have been notified by e-mail!`, {toastId: "success"});
|
toast.success(
|
||||||
|
`Successfully generated${data.valid ? ` ${data.valid}/${informations.length}` : ""} ${capitalize(
|
||||||
|
type,
|
||||||
|
)} codes and they have been notified by e-mail!`,
|
||||||
|
{ toastId: "success" },
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 403) {
|
if (status === 403) {
|
||||||
toast.error(data.reason, {toastId: "forbidden"});
|
toast.error(data.reason, { toastId: "forbidden" });
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(({response: {status, data}}) => {
|
.catch(({ response: { status, data } }) => {
|
||||||
if (status === 403) {
|
if (status === 403) {
|
||||||
toast.error(data.reason, {toastId: "forbidden"});
|
toast.error(data.reason, { toastId: "forbidden" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error(`Something went wrong, please try again later!`, {toastId: "error"});
|
toast.error(`Something went wrong, please try again later!`, {
|
||||||
|
toastId: "error",
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
return clear();
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal isOpen={showHelp} onClose={() => setShowHelp(false)} title="Excel File Format">
|
<Modal
|
||||||
|
isOpen={showHelp}
|
||||||
|
onClose={() => setShowHelp(false)}
|
||||||
|
title="Excel File Format"
|
||||||
|
>
|
||||||
<div className="mt-4 flex flex-col gap-2">
|
<div className="mt-4 flex flex-col gap-2">
|
||||||
<span>Please upload an Excel file with the following format:</span>
|
<span>Please upload an Excel file with the following format:</span>
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="border border-neutral-200 px-2 py-1">First Name</th>
|
<th className="border border-neutral-200 px-2 py-1">
|
||||||
<th className="border border-neutral-200 px-2 py-1">Last Name</th>
|
First Name
|
||||||
|
</th>
|
||||||
|
<th className="border border-neutral-200 px-2 py-1">
|
||||||
|
Last Name
|
||||||
|
</th>
|
||||||
<th className="border border-neutral-200 px-2 py-1">Country</th>
|
<th className="border border-neutral-200 px-2 py-1">Country</th>
|
||||||
<th className="border border-neutral-200 px-2 py-1">Passport/National ID</th>
|
<th className="border border-neutral-200 px-2 py-1">
|
||||||
|
Passport/National ID
|
||||||
|
</th>
|
||||||
<th className="border border-neutral-200 px-2 py-1">E-mail</th>
|
<th className="border border-neutral-200 px-2 py-1">E-mail</th>
|
||||||
<th className="border border-neutral-200 px-2 py-1">Phone Number</th>
|
<th className="border border-neutral-200 px-2 py-1">
|
||||||
|
Phone Number
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
</table>
|
</table>
|
||||||
@@ -137,34 +225,55 @@ export default function BatchCodeGenerator({user}: {user: User}) {
|
|||||||
<ul>
|
<ul>
|
||||||
<li>- All incorrect e-mails will be ignored;</li>
|
<li>- All incorrect e-mails will be ignored;</li>
|
||||||
<li>- All already registered e-mails will be ignored;</li>
|
<li>- All already registered e-mails will be ignored;</li>
|
||||||
<li>- You may have a header row with the format above, however, it is not necessary;</li>
|
<li>
|
||||||
<li>- All of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below.</li>
|
- You may have a header row with the format above, however, it
|
||||||
|
is not necessary;
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
- All of the e-mails in the file will receive an e-mail to join
|
||||||
|
EnCoach with the role selected below.
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
|
||||||
<div className="flex justify-between items-end">
|
<div className="flex items-end justify-between">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Choose an Excel file</label>
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
<div className="cursor-pointer tooltip" data-tip="Excel File Format" onClick={() => setShowHelp(true)}>
|
Choose an Excel file
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="tooltip cursor-pointer"
|
||||||
|
data-tip="Excel File Format"
|
||||||
|
onClick={() => setShowHelp(true)}
|
||||||
|
>
|
||||||
<BsQuestionCircleFill />
|
<BsQuestionCircleFill />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
|
<Button
|
||||||
|
onClick={openFilePicker}
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
||||||
</Button>
|
</Button>
|
||||||
{user && (user.type === "developer" || user.type === "admin") && (
|
{user && (user.type === "developer" || user.type === "admin") && (
|
||||||
<>
|
<>
|
||||||
<div className="flex -md:flex-row md:flex-col -md:items-center 2xl:flex-row 2xl:items-center justify-between gap-2">
|
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled}>
|
Expiry Date
|
||||||
|
</label>
|
||||||
|
<Checkbox
|
||||||
|
isChecked={isExpiryDateEnabled}
|
||||||
|
onChange={setIsExpiryDateEnabled}
|
||||||
|
>
|
||||||
Enabled
|
Enabled
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
{isExpiryDateEnabled && (
|
{isExpiryDateEnabled && (
|
||||||
<ReactDatePicker
|
<ReactDatePicker
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||||
"hover:border-mti-purple tooltip",
|
"hover:border-mti-purple tooltip",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
)}
|
)}
|
||||||
@@ -176,14 +285,19 @@ export default function BatchCodeGenerator({user}: {user: User}) {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Select the type of user they should be</label>
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
|
Select the type of user they should be
|
||||||
|
</label>
|
||||||
{user && (
|
{user && (
|
||||||
<select
|
<select
|
||||||
defaultValue="student"
|
defaultValue="student"
|
||||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||||
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none"
|
||||||
|
>
|
||||||
{Object.keys(USER_TYPE_LABELS)
|
{Object.keys(USER_TYPE_LABELS)
|
||||||
.filter((x) => USER_TYPE_PERMISSIONS[user.type].includes(x as Type))
|
.filter((x) =>
|
||||||
|
USER_TYPE_PERMISSIONS[user.type].includes(x as Type),
|
||||||
|
)
|
||||||
.map((type) => (
|
.map((type) => (
|
||||||
<option key={type} value={type}>
|
<option key={type} value={type}>
|
||||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||||
@@ -191,7 +305,12 @@ export default function BatchCodeGenerator({user}: {user: User}) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
<Button onClick={() => generateCode(type)} disabled={infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}>
|
<Button
|
||||||
|
onClick={generateAndInvite}
|
||||||
|
disabled={
|
||||||
|
infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)
|
||||||
|
}
|
||||||
|
>
|
||||||
Generate & Send
|
Generate & Send
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,26 +1,28 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {Module} from "@/interfaces";
|
import { Group, User } from "@/interfaces/user";
|
||||||
import {Group, User} from "@/interfaces/user";
|
import {
|
||||||
import {Disclosure, Transition} from "@headlessui/react";
|
createColumnHelper,
|
||||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import { capitalize, uniq } from "lodash";
|
||||||
import {capitalize, uniq, uniqBy} from "lodash";
|
import { useEffect, useState } from "react";
|
||||||
import {useEffect, useRef, useState} from "react";
|
import { BsPencil, BsQuestionCircleFill, BsTrash } from "react-icons/bs";
|
||||||
import {BsCheck, BsDash, BsPencil, BsPlus, BsQuestionCircleFill, BsTrash} from "react-icons/bs";
|
|
||||||
import {toast} from "react-toastify";
|
|
||||||
import Select from "react-select";
|
import Select from "react-select";
|
||||||
import {uuidv4} from "@firebase/util";
|
import { toast } from "react-toastify";
|
||||||
import {useFilePicker} from "use-file-picker";
|
|
||||||
import Modal from "@/components/Modal";
|
|
||||||
import readXlsxFile from "read-excel-file";
|
import readXlsxFile from "read-excel-file";
|
||||||
|
import { useFilePicker } from "use-file-picker";
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<Group>();
|
const columnHelper = createColumnHelper<Group>();
|
||||||
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
const EMAIL_REGEX = new RegExp(
|
||||||
|
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/,
|
||||||
|
);
|
||||||
|
|
||||||
interface CreateDialogProps {
|
interface CreateDialogProps {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -29,11 +31,17 @@ interface CreateDialogProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
||||||
const [name, setName] = useState<string | undefined>(group?.name || undefined);
|
const [name, setName] = useState<string | undefined>(
|
||||||
|
group?.name || undefined,
|
||||||
|
);
|
||||||
const [admin, setAdmin] = useState<string>(group?.admin || user.id);
|
const [admin, setAdmin] = useState<string>(group?.admin || user.id);
|
||||||
const [participants, setParticipants] = useState<string[]>(group?.participants || []);
|
const [participants, setParticipants] = useState<string[]>(
|
||||||
const {openFilePicker, filesContent, clear} = useFilePicker({
|
group?.participants || [],
|
||||||
|
);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const { openFilePicker, filesContent, clear } = useFilePicker({
|
||||||
accept: ".xlsx",
|
accept: ".xlsx",
|
||||||
multiple: false,
|
multiple: false,
|
||||||
readAs: "ArrayBuffer",
|
readAs: "ArrayBuffer",
|
||||||
@@ -41,13 +49,18 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (filesContent.length > 0) {
|
if (filesContent.length > 0) {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
const file = filesContent[0];
|
const file = filesContent[0];
|
||||||
readXlsxFile(file.content).then((rows) => {
|
readXlsxFile(file.content).then((rows) => {
|
||||||
const emails = uniq(
|
const emails = uniq(
|
||||||
rows
|
rows
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
const [email] = row as string[];
|
const [email] = row as string[];
|
||||||
return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email) ? email.toString().trim() : undefined;
|
return EMAIL_REGEX.test(email) &&
|
||||||
|
!users.map((u) => u.email).includes(email)
|
||||||
|
? email.toString().trim()
|
||||||
|
: undefined;
|
||||||
})
|
})
|
||||||
.filter((x) => !!x),
|
.filter((x) => !!x),
|
||||||
);
|
);
|
||||||
@@ -55,13 +68,18 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
|||||||
if (emails.length === 0) {
|
if (emails.length === 0) {
|
||||||
toast.error("Please upload an Excel file containing e-mails!");
|
toast.error("Please upload an Excel file containing e-mails!");
|
||||||
clear();
|
clear();
|
||||||
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined);
|
const emailUsers = [...new Set(emails)]
|
||||||
|
.map((x) => users.find((y) => y.email.toLowerCase() === x))
|
||||||
|
.filter((x) => x !== undefined);
|
||||||
const filteredUsers = emailUsers.filter(
|
const filteredUsers = emailUsers.filter(
|
||||||
(x) =>
|
(x) =>
|
||||||
((user.type === "developer" || user.type === "admin" || user.type === "corporate") &&
|
((user.type === "developer" ||
|
||||||
|
user.type === "admin" ||
|
||||||
|
user.type === "corporate") &&
|
||||||
(x?.type === "student" || x?.type === "teacher")) ||
|
(x?.type === "student" || x?.type === "teacher")) ||
|
||||||
(user.type === "teacher" && x?.type === "student"),
|
(user.type === "teacher" && x?.type === "student"),
|
||||||
);
|
);
|
||||||
@@ -71,43 +89,70 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
|||||||
user.type !== "teacher"
|
user.type !== "teacher"
|
||||||
? "Added all teachers and students found in the file you've provided!"
|
? "Added all teachers and students found in the file you've provided!"
|
||||||
: "Added all students found in the file you've provided!",
|
: "Added all students found in the file you've provided!",
|
||||||
{toastId: "upload-success"},
|
{ toastId: "upload-success" },
|
||||||
);
|
);
|
||||||
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [filesContent, user.type, users]);
|
}, [filesContent, user.type, users]);
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
if (name !== group?.name && (name === "Students" || name === "Teachers")) {
|
if (name !== group?.name && (name === "Students" || name === "Teachers")) {
|
||||||
toast.error("That group name is reserved and cannot be used, please enter another one.");
|
toast.error(
|
||||||
|
"That group name is reserved and cannot be used, please enter another one.",
|
||||||
|
);
|
||||||
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
(group ? axios.patch : axios.post)(group ? `/api/groups/${group.id}` : "/api/groups", {name, admin, participants})
|
(group ? axios.patch : axios.post)(
|
||||||
|
group ? `/api/groups/${group.id}` : "/api/groups",
|
||||||
|
{ name, admin, participants },
|
||||||
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(`Group "${name}" ${group ? "edited" : "created"} successfully`);
|
toast.success(
|
||||||
|
`Group "${name}" ${group ? "edited" : "created"} successfully`,
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Something went wrong, please try again later!");
|
toast.error("Something went wrong, please try again later!");
|
||||||
return false;
|
return false;
|
||||||
})
|
})
|
||||||
.finally(onClose);
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
onClose();
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-12 mt-4 w-full px-4 py-2">
|
<div className="mt-4 flex w-full flex-col gap-12 px-4 py-2">
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
<Input name="name" type="text" label="Name" defaultValue={name} onChange={setName} required disabled={group?.disableEditing} />
|
<Input
|
||||||
<div className="flex flex-col gap-3 w-full">
|
name="name"
|
||||||
<div className="flex gap-2 items-center">
|
type="text"
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Participants</label>
|
label="Name"
|
||||||
<div className="tooltip" data-tip="The Excel file should only include a column with the desired e-mails.">
|
defaultValue={name}
|
||||||
|
onChange={setName}
|
||||||
|
required
|
||||||
|
disabled={group?.disableEditing}
|
||||||
|
/>
|
||||||
|
<div className="flex w-full flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
|
Participants
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="tooltip"
|
||||||
|
data-tip="The Excel file should only include a column with the desired e-mails."
|
||||||
|
>
|
||||||
<BsQuestionCircleFill />
|
<BsQuestionCircleFill />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-8 w-full">
|
<div className="flex w-full gap-8">
|
||||||
<Select
|
<Select
|
||||||
className="w-full"
|
className="w-full"
|
||||||
value={participants.map((x) => ({
|
value={participants.map((x) => ({
|
||||||
@@ -120,12 +165,18 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
|||||||
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
|
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
|
||||||
}))}
|
}))}
|
||||||
options={users
|
options={users
|
||||||
.filter((x) => (user.type === "teacher" ? x.type === "student" : x.type === "student" || x.type === "teacher"))
|
.filter((x) =>
|
||||||
.map((x) => ({value: x.id, label: `${x.email} - ${x.name}`}))}
|
user.type === "teacher"
|
||||||
|
? x.type === "student"
|
||||||
|
: x.type === "student" || x.type === "teacher",
|
||||||
|
)
|
||||||
|
.map((x) => ({ value: x.id, label: `${x.email} - ${x.name}` }))}
|
||||||
onChange={(value) => setParticipants(value.map((x) => x.value))}
|
onChange={(value) => setParticipants(value.map((x) => x.value))}
|
||||||
isMulti
|
isMulti
|
||||||
isSearchable
|
isSearchable
|
||||||
|
menuPortalTarget={document?.body}
|
||||||
styles={{
|
styles={{
|
||||||
|
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||||
control: (styles) => ({
|
control: (styles) => ({
|
||||||
...styles,
|
...styles,
|
||||||
backgroundColor: "white",
|
backgroundColor: "white",
|
||||||
@@ -136,18 +187,36 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{user.type !== "teacher" && (
|
{user.type !== "teacher" && (
|
||||||
<Button className="w-full max-w-[300px]" onClick={openFilePicker} variant="outline">
|
<Button
|
||||||
{filesContent.length === 0 ? "Upload participants Excel file" : filesContent[0].name}
|
className="w-full max-w-[300px]"
|
||||||
|
onClick={openFilePicker}
|
||||||
|
isLoading={isLoading}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{filesContent.length === 0
|
||||||
|
? "Upload participants Excel file"
|
||||||
|
: filesContent[0].name}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full justify-end items-center gap-8 mt-8">
|
<div className="mt-8 flex w-full items-center justify-end gap-8">
|
||||||
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={onClose}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="red"
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
isLoading={isLoading}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="w-full max-w-[200px]" onClick={submit} disabled={!name}>
|
<Button
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
onClick={submit}
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={!name}
|
||||||
|
>
|
||||||
Submit
|
Submit
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,13 +226,15 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
|||||||
|
|
||||||
const filterTypes = ["corporate", "teacher"];
|
const filterTypes = ["corporate", "teacher"];
|
||||||
|
|
||||||
export default function GroupList({user}: {user: User}) {
|
export default function GroupList({ user }: { user: User }) {
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [editingGroup, setEditingGroup] = useState<Group>();
|
const [editingGroup, setEditingGroup] = useState<Group>();
|
||||||
const [filterByUser, setFilterByUser] = useState(false);
|
const [filterByUser, setFilterByUser] = useState(false);
|
||||||
|
|
||||||
const {users} = useUsers();
|
const { users } = useUsers();
|
||||||
const {groups, reload} = useGroups(user && filterTypes.includes(user?.type) ? user.id : undefined);
|
const { groups, reload } = useGroups(
|
||||||
|
user && filterTypes.includes(user?.type) ? user.id : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user && (user.type === "corporate" || user.type === "teacher")) {
|
if (user && (user.type === "corporate" || user.type === "teacher")) {
|
||||||
@@ -175,7 +246,7 @@ export default function GroupList({user}: {user: User}) {
|
|||||||
if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return;
|
if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.delete<{ok: boolean}>(`/api/groups/${group.id}`)
|
.delete<{ ok: boolean }>(`/api/groups/${group.id}`)
|
||||||
.then(() => toast.success(`Group "${group.name}" deleted successfully`))
|
.then(() => toast.success(`Group "${group.name}" deleted successfully`))
|
||||||
.catch(() => toast.error("Something went wrong, please try again later!"))
|
.catch(() => toast.error("Something went wrong, please try again later!"))
|
||||||
.finally(reload);
|
.finally(reload);
|
||||||
@@ -193,7 +264,12 @@ export default function GroupList({user}: {user: User}) {
|
|||||||
columnHelper.accessor("admin", {
|
columnHelper.accessor("admin", {
|
||||||
header: "Admin",
|
header: "Admin",
|
||||||
cell: (info) => (
|
cell: (info) => (
|
||||||
<div className="tooltip" data-tip={capitalize(users.find((x) => x.id === info.getValue())?.type)}>
|
<div
|
||||||
|
className="tooltip"
|
||||||
|
data-tip={capitalize(
|
||||||
|
users.find((x) => x.id === info.getValue())?.type,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{users.find((x) => x.id === info.getValue())?.name}
|
{users.find((x) => x.id === info.getValue())?.name}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -209,19 +285,32 @@ export default function GroupList({user}: {user: User}) {
|
|||||||
{
|
{
|
||||||
header: "",
|
header: "",
|
||||||
id: "actions",
|
id: "actions",
|
||||||
cell: ({row}: {row: {original: Group}}) => {
|
cell: ({ row }: { row: { original: Group } }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{user && (user.type === "developer" || user.type === "admin" || user.id === row.original.admin) && (
|
{user &&
|
||||||
|
(user.type === "developer" ||
|
||||||
|
user.type === "admin" ||
|
||||||
|
user.id === row.original.admin) && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{!row.original.disableEditing && (
|
{(!row.original.disableEditing ||
|
||||||
<div data-tip="Edit" className="cursor-pointer tooltip" onClick={() => setEditingGroup(row.original)}>
|
["developer", "admin"].includes(user.type)) && (
|
||||||
<BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
<div
|
||||||
|
data-tip="Edit"
|
||||||
|
className="tooltip cursor-pointer"
|
||||||
|
onClick={() => setEditingGroup(row.original)}
|
||||||
|
>
|
||||||
|
<BsPencil className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!row.original.disableEditing && (
|
{(!row.original.disableEditing ||
|
||||||
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteGroup(row.original)}>
|
["developer", "admin"].includes(user.type)) && (
|
||||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
<div
|
||||||
|
data-tip="Delete"
|
||||||
|
className="tooltip cursor-pointer"
|
||||||
|
onClick={() => deleteGroup(row.original)}
|
||||||
|
>
|
||||||
|
<BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -245,8 +334,12 @@ export default function GroupList({user}: {user: User}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full rounded-xl">
|
<div className="h-full w-full rounded-xl">
|
||||||
<Modal isOpen={isCreating || !!editingGroup} onClose={closeModal} title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}>
|
<Modal
|
||||||
|
isOpen={isCreating || !!editingGroup}
|
||||||
|
onClose={closeModal}
|
||||||
|
title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}
|
||||||
|
>
|
||||||
<CreatePanel
|
<CreatePanel
|
||||||
group={editingGroup}
|
group={editingGroup}
|
||||||
user={user}
|
user={user}
|
||||||
@@ -258,19 +351,25 @@ export default function GroupList({user}: {user: User}) {
|
|||||||
groups
|
groups
|
||||||
.filter((g) => g.admin === user.id)
|
.filter((g) => g.admin === user.id)
|
||||||
.flatMap((g) => g.participants)
|
.flatMap((g) => g.participants)
|
||||||
.includes(u.id) || groups.flatMap((g) => g.participants).includes(u.id),
|
.includes(u.id) ||
|
||||||
|
groups.flatMap((g) => g.participants).includes(u.id),
|
||||||
)
|
)
|
||||||
: users
|
: users
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
<table className="bg-mti-purple-ultralight/40 w-full rounded-xl">
|
||||||
<thead>
|
<thead>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<tr key={headerGroup.id}>
|
<tr key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => (
|
{headerGroup.headers.map((header) => (
|
||||||
<th className="py-4" key={header.id}>
|
<th className="py-4" key={header.id}>
|
||||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -278,7 +377,10 @@ export default function GroupList({user}: {user: User}) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="px-2">
|
<tbody className="px-2">
|
||||||
{table.getRowModel().rows.map((row) => (
|
{table.getRowModel().rows.map((row) => (
|
||||||
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
<tr
|
||||||
|
className="even:bg-mti-purple-ultralight/40 rounded-lg py-2 odd:bg-white"
|
||||||
|
key={row.id}
|
||||||
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<td className="px-4 py-2" key={cell.id}>
|
<td className="px-4 py-2" key={cell.id}>
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
@@ -291,7 +393,8 @@ export default function GroupList({user}: {user: User}) {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsCreating(true)}
|
onClick={() => setIsCreating(true)}
|
||||||
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white">
|
className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out"
|
||||||
|
>
|
||||||
New Group
|
New Group
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -71,7 +71,9 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
|
|||||||
defaultValue={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
|
defaultValue={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
|
||||||
onChange={(value) => setCurrency(value?.value || "EUR")}
|
onChange={(value) => setCurrency(value?.value || "EUR")}
|
||||||
value={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
|
value={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
|
||||||
|
menuPortalTarget={document?.body}
|
||||||
styles={{
|
styles={{
|
||||||
|
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||||
control: (styles) => ({
|
control: (styles) => ({
|
||||||
...styles,
|
...styles,
|
||||||
paddingLeft: "4px",
|
paddingLeft: "4px",
|
||||||
@@ -105,7 +107,9 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
|
|||||||
defaultValue={{value: "months", label: "Months"}}
|
defaultValue={{value: "months", label: "Months"}}
|
||||||
onChange={(value) => setUnit((value?.value as DurationUnit) || "months")}
|
onChange={(value) => setUnit((value?.value as DurationUnit) || "months")}
|
||||||
value={{value: unit, label: capitalize(unit)}}
|
value={{value: unit, label: capitalize(unit)}}
|
||||||
|
menuPortalTarget={document?.body}
|
||||||
styles={{
|
styles={{
|
||||||
|
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||||
control: (styles) => ({
|
control: (styles) => ({
|
||||||
...styles,
|
...styles,
|
||||||
paddingLeft: "4px",
|
paddingLeft: "4px",
|
||||||
|
|||||||
@@ -1,60 +1,134 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import Head from "next/head";
|
|
||||||
import {useEffect, useState} from "react";
|
|
||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
|
||||||
import Selection from "@/exams/Selection";
|
|
||||||
import Reading from "@/exams/Reading";
|
|
||||||
import {Exam, InteractiveSpeakingExercise, SpeakingExercise, UserSolution, WritingExercise} from "@/interfaces/exam";
|
|
||||||
import Listening from "@/exams/Listening";
|
|
||||||
import Writing from "@/exams/Writing";
|
|
||||||
import {ToastContainer, toast} from "react-toastify";
|
|
||||||
import Finish from "@/exams/Finish";
|
|
||||||
import axios from "axios";
|
|
||||||
import {Stat} from "@/interfaces/user";
|
|
||||||
import Speaking from "@/exams/Speaking";
|
|
||||||
import {v4 as uuidv4} from "uuid";
|
|
||||||
import useUser from "@/hooks/useUser";
|
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
import Layout from "@/components/High/Layout";
|
|
||||||
import AbandonPopup from "@/components/AbandonPopup";
|
import AbandonPopup from "@/components/AbandonPopup";
|
||||||
import {evaluateSpeakingAnswer, evaluateWritingAnswer} from "@/utils/evaluation";
|
import Layout from "@/components/High/Layout";
|
||||||
import {useRouter} from "next/router";
|
import Finish from "@/exams/Finish";
|
||||||
import {getExam} from "@/utils/exams";
|
|
||||||
import {capitalize} from "lodash";
|
|
||||||
import Level from "@/exams/Level";
|
import Level from "@/exams/Level";
|
||||||
|
import Listening from "@/exams/Listening";
|
||||||
|
import Reading from "@/exams/Reading";
|
||||||
|
import Selection from "@/exams/Selection";
|
||||||
|
import Speaking from "@/exams/Speaking";
|
||||||
|
import Writing from "@/exams/Writing";
|
||||||
|
import useUser from "@/hooks/useUser";
|
||||||
|
import {Exam, UserSolution, Variant} from "@/interfaces/exam";
|
||||||
|
import {Stat} from "@/interfaces/user";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import {evaluateSpeakingAnswer, evaluateWritingAnswer} from "@/utils/evaluation";
|
||||||
|
import {defaultExamUserSolutions, getExam} from "@/utils/exams";
|
||||||
|
import axios from "axios";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import {toast, ToastContainer} from "react-toastify";
|
||||||
|
import {v4 as uuidv4} from "uuid";
|
||||||
|
import useSessions from "@/hooks/useSessions";
|
||||||
|
import ShortUniqueId from "short-unique-id";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
page: "exams" | "exercises";
|
page: "exams" | "exercises";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ExamPage({page}: Props) {
|
export default function ExamPage({page}: Props) {
|
||||||
const [hasBeenUploaded, setHasBeenUploaded] = useState(false);
|
const [variant, setVariant] = useState<Variant>("full");
|
||||||
const [moduleIndex, setModuleIndex] = useState(0);
|
|
||||||
const [sessionId, setSessionId] = useState("");
|
|
||||||
const [exam, setExam] = useState<Exam>();
|
|
||||||
const [isEvaluationLoading, setIsEvaluationLoading] = useState(false);
|
|
||||||
const [showAbandonPopup, setShowAbandonPopup] = useState(false);
|
|
||||||
const [avoidRepeated, setAvoidRepeated] = useState(false);
|
const [avoidRepeated, setAvoidRepeated] = useState(false);
|
||||||
const [timeSpent, setTimeSpent] = useState(0);
|
const [hasBeenUploaded, setHasBeenUploaded] = useState(false);
|
||||||
|
const [showAbandonPopup, setShowAbandonPopup] = useState(false);
|
||||||
|
const [isEvaluationLoading, setIsEvaluationLoading] = useState(false);
|
||||||
const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<string[]>([]);
|
const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<string[]>([]);
|
||||||
|
const [timeSpent, setTimeSpent] = useState(0);
|
||||||
|
|
||||||
const [exams, setExams] = useExamStore((state) => [state.exams, state.setExams]);
|
const resetStore = useExamStore((state) => state.reset);
|
||||||
const [userSolutions, setUserSolutions] = useExamStore((state) => [state.userSolutions, state.setUserSolutions]);
|
|
||||||
const [showSolutions, setShowSolutions] = useExamStore((state) => [state.showSolutions, state.setShowSolutions]);
|
|
||||||
const [selectedModules, setSelectedModules] = useExamStore((state) => [state.selectedModules, state.setSelectedModules]);
|
|
||||||
const assignment = useExamStore((state) => state.assignment);
|
const assignment = useExamStore((state) => state.assignment);
|
||||||
|
const initialTimeSpent = useExamStore((state) => state.timeSpent);
|
||||||
|
|
||||||
|
const {exam, setExam} = useExamStore((state) => state);
|
||||||
|
const {exams, setExams} = useExamStore((state) => state);
|
||||||
|
const {sessionId, setSessionId} = useExamStore((state) => state);
|
||||||
|
const {partIndex, setPartIndex} = useExamStore((state) => state);
|
||||||
|
const {moduleIndex, setModuleIndex} = useExamStore((state) => state);
|
||||||
|
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
|
||||||
|
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
||||||
|
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||||
|
const {showSolutions, setShowSolutions} = useExamStore((state) => state);
|
||||||
|
const {selectedModules, setSelectedModules} = useExamStore((state) => state);
|
||||||
|
|
||||||
const {user} = useUser({redirectTo: "/login"});
|
const {user} = useUser({redirectTo: "/login"});
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => setSessionId(uuidv4()), []);
|
const reset = () => {
|
||||||
|
resetStore();
|
||||||
|
setVariant("full");
|
||||||
|
setAvoidRepeated(false);
|
||||||
|
setHasBeenUploaded(false);
|
||||||
|
setShowAbandonPopup(false);
|
||||||
|
setIsEvaluationLoading(false);
|
||||||
|
setStatsAwaitingEvaluation([]);
|
||||||
|
setTimeSpent(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
const saveSession = async () => {
|
||||||
|
console.log("Saving your session...");
|
||||||
|
|
||||||
|
await axios.post("/api/sessions", {
|
||||||
|
id: sessionId,
|
||||||
|
sessionId,
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
userSolutions,
|
||||||
|
moduleIndex,
|
||||||
|
selectedModules,
|
||||||
|
assignment,
|
||||||
|
timeSpent,
|
||||||
|
exams,
|
||||||
|
exam,
|
||||||
|
partIndex,
|
||||||
|
exerciseIndex,
|
||||||
|
questionIndex,
|
||||||
|
user: user?.id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => setTimeSpent((prev) => prev + initialTimeSpent), [initialTimeSpent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (userSolutions.length === 0 && exams.length > 0) {
|
||||||
|
const defaultSolutions = exams.map(defaultExamUserSolutions).flat();
|
||||||
|
setUserSolutions(defaultSolutions);
|
||||||
|
}
|
||||||
|
}, [exams, setUserSolutions, userSolutions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
sessionId.length > 0 &&
|
||||||
|
userSolutions.length > 0 &&
|
||||||
|
selectedModules.length > 0 &&
|
||||||
|
exams.length > 0 &&
|
||||||
|
!!exam &&
|
||||||
|
timeSpent > 0 &&
|
||||||
|
!showSolutions &&
|
||||||
|
moduleIndex < selectedModules.length
|
||||||
|
)
|
||||||
|
saveSession();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [assignment, exam, exams, moduleIndex, selectedModules, sessionId, userSolutions, user, exerciseIndex, partIndex, questionIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timeSpent % 20 === 0 && timeSpent > 0 && moduleIndex < selectedModules.length && !showSolutions) saveSession();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [timeSpent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedModules.length > 0 && sessionId.length === 0) {
|
||||||
|
const shortUID = new ShortUniqueId();
|
||||||
|
setSessionId(shortUID.randomUUID(8));
|
||||||
|
}
|
||||||
|
}, [setSessionId, selectedModules, sessionId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.type === "developer") console.log(exam);
|
if (user?.type === "developer") console.log(exam);
|
||||||
}, [exam, user]);
|
}, [exam, user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
selectedModules.length > 0 && timeSpent === 0 && !showSolutions;
|
|
||||||
if (selectedModules.length > 0 && timeSpent === 0 && !showSolutions) {
|
if (selectedModules.length > 0 && timeSpent === 0 && !showSolutions) {
|
||||||
const timerInterval = setInterval(() => {
|
const timerInterval = setInterval(() => {
|
||||||
setTimeSpent((prev) => prev + 1);
|
setTimeSpent((prev) => prev + 1);
|
||||||
@@ -69,12 +143,15 @@ export default function ExamPage({page}: Props) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showSolutions) setModuleIndex(-1);
|
if (showSolutions) setModuleIndex(-1);
|
||||||
}, [showSolutions]);
|
}, [setModuleIndex, showSolutions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) {
|
if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) {
|
||||||
const nextExam = exams[moduleIndex];
|
const nextExam = exams[moduleIndex];
|
||||||
|
|
||||||
|
if (partIndex === -1 && nextExam.module !== "listening") setPartIndex(0);
|
||||||
|
if (exerciseIndex === -1 && !["reading", "listening"].includes(nextExam.module)) setExerciseIndex(0);
|
||||||
setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined);
|
setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -84,7 +161,14 @@ export default function ExamPage({page}: Props) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (selectedModules.length > 0 && exams.length === 0) {
|
if (selectedModules.length > 0 && exams.length === 0) {
|
||||||
const examPromises = selectedModules.map((module) => getExam(module, avoidRepeated));
|
const examPromises = selectedModules.map((module) =>
|
||||||
|
getExam(
|
||||||
|
module,
|
||||||
|
avoidRepeated,
|
||||||
|
variant,
|
||||||
|
user?.type === "student" || user?.type === "developer" ? user.preferredGender : undefined,
|
||||||
|
),
|
||||||
|
);
|
||||||
Promise.all(examPromises).then((values) => {
|
Promise.all(examPromises).then((values) => {
|
||||||
if (values.every((x) => !!x)) {
|
if (values.every((x) => !!x)) {
|
||||||
setExams(values.map((x) => x!));
|
setExams(values.map((x) => x!));
|
||||||
@@ -105,8 +189,8 @@ export default function ExamPage({page}: Props) {
|
|||||||
id: solution.id || uuidv4(),
|
id: solution.id || uuidv4(),
|
||||||
timeSpent,
|
timeSpent,
|
||||||
session: sessionId,
|
session: sessionId,
|
||||||
exam: solution.exam!,
|
exam: exam!.id,
|
||||||
module: solution.module!,
|
module: exam!.module,
|
||||||
user: user?.id || "",
|
user: user?.id || "",
|
||||||
date: new Date().getTime(),
|
date: new Date().getTime(),
|
||||||
...(assignment ? {assignment: assignment.id} : {}),
|
...(assignment ? {assignment: assignment.id} : {}),
|
||||||
@@ -121,37 +205,41 @@ export default function ExamPage({page}: Props) {
|
|||||||
}, [selectedModules, moduleIndex, hasBeenUploaded]);
|
}, [selectedModules, moduleIndex, hasBeenUploaded]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (statsAwaitingEvaluation.length === 0) return setIsEvaluationLoading(false);
|
setIsEvaluationLoading(statsAwaitingEvaluation.length !== 0);
|
||||||
return setIsEvaluationLoading(true);
|
|
||||||
}, [statsAwaitingEvaluation]);
|
}, [statsAwaitingEvaluation]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (statsAwaitingEvaluation.length > 0) {
|
if (statsAwaitingEvaluation.length > 0) {
|
||||||
statsAwaitingEvaluation.forEach(checkIfStatHasBeenEvaluated);
|
checkIfStatsHaveBeenEvaluated(statsAwaitingEvaluation);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [statsAwaitingEvaluation]);
|
}, [statsAwaitingEvaluation]);
|
||||||
|
|
||||||
const checkIfStatHasBeenEvaluated = (id: string) => {
|
const checkIfStatsHaveBeenEvaluated = (ids: string[]) => {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
const statRequest = await axios.get<Stat>(`/api/stats/${id}`);
|
const awaitedStats = await Promise.all(ids.map(async (id) => (await axios.get<Stat>(`/api/stats/${id}`)).data));
|
||||||
const stat = statRequest.data;
|
const solutionsEvaluated = awaitedStats.every((stat) => stat.solutions.every((x) => x.evaluation !== null));
|
||||||
if (stat.solutions.every((x) => x.evaluation !== null)) {
|
if (solutionsEvaluated) {
|
||||||
const userSolution: UserSolution = {
|
const statsUserSolutions: UserSolution[] = awaitedStats.map((stat) => ({
|
||||||
id,
|
id: stat.id,
|
||||||
exercise: stat.exercise,
|
exercise: stat.exercise,
|
||||||
score: stat.score,
|
score: stat.score,
|
||||||
solutions: stat.solutions,
|
solutions: stat.solutions,
|
||||||
type: stat.type,
|
type: stat.type,
|
||||||
exam: stat.exam,
|
exam: stat.exam,
|
||||||
module: stat.module,
|
module: stat.module,
|
||||||
};
|
}));
|
||||||
|
|
||||||
setUserSolutions(userSolutions.map((x) => (x.exercise === userSolution.exercise ? userSolution : x)));
|
const updatedUserSolutions = userSolutions.map((x) => {
|
||||||
return setStatsAwaitingEvaluation((prev) => prev.filter((x) => x !== id));
|
const respectiveSolution = statsUserSolutions.find((y) => y.exercise === x.exercise);
|
||||||
|
return respectiveSolution ? respectiveSolution : x;
|
||||||
|
});
|
||||||
|
|
||||||
|
setUserSolutions(updatedUserSolutions);
|
||||||
|
return setStatsAwaitingEvaluation((prev) => prev.filter((x) => !ids.includes(x)));
|
||||||
}
|
}
|
||||||
|
|
||||||
return checkIfStatHasBeenEvaluated(id);
|
return checkIfStatsHaveBeenEvaluated(ids);
|
||||||
}, 5 * 1000);
|
}, 5 * 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -159,13 +247,21 @@ export default function ExamPage({page}: Props) {
|
|||||||
if (exam.module === "reading" || exam.module === "listening") {
|
if (exam.module === "reading" || exam.module === "listening") {
|
||||||
const parts = exam.parts.map((p) =>
|
const parts = exam.parts.map((p) =>
|
||||||
Object.assign(p, {
|
Object.assign(p, {
|
||||||
exercises: p.exercises.map((x) => Object.assign(x, {userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions})),
|
exercises: p.exercises.map((x) =>
|
||||||
|
Object.assign(x, {
|
||||||
|
userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions,
|
||||||
|
}),
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return Object.assign(exam, {parts});
|
return Object.assign(exam, {parts});
|
||||||
}
|
}
|
||||||
|
|
||||||
const exercises = exam.exercises.map((x) => Object.assign(x, {userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions}));
|
const exercises = exam.exercises.map((x) =>
|
||||||
|
Object.assign(x, {
|
||||||
|
userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions,
|
||||||
|
}),
|
||||||
|
);
|
||||||
return Object.assign(exam, {exercises});
|
return Object.assign(exam, {exercises});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -201,11 +297,17 @@ export default function ExamPage({page}: Props) {
|
|||||||
axios.get("/api/stats/update");
|
axios.get("/api/stats/update");
|
||||||
|
|
||||||
setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...solutions]);
|
setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...solutions]);
|
||||||
setModuleIndex((prev) => prev + 1);
|
setModuleIndex(moduleIndex + 1);
|
||||||
|
|
||||||
|
setPartIndex(-1);
|
||||||
|
setExerciseIndex(-1);
|
||||||
|
setQuestionIndex(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const aggregateScoresByModule = (answers: UserSolution[]): {module: Module; total: number; missing: number; correct: number}[] => {
|
const aggregateScoresByModule = (answers: UserSolution[]): {module: Module; total: number; missing: number; correct: number}[] => {
|
||||||
const scores: {[key in Module]: {total: number; missing: number; correct: number}} = {
|
const scores: {
|
||||||
|
[key in Module]: {total: number; missing: number; correct: number};
|
||||||
|
} = {
|
||||||
reading: {
|
reading: {
|
||||||
total: 0,
|
total: 0,
|
||||||
correct: 0,
|
correct: 0,
|
||||||
@@ -234,6 +336,8 @@ export default function ExamPage({page}: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
answers.forEach((x) => {
|
answers.forEach((x) => {
|
||||||
|
console.log({x});
|
||||||
|
|
||||||
scores[x.module!] = {
|
scores[x.module!] = {
|
||||||
total: scores[x.module!].total + x.score.total,
|
total: scores[x.module!].total + x.score.total,
|
||||||
correct: scores[x.module!].correct + x.score.correct,
|
correct: scores[x.module!].correct + x.score.correct,
|
||||||
@@ -253,10 +357,11 @@ export default function ExamPage({page}: Props) {
|
|||||||
page={page}
|
page={page}
|
||||||
user={user!}
|
user={user!}
|
||||||
disableSelection={page === "exams"}
|
disableSelection={page === "exams"}
|
||||||
onStart={(modules, avoid) => {
|
onStart={(modules: Module[], avoid: boolean, variant: Variant) => {
|
||||||
setModuleIndex(0);
|
setModuleIndex(0);
|
||||||
setAvoidRepeated(avoid);
|
setAvoidRepeated(avoid);
|
||||||
setSelectedModules(modules);
|
setSelectedModules(modules);
|
||||||
|
setVariant(variant);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -271,6 +376,8 @@ export default function ExamPage({page}: Props) {
|
|||||||
onViewResults={() => {
|
onViewResults={() => {
|
||||||
setShowSolutions(true);
|
setShowSolutions(true);
|
||||||
setModuleIndex(0);
|
setModuleIndex(0);
|
||||||
|
setExerciseIndex(["reading", "listening"].includes(exams[0].module) ? -1 : 0);
|
||||||
|
setPartIndex(exams[0].module === "listening" ? -1 : 0);
|
||||||
setExam(exams[0]);
|
setExam(exams[0]);
|
||||||
}}
|
}}
|
||||||
scores={aggregateScoresByModule(userSolutions)}
|
scores={aggregateScoresByModule(userSolutions)}
|
||||||
@@ -318,7 +425,9 @@ export default function ExamPage({page}: Props) {
|
|||||||
abandonPopupTitle="Leave Exercise"
|
abandonPopupTitle="Leave Exercise"
|
||||||
abandonPopupDescription="Are you sure you want to leave the exercise? You will lose all your progress."
|
abandonPopupDescription="Are you sure you want to leave the exercise? You will lose all your progress."
|
||||||
abandonConfirmButtonText="Confirm"
|
abandonConfirmButtonText="Confirm"
|
||||||
onAbandon={() => router.reload()}
|
onAbandon={() => {
|
||||||
|
reset();
|
||||||
|
}}
|
||||||
onCancel={() => setShowAbandonPopup(false)}
|
onCancel={() => setShowAbandonPopup(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ const TaskTab = ({exam, setExam}: {exam?: LevelExam; setExam: (exam: LevelExam)
|
|||||||
axios
|
axios
|
||||||
.get(`/api/exam/level/generate/level`)
|
.get(`/api/exam/level/generate/level`)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
playSound("check");
|
playSound(typeof result.data === "string" ? "error" : "check");
|
||||||
console.log(result.data);
|
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
||||||
setExam(result.data);
|
setExam(result.data);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -77,10 +77,20 @@ const TaskTab = ({exam, setExam}: {exam?: LevelExam; setExam: (exam: LevelExam)
|
|||||||
<span className="font-semibold">
|
<span className="font-semibold">
|
||||||
{question.id}. {question.prompt}
|
{question.id}. {question.prompt}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<div className="flex flex-col gap-1">
|
||||||
<span className="font-semibold text-ielts-level">({question.solution})</span>{" "}
|
{question.options.map((option) => (
|
||||||
{question.options.find((o) => o.id === question.solution)?.text}
|
<span key={option.id} className={clsx(question.solution === option.id && "font-bold")}>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"font-semibold",
|
||||||
|
question.solution === option.id ? "text-mti-green-light" : "text-ielts-level",
|
||||||
|
)}>
|
||||||
|
({option.id})
|
||||||
|
</span>{" "}
|
||||||
|
{option.text}
|
||||||
</span>
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import {Tab} from "@headlessui/react";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {BsArrowRepeat} from "react-icons/bs";
|
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
|
|
||||||
const PartTab = ({part, types, index, setPart}: {part?: ListeningPart; types: string[]; index: number; setPart: (part?: ListeningPart) => void}) => {
|
const PartTab = ({part, types, index, setPart}: {part?: ListeningPart; types: string[]; index: number; setPart: (part?: ListeningPart) => void}) => {
|
||||||
@@ -26,7 +26,8 @@ const PartTab = ({part, types, index, setPart}: {part?: ListeningPart; types: st
|
|||||||
axios
|
axios
|
||||||
.get(`/api/exam/listening/generate/listening_section_${index}${topic || types ? `?${url.toString()}` : ""}`)
|
.get(`/api/exam/listening/generate/listening_section_${index}${topic || types ? `?${url.toString()}` : ""}`)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
playSound("check");
|
playSound(typeof result.data === "string" ? "error" : "check");
|
||||||
|
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
||||||
setPart(result.data);
|
setPart(result.data);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -42,7 +43,7 @@ const PartTab = ({part, types, index, setPart}: {part?: ListeningPart; types: st
|
|||||||
<Input type="text" placeholder="Grand Canyon..." name="topic" label="Topic" onChange={setTopic} roundness="xl" defaultValue={topic} />
|
<Input type="text" placeholder="Grand Canyon..." name="topic" label="Topic" onChange={setTopic} roundness="xl" defaultValue={topic} />
|
||||||
<button
|
<button
|
||||||
onClick={generate}
|
onClick={generate}
|
||||||
disabled={isLoading}
|
disabled={isLoading || types.length === 0}
|
||||||
data-tip="The passage is currently being generated"
|
data-tip="The passage is currently being generated"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"bg-ielts-listening/70 border border-ielts-listening text-white w-full max-w-[200px] rounded-xl h-[70px]",
|
"bg-ielts-listening/70 border border-ielts-listening text-white w-full max-w-[200px] rounded-xl h-[70px]",
|
||||||
@@ -110,10 +111,21 @@ const ListeningGeneration = () => {
|
|||||||
const [part2, setPart2] = useState<ListeningPart>();
|
const [part2, setPart2] = useState<ListeningPart>();
|
||||||
const [part3, setPart3] = useState<ListeningPart>();
|
const [part3, setPart3] = useState<ListeningPart>();
|
||||||
const [part4, setPart4] = useState<ListeningPart>();
|
const [part4, setPart4] = useState<ListeningPart>();
|
||||||
|
const [minTimer, setMinTimer] = useState(30);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [resultingExam, setResultingExam] = useState<ListeningExam>();
|
const [resultingExam, setResultingExam] = useState<ListeningExam>();
|
||||||
const [types, setTypes] = useState<string[]>([]);
|
const [types, setTypes] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const part1Timer = part1 ? 5 : 0;
|
||||||
|
const part2Timer = part2 ? 8 : 0;
|
||||||
|
const part3Timer = part3 ? 8 : 0;
|
||||||
|
const part4Timer = part4 ? 9 : 0;
|
||||||
|
|
||||||
|
const sum = part1Timer + part2Timer + part3Timer + part4Timer;
|
||||||
|
setMinTimer(sum > 0 ? sum : 5);
|
||||||
|
}, [part1, part2, part3, part4]);
|
||||||
|
|
||||||
const availableTypes = [
|
const availableTypes = [
|
||||||
{type: "multipleChoice", label: "Multiple Choice"},
|
{type: "multipleChoice", label: "Multiple Choice"},
|
||||||
{type: "writeBlanksQuestions", label: "Write the Blanks: Questions"},
|
{type: "writeBlanksQuestions", label: "Write the Blanks: Questions"},
|
||||||
@@ -129,12 +141,14 @@ const ListeningGeneration = () => {
|
|||||||
const toggleType = (type: string) => setTypes((prev) => (prev.includes(type) ? [...prev.filter((x) => x !== type)] : [...prev, type]));
|
const toggleType = (type: string) => setTypes((prev) => (prev.includes(type) ? [...prev.filter((x) => x !== type)] : [...prev, type]));
|
||||||
|
|
||||||
const submitExam = () => {
|
const submitExam = () => {
|
||||||
if (!part1 || !part2 || !part3 || !part4) return toast.error("Please generate all for sections!");
|
const parts = [part1, part2, part3, part4].filter((x) => !!x);
|
||||||
|
console.log({parts});
|
||||||
|
if (parts.length === 0) return toast.error("Please generate at least one section!");
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post(`/api/exam/listening/generate/listening`, {parts: [part1, part2, part3, part4]})
|
.post(`/api/exam/listening/generate/listening`, {parts, minTimer})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
playSound("sent");
|
playSound("sent");
|
||||||
console.log(`Generated Exam ID: ${result.data.id}`);
|
console.log(`Generated Exam ID: ${result.data.id}`);
|
||||||
@@ -172,6 +186,17 @@ const ListeningGeneration = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
name="minTimer"
|
||||||
|
onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))}
|
||||||
|
value={minTimer}
|
||||||
|
className="max-w-[300px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Exercises</label>
|
<label className="font-normal text-base text-mti-gray-dim">Exercises</label>
|
||||||
<div className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
|
<div className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
|
||||||
@@ -197,46 +222,46 @@ const ListeningGeneration = () => {
|
|||||||
<Tab
|
<Tab
|
||||||
className={({selected}) =>
|
className={({selected}) =>
|
||||||
clsx(
|
clsx(
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70 flex gap-2 items-center justify-center",
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-listening focus:outline-none focus:ring-2",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-listening focus:outline-none focus:ring-2",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-listening",
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-listening",
|
||||||
)
|
)
|
||||||
}>
|
}>
|
||||||
Section 1
|
Section 1 {part1 && <BsCheck />}
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab
|
<Tab
|
||||||
className={({selected}) =>
|
className={({selected}) =>
|
||||||
clsx(
|
clsx(
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70 flex gap-2 items-center justify-center",
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-listening focus:outline-none focus:ring-2",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-listening focus:outline-none focus:ring-2",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-listening",
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-listening",
|
||||||
)
|
)
|
||||||
}>
|
}>
|
||||||
Section 2
|
Section 2 {part2 && <BsCheck />}
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab
|
<Tab
|
||||||
className={({selected}) =>
|
className={({selected}) =>
|
||||||
clsx(
|
clsx(
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70 flex gap-2 items-center justify-center",
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-listening focus:outline-none focus:ring-2",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-listening focus:outline-none focus:ring-2",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-listening",
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-listening",
|
||||||
)
|
)
|
||||||
}>
|
}>
|
||||||
Section 3
|
Section 3 {part3 && <BsCheck />}
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab
|
<Tab
|
||||||
className={({selected}) =>
|
className={({selected}) =>
|
||||||
clsx(
|
clsx(
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70 flex gap-2 items-center justify-center",
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-listening focus:outline-none focus:ring-2",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-listening focus:outline-none focus:ring-2",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-listening",
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-listening",
|
||||||
)
|
)
|
||||||
}>
|
}>
|
||||||
Section 4
|
Section 4 {part4 && <BsCheck />}
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tab.List>
|
</Tab.List>
|
||||||
<Tab.Panels>
|
<Tab.Panels>
|
||||||
@@ -264,14 +289,14 @@ const ListeningGeneration = () => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
disabled={!part1 || !part2 || !part3 || !part4 || isLoading}
|
disabled={(!part1 && !part2 && !part3 && !part4) || isLoading}
|
||||||
data-tip="Please generate all three passages"
|
data-tip="Please generate all three passages"
|
||||||
onClick={submitExam}
|
onClick={submitExam}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"bg-ielts-listening/70 border border-ielts-listening text-white w-full max-w-[200px] rounded-xl h-[70px] self-end",
|
"bg-ielts-listening/70 border border-ielts-listening text-white w-full max-w-[200px] rounded-xl h-[70px] self-end",
|
||||||
"hover:bg-ielts-listening disabled:bg-ielts-listening/40 disabled:cursor-not-allowed",
|
"hover:bg-ielts-listening disabled:bg-ielts-listening/40 disabled:cursor-not-allowed",
|
||||||
"transition ease-in-out duration-300",
|
"transition ease-in-out duration-300",
|
||||||
(!part1 || !part2 || !part3 || !part4) && "tooltip",
|
!part1 && !part2 && !part3 && !part4 && "tooltip",
|
||||||
)}>
|
)}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import {Tab} from "@headlessui/react";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {BsArrowRepeat} from "react-icons/bs";
|
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import {v4} from "uuid";
|
import {v4} from "uuid";
|
||||||
|
|
||||||
@@ -27,7 +27,8 @@ const PartTab = ({part, types, index, setPart}: {part?: ReadingPart; types: stri
|
|||||||
axios
|
axios
|
||||||
.get(`/api/exam/reading/generate/reading_passage_${index}${topic || types ? `?${url.toString()}` : ""}`)
|
.get(`/api/exam/reading/generate/reading_passage_${index}${topic || types ? `?${url.toString()}` : ""}`)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
playSound("check");
|
playSound(typeof result.data === "string" ? "error" : "check");
|
||||||
|
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
||||||
setPart(result.data);
|
setPart(result.data);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -43,7 +44,7 @@ const PartTab = ({part, types, index, setPart}: {part?: ReadingPart; types: stri
|
|||||||
<Input type="text" placeholder="Grand Canyon..." name="topic" label="Topic" onChange={setTopic} roundness="xl" defaultValue={topic} />
|
<Input type="text" placeholder="Grand Canyon..." name="topic" label="Topic" onChange={setTopic} roundness="xl" defaultValue={topic} />
|
||||||
<button
|
<button
|
||||||
onClick={generate}
|
onClick={generate}
|
||||||
disabled={isLoading}
|
disabled={isLoading || types.length === 0}
|
||||||
data-tip="The passage is currently being generated"
|
data-tip="The passage is currently being generated"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"bg-ielts-reading/70 border border-ielts-reading text-white w-full max-w-[200px] rounded-xl h-[70px]",
|
"bg-ielts-reading/70 border border-ielts-reading text-white w-full max-w-[200px] rounded-xl h-[70px]",
|
||||||
@@ -87,10 +88,16 @@ const ReadingGeneration = () => {
|
|||||||
const [part1, setPart1] = useState<ReadingPart>();
|
const [part1, setPart1] = useState<ReadingPart>();
|
||||||
const [part2, setPart2] = useState<ReadingPart>();
|
const [part2, setPart2] = useState<ReadingPart>();
|
||||||
const [part3, setPart3] = useState<ReadingPart>();
|
const [part3, setPart3] = useState<ReadingPart>();
|
||||||
|
const [minTimer, setMinTimer] = useState(60);
|
||||||
const [types, setTypes] = useState<string[]>([]);
|
const [types, setTypes] = useState<string[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [resultingExam, setResultingExam] = useState<ReadingExam>();
|
const [resultingExam, setResultingExam] = useState<ReadingExam>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const parts = [part1, part2, part3].filter((x) => !!x);
|
||||||
|
setMinTimer(parts.length === 0 ? 60 : parts.length * 20);
|
||||||
|
}, [part1, part2, part3]);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const setExams = useExamStore((state) => state.setExams);
|
const setExams = useExamStore((state) => state.setExams);
|
||||||
@@ -122,19 +129,21 @@ const ReadingGeneration = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const submitExam = () => {
|
const submitExam = () => {
|
||||||
if (!part1 || !part2 || !part3) {
|
const parts = [part1, part2, part3].filter((x) => !!x) as ReadingPart[];
|
||||||
toast.error("Please generate all three passages before submitting");
|
if (parts.length === 0) {
|
||||||
|
toast.error("Please generate at least one passage before submitting");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const exam: ReadingExam = {
|
const exam: ReadingExam = {
|
||||||
parts: [part1, part2, part3],
|
parts,
|
||||||
isDiagnostic: false,
|
isDiagnostic: false,
|
||||||
minTimer: 60,
|
minTimer,
|
||||||
module: "reading",
|
module: "reading",
|
||||||
id: v4(),
|
id: v4(),
|
||||||
type: "academic",
|
type: "academic",
|
||||||
|
variant: parts.length === 3 ? "full" : "partial",
|
||||||
};
|
};
|
||||||
|
|
||||||
axios
|
axios
|
||||||
@@ -148,6 +157,7 @@ const ReadingGeneration = () => {
|
|||||||
setPart1(undefined);
|
setPart1(undefined);
|
||||||
setPart2(undefined);
|
setPart2(undefined);
|
||||||
setPart3(undefined);
|
setPart3(undefined);
|
||||||
|
setMinTimer(60);
|
||||||
setTypes([]);
|
setTypes([]);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -159,6 +169,17 @@ const ReadingGeneration = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
name="minTimer"
|
||||||
|
onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))}
|
||||||
|
value={minTimer}
|
||||||
|
className="max-w-[300px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Exercises</label>
|
<label className="font-normal text-base text-mti-gray-dim">Exercises</label>
|
||||||
<div className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
|
<div className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
|
||||||
@@ -182,35 +203,35 @@ const ReadingGeneration = () => {
|
|||||||
<Tab
|
<Tab
|
||||||
className={({selected}) =>
|
className={({selected}) =>
|
||||||
clsx(
|
clsx(
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-reading/70",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-reading/70 flex gap-2 items-center justify-center",
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-reading focus:outline-none focus:ring-2",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-reading focus:outline-none focus:ring-2",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-reading",
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-reading",
|
||||||
)
|
)
|
||||||
}>
|
}>
|
||||||
Passage 1
|
Passage 1 {part1 && <BsCheck />}
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab
|
<Tab
|
||||||
className={({selected}) =>
|
className={({selected}) =>
|
||||||
clsx(
|
clsx(
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-reading/70",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-reading/70 flex gap-2 items-center justify-center",
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-reading focus:outline-none focus:ring-2",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-reading focus:outline-none focus:ring-2",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-reading",
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-reading",
|
||||||
)
|
)
|
||||||
}>
|
}>
|
||||||
Passage 2
|
Passage 2 {part2 && <BsCheck />}
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab
|
<Tab
|
||||||
className={({selected}) =>
|
className={({selected}) =>
|
||||||
clsx(
|
clsx(
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-reading/70",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-reading/70 flex gap-2 items-center justify-center",
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-reading focus:outline-none focus:ring-2",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-reading focus:outline-none focus:ring-2",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-reading",
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-reading",
|
||||||
)
|
)
|
||||||
}>
|
}>
|
||||||
Passage 3
|
Passage 3 {part3 && <BsCheck />}
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tab.List>
|
</Tab.List>
|
||||||
<Tab.Panels>
|
<Tab.Panels>
|
||||||
@@ -237,14 +258,14 @@ const ReadingGeneration = () => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
disabled={!part1 || !part2 || !part3 || isLoading}
|
disabled={(!part1 && !part2 && !part3) || isLoading}
|
||||||
data-tip="Please generate all three passages"
|
data-tip="Please generate all three passages"
|
||||||
onClick={submitExam}
|
onClick={submitExam}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"bg-ielts-reading/70 border border-ielts-reading text-white w-full max-w-[200px] rounded-xl h-[70px] self-end",
|
"bg-ielts-reading/70 border border-ielts-reading text-white w-full max-w-[200px] rounded-xl h-[70px] self-end",
|
||||||
"hover:bg-ielts-reading disabled:bg-ielts-reading/40 disabled:cursor-not-allowed",
|
"hover:bg-ielts-reading disabled:bg-ielts-reading/40 disabled:cursor-not-allowed",
|
||||||
"transition ease-in-out duration-300",
|
"transition ease-in-out duration-300",
|
||||||
(!part1 || !part2 || !part3) && "tooltip",
|
!part1 && !part2 && !part3 && "tooltip",
|
||||||
)}>
|
)}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import {Exercise, SpeakingExam} from "@/interfaces/exam";
|
import Select from "@/components/Low/Select";
|
||||||
|
import {Exercise, InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise} from "@/interfaces/exam";
|
||||||
|
import {AVATARS} from "@/resources/speakingAvatars";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {getExamById} from "@/utils/exams";
|
import {getExamById} from "@/utils/exams";
|
||||||
import {playSound} from "@/utils/sound";
|
import {playSound} from "@/utils/sound";
|
||||||
@@ -7,21 +9,27 @@ import {convertCamelCaseToReadable} from "@/utils/string";
|
|||||||
import {Tab} from "@headlessui/react";
|
import {Tab} from "@headlessui/react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import {capitalize, sample, uniq} from "lodash";
|
||||||
|
import moment from "moment";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {BsArrowRepeat} from "react-icons/bs";
|
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
|
import {v4} from "uuid";
|
||||||
|
|
||||||
const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; setPart: (part?: SpeakingPart) => void}) => {
|
const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; setPart: (part?: SpeakingPart) => void}) => {
|
||||||
|
const [gender, setGender] = useState<"male" | "female">("male");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const generate = () => {
|
const generate = () => {
|
||||||
setPart(undefined);
|
setPart(undefined);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.get(`/api/exam/speaking/generate/speaking_task_${index}`)
|
.get(`/api/exam/speaking/generate/speaking_task_${index}`)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
playSound("check");
|
playSound(typeof result.data === "string" ? "error" : "check");
|
||||||
|
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
||||||
setPart(result.data);
|
setPart(result.data);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -31,8 +39,45 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
|
|||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const generateVideo = () => {
|
||||||
|
if (!part) return toast.error("Please generate the first part before generating the video!");
|
||||||
|
toast.info("This will take quite a while, please do not leave this page or close the tab/window.");
|
||||||
|
|
||||||
|
const avatar = sample(AVATARS.filter((x) => x.gender === gender));
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
const initialTime = moment();
|
||||||
|
|
||||||
|
axios
|
||||||
|
.post(`/api/exam/speaking/generate/speaking/generate_${index === 3 ? "interactive" : "speaking"}_video`, {...part, avatar: avatar?.id})
|
||||||
|
.then((result) => {
|
||||||
|
const isError = typeof result.data === "string" || moment().diff(initialTime, "seconds") < 60;
|
||||||
|
|
||||||
|
playSound(isError ? "error" : "check");
|
||||||
|
if (isError) return toast.error("Something went wrong, please try to generate the video again.");
|
||||||
|
setPart({...part, result: result.data, gender, avatar});
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
toast.error("Something went wrong!");
|
||||||
|
console.log(e);
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
|
<Tab.Panel className="w-full bg-ielts-speaking/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Gender</label>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{value: "male", label: "Male"},
|
||||||
|
{value: "female", label: "Female"},
|
||||||
|
]}
|
||||||
|
value={{value: gender, label: capitalize(gender)}}
|
||||||
|
onChange={(value) => (value ? setGender(value.value as typeof gender) : null)}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex gap-4 items-end">
|
<div className="flex gap-4 items-end">
|
||||||
<button
|
<button
|
||||||
onClick={generate}
|
onClick={generate}
|
||||||
@@ -52,6 +97,24 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
|
|||||||
"Generate"
|
"Generate"
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={generateVideo}
|
||||||
|
disabled={isLoading || !part}
|
||||||
|
data-tip="The passage is currently being generated"
|
||||||
|
className={clsx(
|
||||||
|
"bg-ielts-speaking/70 border border-ielts-speaking text-white w-full rounded-xl h-[70px]",
|
||||||
|
"hover:bg-ielts-speaking disabled:bg-ielts-speaking/40 disabled:cursor-not-allowed",
|
||||||
|
"transition ease-in-out duration-300",
|
||||||
|
isLoading && "tooltip",
|
||||||
|
)}>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"Generate Video"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="w-fit h-fit mt-12 self-center animate-pulse flex flex-col gap-8 items-center">
|
<div className="w-fit h-fit mt-12 self-center animate-pulse flex flex-col gap-8 items-center">
|
||||||
@@ -59,7 +122,7 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
|
|||||||
<span className={clsx("font-bold text-2xl text-ielts-speaking")}>Generating...</span>
|
<span className={clsx("font-bold text-2xl text-ielts-speaking")}>Generating...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{part && (
|
{part && !isLoading && (
|
||||||
<div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide h-96">
|
<div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide h-96">
|
||||||
<h3 className="text-xl font-semibold">{part.topic}</h3>
|
<h3 className="text-xl font-semibold">{part.topic}</h3>
|
||||||
{part.question && <span className="w-full">{part.question}</span>}
|
{part.question && <span className="w-full">{part.question}</span>}
|
||||||
@@ -82,6 +145,12 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{part.result && <span className="font-bold mt-4">Video Generated: ✅</span>}
|
||||||
|
{part.avatar && part.gender && (
|
||||||
|
<span>
|
||||||
|
<b>Instructor:</b> {part.avatar.name} - {capitalize(part.avatar.gender)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
@@ -93,27 +162,48 @@ interface SpeakingPart {
|
|||||||
question?: string;
|
question?: string;
|
||||||
questions?: string[];
|
questions?: string[];
|
||||||
topic: string;
|
topic: string;
|
||||||
|
result?: SpeakingExercise | InteractiveSpeakingExercise;
|
||||||
|
gender?: "male" | "female";
|
||||||
|
avatar?: (typeof AVATARS)[number];
|
||||||
}
|
}
|
||||||
|
|
||||||
const SpeakingGeneration = () => {
|
const SpeakingGeneration = () => {
|
||||||
const [part1, setPart1] = useState<SpeakingPart>();
|
const [part1, setPart1] = useState<SpeakingPart>();
|
||||||
const [part2, setPart2] = useState<SpeakingPart>();
|
const [part2, setPart2] = useState<SpeakingPart>();
|
||||||
const [part3, setPart3] = useState<SpeakingPart>();
|
const [part3, setPart3] = useState<SpeakingPart>();
|
||||||
|
const [minTimer, setMinTimer] = useState(14);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [resultingExam, setResultingExam] = useState<SpeakingExam>();
|
const [resultingExam, setResultingExam] = useState<SpeakingExam>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const parts = [part1, part2, part3].filter((x) => !!x);
|
||||||
|
setMinTimer(parts.length === 0 ? 5 : parts.length * 5);
|
||||||
|
}, [part1, part2, part3]);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const setExams = useExamStore((state) => state.setExams);
|
const setExams = useExamStore((state) => state.setExams);
|
||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||||
|
|
||||||
const submitExam = () => {
|
const submitExam = () => {
|
||||||
if (!part1 || !part2 || !part3) return toast.error("Please generate all for tasks!");
|
if (!part1?.result && !part2?.result && !part3?.result) return toast.error("Please generate at least one task!");
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const genders = [part1?.gender, part2?.gender, part3?.gender].filter((x) => !!x);
|
||||||
|
|
||||||
|
const exam: SpeakingExam = {
|
||||||
|
id: v4(),
|
||||||
|
isDiagnostic: false,
|
||||||
|
exercises: [part1?.result, part2?.result, part3?.result].filter((x) => !!x) as (SpeakingExercise | InteractiveSpeakingExercise)[],
|
||||||
|
minTimer,
|
||||||
|
variant: minTimer >= 14 ? "full" : "partial",
|
||||||
|
module: "speaking",
|
||||||
|
instructorGender: genders.every((x) => x === "male") ? "male" : genders.every((x) => x === "female") ? "female" : "varied",
|
||||||
|
};
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post(`/api/exam/speaking/generate/speaking`, {exercises: [part1, part2, part3]})
|
.post(`/api/exam/speaking`, exam)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
playSound("sent");
|
playSound("sent");
|
||||||
console.log(`Generated Exam ID: ${result.data.id}`);
|
console.log(`Generated Exam ID: ${result.data.id}`);
|
||||||
@@ -123,10 +213,11 @@ const SpeakingGeneration = () => {
|
|||||||
setPart1(undefined);
|
setPart1(undefined);
|
||||||
setPart2(undefined);
|
setPart2(undefined);
|
||||||
setPart3(undefined);
|
setPart3(undefined);
|
||||||
|
setMinTimer(14);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
toast.error("Something went wrong!");
|
toast.error("Something went wrong while generating, please try again later.");
|
||||||
})
|
})
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
@@ -149,40 +240,51 @@ const SpeakingGeneration = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
name="minTimer"
|
||||||
|
onChange={(e) => setMinTimer(parseInt(e) < 5 ? 5 : parseInt(e))}
|
||||||
|
value={minTimer}
|
||||||
|
className="max-w-[300px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Tab.Group>
|
<Tab.Group>
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
||||||
<Tab
|
<Tab
|
||||||
className={({selected}) =>
|
className={({selected}) =>
|
||||||
clsx(
|
clsx(
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/70",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/70 flex gap-2 items-center justify-center",
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||||
)
|
)
|
||||||
}>
|
}>
|
||||||
Task 1
|
Exercise 1 {part1 && part1.result && <BsCheck />}
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab
|
<Tab
|
||||||
className={({selected}) =>
|
className={({selected}) =>
|
||||||
clsx(
|
clsx(
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/70",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/70 flex gap-2 items-center justify-center",
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||||
)
|
)
|
||||||
}>
|
}>
|
||||||
Task 2
|
Exercise 2 {part2 && part2.result && <BsCheck />}
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab
|
<Tab
|
||||||
className={({selected}) =>
|
className={({selected}) =>
|
||||||
clsx(
|
clsx(
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/70",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/70 flex gap-2 items-center justify-center",
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||||
)
|
)
|
||||||
}>
|
}>
|
||||||
Task 3
|
Interactive {part3 && part3.result && <BsCheck />}
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tab.List>
|
</Tab.List>
|
||||||
<Tab.Panels>
|
<Tab.Panels>
|
||||||
@@ -209,14 +311,14 @@ const SpeakingGeneration = () => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
disabled={!part1 || !part2 || !part3 || isLoading}
|
disabled={(!part1?.result && !part2?.result && !part3?.result) || isLoading}
|
||||||
data-tip="Please generate all three passages"
|
data-tip="Please generate all three passages"
|
||||||
onClick={submitExam}
|
onClick={submitExam}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"bg-ielts-speaking/70 border border-ielts-speaking text-white w-full max-w-[200px] rounded-xl h-[70px] self-end",
|
"bg-ielts-speaking/70 border border-ielts-speaking text-white w-full max-w-[200px] rounded-xl h-[70px] self-end",
|
||||||
"hover:bg-ielts-speaking disabled:bg-ielts-speaking/40 disabled:cursor-not-allowed",
|
"hover:bg-ielts-speaking disabled:bg-ielts-speaking/40 disabled:cursor-not-allowed",
|
||||||
"transition ease-in-out duration-300",
|
"transition ease-in-out duration-300",
|
||||||
(!part1 || !part2 || !part3) && "tooltip",
|
!part1 && !part2 && !part3 && "tooltip",
|
||||||
)}>
|
)}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import {WritingExam} from "@/interfaces/exam";
|
import {WritingExam, WritingExercise} from "@/interfaces/exam";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {getExamById} from "@/utils/exams";
|
import {getExamById} from "@/utils/exams";
|
||||||
import {playSound} from "@/utils/sound";
|
import {playSound} from "@/utils/sound";
|
||||||
@@ -7,8 +7,8 @@ import {Tab} from "@headlessui/react";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {BsArrowRepeat} from "react-icons/bs";
|
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import {v4} from "uuid";
|
import {v4} from "uuid";
|
||||||
|
|
||||||
@@ -20,7 +20,8 @@ const TaskTab = ({task, index, setTask}: {task?: string; index: number; setTask:
|
|||||||
axios
|
axios
|
||||||
.get(`/api/exam/writing/generate/writing_task${index}_general`)
|
.get(`/api/exam/writing/generate/writing_task${index}_general`)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
playSound("check");
|
playSound(typeof result.data === "string" ? "error" : "check");
|
||||||
|
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
||||||
setTask(result.data.question);
|
setTask(result.data.question);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -68,9 +69,16 @@ const TaskTab = ({task, index, setTask}: {task?: string; index: number; setTask:
|
|||||||
const WritingGeneration = () => {
|
const WritingGeneration = () => {
|
||||||
const [task1, setTask1] = useState<string>();
|
const [task1, setTask1] = useState<string>();
|
||||||
const [task2, setTask2] = useState<string>();
|
const [task2, setTask2] = useState<string>();
|
||||||
|
const [minTimer, setMinTimer] = useState(60);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [resultingExam, setResultingExam] = useState<WritingExam>();
|
const [resultingExam, setResultingExam] = useState<WritingExam>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const task1Timer = task1 ? 20 : 0;
|
||||||
|
const task2Timer = task2 ? 40 : 0;
|
||||||
|
setMinTimer(task1Timer > 0 || task2Timer > 0 ? task1Timer + task2Timer : 20);
|
||||||
|
}, [task1, task2]);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const setExams = useExamStore((state) => state.setExams);
|
const setExams = useExamStore((state) => state.setExams);
|
||||||
@@ -93,18 +101,13 @@ const WritingGeneration = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const submitExam = () => {
|
const submitExam = () => {
|
||||||
if (!task1 || !task2) {
|
if (!task1 && !task2) {
|
||||||
toast.error("Please generate all tasks before submitting");
|
toast.error("Please generate a task before submitting");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
const exercise1 = task1
|
||||||
const exam: WritingExam = {
|
? ({
|
||||||
isDiagnostic: false,
|
|
||||||
minTimer: 60,
|
|
||||||
module: "writing",
|
|
||||||
exercises: [
|
|
||||||
{
|
|
||||||
id: v4(),
|
id: v4(),
|
||||||
type: "writing",
|
type: "writing",
|
||||||
prefix: `You should spend about 20 minutes on this task.`,
|
prefix: `You should spend about 20 minutes on this task.`,
|
||||||
@@ -115,8 +118,11 @@ const WritingGeneration = () => {
|
|||||||
limit: 150,
|
limit: 150,
|
||||||
type: "min",
|
type: "min",
|
||||||
},
|
},
|
||||||
},
|
} as WritingExercise)
|
||||||
{
|
: undefined;
|
||||||
|
|
||||||
|
const exercise2 = task2
|
||||||
|
? ({
|
||||||
id: v4(),
|
id: v4(),
|
||||||
type: "writing",
|
type: "writing",
|
||||||
prefix: `You should spend about 40 minutes on this task.`,
|
prefix: `You should spend about 40 minutes on this task.`,
|
||||||
@@ -127,9 +133,17 @@ const WritingGeneration = () => {
|
|||||||
limit: 250,
|
limit: 250,
|
||||||
type: "min",
|
type: "min",
|
||||||
},
|
},
|
||||||
},
|
} as WritingExercise)
|
||||||
],
|
: undefined;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
const exam: WritingExam = {
|
||||||
|
isDiagnostic: false,
|
||||||
|
minTimer,
|
||||||
|
module: "writing",
|
||||||
|
exercises: [...(exercise1 ? [exercise1] : []), ...(exercise2 ? [exercise2] : [])],
|
||||||
id: v4(),
|
id: v4(),
|
||||||
|
variant: exercise1 && exercise2 ? "full" : "partial",
|
||||||
};
|
};
|
||||||
|
|
||||||
axios
|
axios
|
||||||
@@ -152,29 +166,40 @@ const WritingGeneration = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
name="minTimer"
|
||||||
|
onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))}
|
||||||
|
value={minTimer}
|
||||||
|
className="max-w-[300px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Tab.Group>
|
<Tab.Group>
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1">
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1">
|
||||||
<Tab
|
<Tab
|
||||||
className={({selected}) =>
|
className={({selected}) =>
|
||||||
clsx(
|
clsx(
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/70",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/70 flex gap-2 items-center justify-center",
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
|
||||||
)
|
)
|
||||||
}>
|
}>
|
||||||
Task 1
|
Task 1 {task1 && <BsCheck />}
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab
|
<Tab
|
||||||
className={({selected}) =>
|
className={({selected}) =>
|
||||||
clsx(
|
clsx(
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/70",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/70 flex gap-2 items-center justify-center",
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
|
||||||
)
|
)
|
||||||
}>
|
}>
|
||||||
Task 2
|
Task 2 {task2 && <BsCheck />}
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tab.List>
|
</Tab.List>
|
||||||
<Tab.Panels>
|
<Tab.Panels>
|
||||||
@@ -200,14 +225,14 @@ const WritingGeneration = () => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
disabled={!task1 || !task2 || isLoading}
|
disabled={(!task1 && !task2) || isLoading}
|
||||||
data-tip="Please generate all three passages"
|
data-tip="Please generate all three passages"
|
||||||
onClick={submitExam}
|
onClick={submitExam}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"bg-ielts-writing/70 border border-ielts-writing text-white w-full max-w-[200px] rounded-xl h-[70px] self-end",
|
"bg-ielts-writing/70 border border-ielts-writing text-white w-full max-w-[200px] rounded-xl h-[70px] self-end",
|
||||||
"hover:bg-ielts-writing disabled:bg-ielts-writing/40 disabled:cursor-not-allowed",
|
"hover:bg-ielts-writing disabled:bg-ielts-writing/40 disabled:cursor-not-allowed",
|
||||||
"transition ease-in-out duration-300",
|
"transition ease-in-out duration-300",
|
||||||
(!task1 || !task2) && "tooltip",
|
!task1 && !task2 && "tooltip",
|
||||||
)}>
|
)}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {User} from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import {sendEmailVerification} from "@/utils/email";
|
import { sendEmailVerification } from "@/utils/email";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {Divider} from "primereact/divider";
|
import { Divider } from "primereact/divider";
|
||||||
import {useState} from "react";
|
import { useState } from "react";
|
||||||
import {toast} from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import {KeyedMutator} from "swr";
|
import { KeyedMutator } from "swr";
|
||||||
import Select from "react-select";
|
import Select from "react-select";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
|
|
||||||
@@ -19,13 +19,18 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const availableDurations = {
|
const availableDurations = {
|
||||||
"1_month": {label: "1 Month", number: 1},
|
"1_month": { label: "1 Month", number: 1 },
|
||||||
"3_months": {label: "3 Months", number: 3},
|
"3_months": { label: "3 Months", number: 3 },
|
||||||
"6_months": {label: "6 Months", number: 6},
|
"6_months": { label: "6 Months", number: 6 },
|
||||||
"12_months": {label: "12 Months", number: 12},
|
"12_months": { label: "12 Months", number: 12 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RegisterCorporate({isLoading, setIsLoading, mutateUser, sendEmailVerification}: Props) {
|
export default function RegisterCorporate({
|
||||||
|
isLoading,
|
||||||
|
setIsLoading,
|
||||||
|
mutateUser,
|
||||||
|
sendEmailVerification,
|
||||||
|
}: Props) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -36,20 +41,27 @@ export default function RegisterCorporate({isLoading, setIsLoading, mutateUser,
|
|||||||
const [companyUsers, setCompanyUsers] = useState(0);
|
const [companyUsers, setCompanyUsers] = useState(0);
|
||||||
const [subscriptionDuration, setSubscriptionDuration] = useState(1);
|
const [subscriptionDuration, setSubscriptionDuration] = useState(1);
|
||||||
|
|
||||||
const {users} = useUsers();
|
const { users } = useUsers();
|
||||||
|
|
||||||
const onSuccess = () => toast.success("An e-mail has been sent, please make sure to check your spam folder!");
|
const onSuccess = () =>
|
||||||
|
toast.success(
|
||||||
|
"An e-mail has been sent, please make sure to check your spam folder!",
|
||||||
|
);
|
||||||
|
|
||||||
const onError = (e: Error) => {
|
const onError = (e: Error) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
toast.error("Something went wrong, please logout and re-login.", {toastId: "send-verify-error"});
|
toast.error("Something went wrong, please logout and re-login.", {
|
||||||
|
toastId: "send-verify-error",
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const register = (e: any) => {
|
const register = (e: any) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (confirmPassword !== password) {
|
if (confirmPassword !== password) {
|
||||||
toast.error("Your passwords do not match!", {toastId: "password-not-match"});
|
toast.error("Your passwords do not match!", {
|
||||||
|
toastId: "password-not-match",
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +84,9 @@ export default function RegisterCorporate({isLoading, setIsLoading, mutateUser,
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
mutateUser(response.data.user).then(() => sendEmailVerification(setIsLoading, onSuccess, onError));
|
mutateUser(response.data.user).then(() =>
|
||||||
|
sendEmailVerification(setIsLoading, onSuccess, onError),
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.log(error.response.data);
|
console.log(error.response.data);
|
||||||
@@ -93,13 +107,30 @@ export default function RegisterCorporate({isLoading, setIsLoading, mutateUser,
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="flex flex-col items-center gap-4 w-full" onSubmit={register}>
|
<form
|
||||||
<div className="w-full flex gap-4">
|
className="flex w-full flex-col items-center gap-4"
|
||||||
<Input type="text" name="name" onChange={(e) => setName(e)} placeholder="Enter your name" defaultValue={name} required />
|
onSubmit={register}
|
||||||
<Input type="email" name="email" onChange={(e) => setEmail(e)} placeholder="Enter email address" defaultValue={email} required />
|
>
|
||||||
|
<div className="flex w-full gap-4">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
onChange={(e) => setName(e)}
|
||||||
|
placeholder="Enter your name"
|
||||||
|
defaultValue={name}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
onChange={(e) => setEmail(e.toLowerCase())}
|
||||||
|
placeholder="Enter email address"
|
||||||
|
defaultValue={email}
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full flex gap-4">
|
<div className="flex w-full gap-4">
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
@@ -118,9 +149,9 @@ export default function RegisterCorporate({isLoading, setIsLoading, mutateUser,
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Divider className="w-full !my-2" />
|
<Divider className="!my-2 w-full" />
|
||||||
|
|
||||||
<div className="w-full flex gap-4">
|
<div className="flex w-full gap-4">
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
name="companyName"
|
name="companyName"
|
||||||
@@ -140,16 +171,20 @@ export default function RegisterCorporate({isLoading, setIsLoading, mutateUser,
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full flex gap-4">
|
<div className="flex w-full gap-4">
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex w-full flex-col gap-3">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Referral *</label>
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
|
Referral *
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
className="placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none disabled:cursor-not-allowed"
|
||||||
options={[
|
options={[
|
||||||
{value: "", label: "No referral"},
|
{ value: "", label: "No referral" },
|
||||||
...users.filter((u) => u.type === "agent").map((x) => ({value: x.id, label: `${x.name} - ${x.email}`})),
|
...users
|
||||||
|
.filter((u) => u.type === "agent")
|
||||||
|
.map((x) => ({ value: x.id, label: `${x.name} - ${x.email}` })),
|
||||||
]}
|
]}
|
||||||
defaultValue={{value: "", label: "No referral"}}
|
defaultValue={{ value: "", label: "No referral" }}
|
||||||
onChange={(value) => setReferralAgent(value?.value)}
|
onChange={(value) => setReferralAgent(value?.value)}
|
||||||
styles={{
|
styles={{
|
||||||
control: (styles) => ({
|
control: (styles) => ({
|
||||||
@@ -163,24 +198,41 @@ export default function RegisterCorporate({isLoading, setIsLoading, mutateUser,
|
|||||||
}),
|
}),
|
||||||
option: (styles, state) => ({
|
option: (styles, state) => ({
|
||||||
...styles,
|
...styles,
|
||||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
backgroundColor: state.isFocused
|
||||||
|
? "#D5D9F0"
|
||||||
|
: state.isSelected
|
||||||
|
? "#7872BF"
|
||||||
|
: "white",
|
||||||
color: state.isFocused ? "black" : styles.color,
|
color: state.isFocused ? "black" : styles.color,
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex w-full flex-col gap-3">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Subscription Duration *</label>
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
|
Subscription Duration *
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
className="placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none disabled:cursor-not-allowed"
|
||||||
options={Object.keys(availableDurations).map((value) => ({
|
options={Object.keys(availableDurations).map((value) => ({
|
||||||
value,
|
value,
|
||||||
label: availableDurations[value as keyof typeof availableDurations].label,
|
label:
|
||||||
|
availableDurations[value as keyof typeof availableDurations]
|
||||||
|
.label,
|
||||||
}))}
|
}))}
|
||||||
defaultValue={{value: "1_month", label: availableDurations["1_month"].label}}
|
defaultValue={{
|
||||||
|
value: "1_month",
|
||||||
|
label: availableDurations["1_month"].label,
|
||||||
|
}}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setSubscriptionDuration(value ? availableDurations[value.value as keyof typeof availableDurations].number : 1)
|
setSubscriptionDuration(
|
||||||
|
value
|
||||||
|
? availableDurations[
|
||||||
|
value.value as keyof typeof availableDurations
|
||||||
|
].number
|
||||||
|
: 1,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
styles={{
|
styles={{
|
||||||
control: (styles) => ({
|
control: (styles) => ({
|
||||||
@@ -194,7 +246,11 @@ export default function RegisterCorporate({isLoading, setIsLoading, mutateUser,
|
|||||||
}),
|
}),
|
||||||
option: (styles, state) => ({
|
option: (styles, state) => ({
|
||||||
...styles,
|
...styles,
|
||||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
backgroundColor: state.isFocused
|
||||||
|
? "#D5D9F0"
|
||||||
|
: state.isSelected
|
||||||
|
? "#7872BF"
|
||||||
|
: "white",
|
||||||
color: state.isFocused ? "black" : styles.color,
|
color: state.isFocused ? "black" : styles.color,
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
@@ -203,11 +259,19 @@ export default function RegisterCorporate({isLoading, setIsLoading, mutateUser,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className="lg:mt-8 w-full"
|
className="w-full lg:mt-8"
|
||||||
color="purple"
|
color="purple"
|
||||||
disabled={
|
disabled={
|
||||||
isLoading || !email || !name || !password || !confirmPassword || password !== confirmPassword || !companyName || companyUsers <= 0
|
isLoading ||
|
||||||
}>
|
!email ||
|
||||||
|
!name ||
|
||||||
|
!password ||
|
||||||
|
!confirmPassword ||
|
||||||
|
password !== confirmPassword ||
|
||||||
|
!companyName ||
|
||||||
|
companyUsers <= 0
|
||||||
|
}
|
||||||
|
>
|
||||||
Create account
|
Create account
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import {User} from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import {sendEmailVerification} from "@/utils/email";
|
import { sendEmailVerification } from "@/utils/email";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {useEffect, useState} from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {toast} from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import {KeyedMutator} from "swr";
|
import { KeyedMutator } from "swr";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
queryCode?: string;
|
queryCode?: string;
|
||||||
@@ -21,7 +21,14 @@ interface Props {
|
|||||||
sendEmailVerification: typeof sendEmailVerification;
|
sendEmailVerification: typeof sendEmailVerification;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RegisterIndividual({queryCode, defaultInformation, isLoading, setIsLoading, mutateUser, sendEmailVerification}: Props) {
|
export default function RegisterIndividual({
|
||||||
|
queryCode,
|
||||||
|
defaultInformation,
|
||||||
|
isLoading,
|
||||||
|
setIsLoading,
|
||||||
|
mutateUser,
|
||||||
|
sendEmailVerification,
|
||||||
|
}: Props) {
|
||||||
const [name, setName] = useState(defaultInformation?.name || "");
|
const [name, setName] = useState(defaultInformation?.name || "");
|
||||||
const [email, setEmail] = useState(defaultInformation?.email || "");
|
const [email, setEmail] = useState(defaultInformation?.email || "");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -29,18 +36,25 @@ export default function RegisterIndividual({queryCode, defaultInformation, isLoa
|
|||||||
const [code, setCode] = useState(queryCode || "");
|
const [code, setCode] = useState(queryCode || "");
|
||||||
const [hasCode, setHasCode] = useState<boolean>(!!queryCode);
|
const [hasCode, setHasCode] = useState<boolean>(!!queryCode);
|
||||||
|
|
||||||
const onSuccess = () => toast.success("An e-mail has been sent, please make sure to check your spam folder!");
|
const onSuccess = () =>
|
||||||
|
toast.success(
|
||||||
|
"An e-mail has been sent, please make sure to check your spam folder!",
|
||||||
|
);
|
||||||
|
|
||||||
const onError = (e: Error) => {
|
const onError = (e: Error) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
toast.error("Something went wrong, please logout and re-login.", {toastId: "send-verify-error"});
|
toast.error("Something went wrong, please logout and re-login.", {
|
||||||
|
toastId: "send-verify-error",
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const register = (e: any) => {
|
const register = (e: any) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (confirmPassword !== password) {
|
if (confirmPassword !== password) {
|
||||||
toast.error("Your passwords do not match!", {toastId: "password-not-match"});
|
toast.error("Your passwords do not match!", {
|
||||||
|
toastId: "password-not-match",
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +70,9 @@ export default function RegisterIndividual({queryCode, defaultInformation, isLoa
|
|||||||
profilePicture: "/defaultAvatar.png",
|
profilePicture: "/defaultAvatar.png",
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
mutateUser(response.data.user).then(() => sendEmailVerification(setIsLoading, onSuccess, onError));
|
mutateUser(response.data.user).then(() =>
|
||||||
|
sendEmailVerification(setIsLoading, onSuccess, onError),
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.log(error.response.data);
|
console.log(error.response.data);
|
||||||
@@ -77,12 +93,22 @@ export default function RegisterIndividual({queryCode, defaultInformation, isLoa
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="flex flex-col items-center gap-6 w-full" onSubmit={register}>
|
<form
|
||||||
<Input type="text" name="name" onChange={(e) => setName(e)} placeholder="Enter your name" value={name} required />
|
className="flex w-full flex-col items-center gap-6"
|
||||||
|
onSubmit={register}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
onChange={(e) => setName(e)}
|
||||||
|
placeholder="Enter your name"
|
||||||
|
value={name}
|
||||||
|
required
|
||||||
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
onChange={(e) => setEmail(e)}
|
onChange={(e) => setEmail(e.toLowerCase())}
|
||||||
placeholder="Enter email address"
|
placeholder="Enter email address"
|
||||||
value={email}
|
value={email}
|
||||||
disabled={!!defaultInformation?.email}
|
disabled={!!defaultInformation?.email}
|
||||||
@@ -105,7 +131,7 @@ export default function RegisterIndividual({queryCode, defaultInformation, isLoa
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 w-full items-start">
|
<div className="flex w-full flex-col items-start gap-4">
|
||||||
<Checkbox isChecked={hasCode} onChange={setHasCode}>
|
<Checkbox isChecked={hasCode} onChange={setHasCode}>
|
||||||
I have a code
|
I have a code
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
@@ -122,9 +148,18 @@ export default function RegisterIndividual({queryCode, defaultInformation, isLoa
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className="lg:mt-8 w-full"
|
className="w-full lg:mt-8"
|
||||||
color="purple"
|
color="purple"
|
||||||
disabled={isLoading || !email || !name || !password || !confirmPassword || password !== confirmPassword || (hasCode ? !code : false)}>
|
disabled={
|
||||||
|
isLoading ||
|
||||||
|
!email ||
|
||||||
|
!name ||
|
||||||
|
!password ||
|
||||||
|
!confirmPassword ||
|
||||||
|
password !== confirmPassword ||
|
||||||
|
(hasCode ? !code : false)
|
||||||
|
}
|
||||||
|
>
|
||||||
Create account
|
Create account
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import clsx from "clsx";
|
|||||||
import {capitalize} from "lodash";
|
import {capitalize} from "lodash";
|
||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
import getSymbolFromCurrency from "currency-symbol-map";
|
import getSymbolFromCurrency from "currency-symbol-map";
|
||||||
|
import useInvites from "@/hooks/useInvites";
|
||||||
|
import {BsArrowRepeat} from "react-icons/bs";
|
||||||
|
import InviteCard from "@/components/Medium/InviteCard";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import {PayPalScriptProvider} from "@paypal/react-paypal-js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -20,9 +25,12 @@ interface Props {
|
|||||||
export default function PaymentDue({user, hasExpired = false, clientID, reload}: Props) {
|
export default function PaymentDue({user, hasExpired = false, clientID, reload}: Props) {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const {packages} = usePackages();
|
const {packages} = usePackages();
|
||||||
const {users} = useUsers();
|
const {users} = useUsers();
|
||||||
const {groups} = useGroups();
|
const {groups} = useGroups();
|
||||||
|
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user?.id});
|
||||||
|
|
||||||
const isIndividual = () => {
|
const isIndividual = () => {
|
||||||
if (user?.type === "developer") return true;
|
if (user?.type === "developer") return true;
|
||||||
@@ -38,35 +46,69 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="w-screen h-screen absolute top-0 left-0 overflow-hidden z-[999] bg-black/60">
|
<div className="absolute left-0 top-0 z-[999] h-screen w-screen overflow-hidden bg-black/60">
|
||||||
<div className="w-fit h-fit absolute top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 animate-pulse flex flex-col gap-8 items-center text-white">
|
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-8 text-white">
|
||||||
<span className={clsx("loading loading-infinity w-48")} />
|
<span className={clsx("loading loading-infinity w-48")} />
|
||||||
<span className={clsx("font-bold text-2xl")}>Completing your payment...</span>
|
<span className={clsx("text-2xl font-bold")}>Completing your payment...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{user ? (
|
{user ? (
|
||||||
<Layout user={user} navDisabled={hasExpired}>
|
<Layout user={user} navDisabled={hasExpired}>
|
||||||
<div className="flex flex-col items-center justify-center text-center w-full gap-4">
|
{invites.length > 0 && (
|
||||||
{hasExpired && <span className="font-bold text-lg">You do not have time credits for your account type!</span>}
|
<section className="flex flex-col gap-1 md:gap-3">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
onClick={reloadInvites}
|
||||||
|
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out">
|
||||||
|
<span className="text-mti-black text-lg font-bold">Invites</span>
|
||||||
|
<BsArrowRepeat className={clsx("text-xl", isInvitesLoading && "animate-spin")} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
||||||
|
{invites.map((invite) => (
|
||||||
|
<InviteCard
|
||||||
|
key={invite.id}
|
||||||
|
invite={invite}
|
||||||
|
users={users}
|
||||||
|
reload={() => {
|
||||||
|
reloadInvites();
|
||||||
|
router.reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex w-full flex-col items-center justify-center gap-4 text-center">
|
||||||
|
{hasExpired && <span className="text-lg font-bold">You do not have time credits for your account type!</span>}
|
||||||
{isIndividual() && (
|
{isIndividual() && (
|
||||||
<div className="flex flex-col items-center w-full overflow-x-scroll scrollbar-hide gap-12">
|
<div className="scrollbar-hide flex w-full flex-col items-center gap-12 overflow-x-scroll">
|
||||||
<span className="max-w-lg">
|
<span className="max-w-lg">
|
||||||
To add to your use of EnCoach, please purchase one of the time packages available below:
|
To add to your use of EnCoach, please purchase one of the time packages available below:
|
||||||
</span>
|
</span>
|
||||||
<div className="w-full flex flex-wrap justify-center gap-8">
|
<div className="flex w-full flex-wrap justify-center gap-8">
|
||||||
|
<PayPalScriptProvider
|
||||||
|
options={{
|
||||||
|
clientId: clientID,
|
||||||
|
currency: "USD",
|
||||||
|
intent: "capture",
|
||||||
|
commit: true,
|
||||||
|
vault: true,
|
||||||
|
}}>
|
||||||
{packages.map((p) => (
|
{packages.map((p) => (
|
||||||
<div key={p.id} className={clsx("p-4 bg-white rounded-xl flex flex-col gap-6 items-start")}>
|
<div key={p.id} className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}>
|
||||||
<div className="flex flex-col items-start mb-2">
|
<div className="mb-2 flex flex-col items-start">
|
||||||
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" />
|
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" />
|
||||||
<span className="font-semibold text-xl">
|
<span className="text-xl font-semibold">
|
||||||
EnCoach - {p.duration}{" "}
|
EnCoach - {p.duration}{" "}
|
||||||
{capitalize(
|
{capitalize(
|
||||||
p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit,
|
p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit,
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 items-start w-full">
|
<div className="flex w-full flex-col items-start gap-2">
|
||||||
<span className="text-2xl">
|
<span className="text-2xl">
|
||||||
{p.price}
|
{p.price}
|
||||||
{getSymbolFromCurrency(p.currency)}
|
{getSymbolFromCurrency(p.currency)}
|
||||||
@@ -81,7 +123,7 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1 items-start">
|
<div className="flex flex-col items-start gap-1">
|
||||||
<span>This includes:</span>
|
<span>This includes:</span>
|
||||||
<ul className="flex flex-col items-start text-sm">
|
<ul className="flex flex-col items-start text-sm">
|
||||||
<li>- Train your abilities for the IELTS exam</li>
|
<li>- Train your abilities for the IELTS exam</li>
|
||||||
@@ -91,6 +133,7 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</PayPalScriptProvider>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -99,12 +142,12 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
|
|||||||
<span className="max-w-lg">
|
<span className="max-w-lg">
|
||||||
To add to your use of EnCoach and that of your students and teachers, please pay your designated package below:
|
To add to your use of EnCoach and that of your students and teachers, please pay your designated package below:
|
||||||
</span>
|
</span>
|
||||||
<div className={clsx("p-4 bg-white rounded-xl flex flex-col gap-6 items-start")}>
|
<div className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}>
|
||||||
<div className="flex flex-col items-start mb-2">
|
<div className="mb-2 flex flex-col items-start">
|
||||||
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" />
|
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" />
|
||||||
<span className="font-semibold text-xl">EnCoach - {user.corporateInformation?.monthlyDuration} Months</span>
|
<span className="text-xl font-semibold">EnCoach - {user.corporateInformation?.monthlyDuration} Months</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 items-start w-full">
|
<div className="flex w-full flex-col items-start gap-2">
|
||||||
<span className="text-2xl">
|
<span className="text-2xl">
|
||||||
{user.corporateInformation.payment.value}
|
{user.corporateInformation.payment.value}
|
||||||
{getSymbolFromCurrency(user.corporateInformation.payment.currency)}
|
{getSymbolFromCurrency(user.corporateInformation.payment.currency)}
|
||||||
@@ -121,9 +164,10 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setTimeout(reload, 500);
|
setTimeout(reload, 500);
|
||||||
}}
|
}}
|
||||||
|
loadScript
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1 items-start">
|
<div className="flex flex-col items-start gap-1">
|
||||||
<span>This includes:</span>
|
<span>This includes:</span>
|
||||||
<ul className="flex flex-col items-start text-sm">
|
<ul className="flex flex-col items-start text-sm">
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import {useRouter} from "next/router";
|
|||||||
import {useEffect} from "react";
|
import {useEffect} from "react";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import usePreferencesStore from "@/stores/preferencesStore";
|
import usePreferencesStore from "@/stores/preferencesStore";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
export default function App({Component, pageProps}: AppProps) {
|
export default function App({Component, pageProps}: AppProps) {
|
||||||
const reset = useExamStore((state) => state.reset);
|
const {reset} = useExamStore((state) => state);
|
||||||
const setIsSidebarMinimized = usePreferencesStore((state) => state.setSidebarMinimized);
|
const setIsSidebarMinimized = usePreferencesStore((state) => state.setSidebarMinimized);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import {toast, ToastContainer} from "react-toastify";
|
import { toast, ToastContainer } from "react-toastify";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {FormEvent, useEffect, useState} from "react";
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import useUser from "@/hooks/useUser";
|
import useUser from "@/hooks/useUser";
|
||||||
import {Divider} from "primereact/divider";
|
import { Divider } from "primereact/divider";
|
||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import {BsArrowRepeat} from "react-icons/bs";
|
import { BsArrowRepeat } from "react-icons/bs";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import {useRouter} from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
export function getServerSideProps({query, res}: {query: {oobCode: string; mode: string; apiKey?: string; continueUrl?: string}; res: any}) {
|
export function getServerSideProps({
|
||||||
|
query,
|
||||||
|
res,
|
||||||
|
}: {
|
||||||
|
query: {
|
||||||
|
oobCode: string;
|
||||||
|
mode: string;
|
||||||
|
continueUrl?: string;
|
||||||
|
};
|
||||||
|
res: any;
|
||||||
|
}) {
|
||||||
if (!query || !query.oobCode || !query.mode) {
|
if (!query || !query.oobCode || !query.mode) {
|
||||||
res.setHeader("location", "/login");
|
res.setHeader("location", "/login");
|
||||||
res.statusCode = 302;
|
res.statusCode = 302;
|
||||||
@@ -25,13 +35,20 @@ export function getServerSideProps({query, res}: {query: {oobCode: string; mode:
|
|||||||
props: {
|
props: {
|
||||||
code: query.oobCode,
|
code: query.oobCode,
|
||||||
mode: query.mode,
|
mode: query.mode,
|
||||||
apiKey: query.apiKey,
|
...(query.continueUrl ? { continueUrl: query.continueUrl } : {}),
|
||||||
continueUrl: query.continueUrl,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Reset({code, mode, apiKey, continueUrl}: {code: string; mode: string; apiKey?: string; continueUrl?: string}) {
|
export default function Reset({
|
||||||
|
code,
|
||||||
|
mode,
|
||||||
|
continueUrl,
|
||||||
|
}: {
|
||||||
|
code: string;
|
||||||
|
mode: string;
|
||||||
|
continueUrl?: string;
|
||||||
|
}) {
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
@@ -45,26 +62,34 @@ export default function Reset({code, mode, apiKey, continueUrl}: {code: string;
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode === "signIn") {
|
if (mode === "signIn") {
|
||||||
axios
|
axios
|
||||||
.post<{ok: boolean}>("/api/reset/verify", {
|
.post<{ ok: boolean }>("/api/reset/verify", {
|
||||||
email: continueUrl?.replace("https://platform.encoach.com/", ""),
|
email: continueUrl?.replace("https://platform.encoach.com/", ""),
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.data.ok) {
|
if (response.data.ok) {
|
||||||
toast.success("Your account has been verified!", {toastId: "verify-successful"});
|
toast.success("Your account has been verified!", {
|
||||||
|
toastId: "verify-successful",
|
||||||
|
});
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.reload();
|
router.push("/");
|
||||||
}, 1000);
|
}, 1000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error("Something went wrong! Please make sure to click the link in your e-mail again and input the correct e-mail!", {
|
toast.error(
|
||||||
|
"Something went wrong! Please make sure to click the link in your e-mail again and input the correct e-mail!",
|
||||||
|
{
|
||||||
toastId: "verify-error",
|
toastId: "verify-error",
|
||||||
});
|
},
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Something went wrong! Please make sure to click the link in your e-mail again and input the correct e-mail!", {
|
toast.error(
|
||||||
|
"Something went wrong! Please make sure to click the link in your e-mail again and input the correct e-mail!",
|
||||||
|
{
|
||||||
toastId: "verify-error",
|
toastId: "verify-error",
|
||||||
});
|
},
|
||||||
|
);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -75,20 +100,28 @@ export default function Reset({code, mode, apiKey, continueUrl}: {code: string;
|
|||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.post<{ok: boolean}>("/api/reset/confirm", {code, password})
|
.post<{ ok: boolean }>("/api/reset/confirm", { code, password })
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.data.ok) {
|
if (response.data.ok) {
|
||||||
toast.success("Your password has been reset!", {toastId: "reset-successful"});
|
toast.success("Your password has been reset!", {
|
||||||
|
toastId: "reset-successful",
|
||||||
|
});
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
}, 1000);
|
}, 1000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error("Something went wrong! Please make sure to click the link in your e-mail again!", {toastId: "reset-error"});
|
toast.error(
|
||||||
|
"Something went wrong! Please make sure to click the link in your e-mail again!",
|
||||||
|
{ toastId: "reset-error" },
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Something went wrong! Please make sure to click the link in your e-mail again!", {toastId: "reset-error"});
|
toast.error(
|
||||||
|
"Something went wrong! Please make sure to click the link in your e-mail again!",
|
||||||
|
{ toastId: "reset-error" },
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
@@ -101,33 +134,60 @@ export default function Reset({code, mode, apiKey, continueUrl}: {code: string;
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<main className="w-full h-[100vh] flex bg-white text-black">
|
<main className="flex h-[100vh] w-full bg-white text-black">
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
<section className="h-full w-fit min-w-fit relative hidden lg:flex">
|
<section className="relative hidden h-full w-fit min-w-fit lg:flex">
|
||||||
<div className="absolute h-full w-full bg-mti-rose-light z-10 bg-opacity-50" />
|
<div className="bg-mti-rose-light absolute z-10 h-full w-full bg-opacity-50" />
|
||||||
<img src="/people-talking-tablet.png" alt="People smiling looking at a tablet" className="h-full aspect-auto" />
|
<img
|
||||||
|
src="/people-talking-tablet.png"
|
||||||
|
alt="People smiling looking at a tablet"
|
||||||
|
className="aspect-auto h-full"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
{mode === "resetPassword" && (
|
{mode === "resetPassword" && (
|
||||||
<section className="h-full w-full flex flex-col items-center justify-center gap-2">
|
<section className="flex h-full w-full flex-col items-center justify-center gap-2">
|
||||||
<div className="flex flex-col gap-2 items-center relative">
|
<div className="relative flex flex-col items-center gap-2">
|
||||||
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-36 lg:w-64 absolute -top-36 lg:-top-64" />
|
<img
|
||||||
<h1 className="font-bold text-2xl lg:text-4xl">Reset your password</h1>
|
src="/logo_title.png"
|
||||||
<p className="self-start text-sm lg:text-base font-normal text-mti-gray-cool">to your registered Email Address</p>
|
alt="EnCoach's Logo"
|
||||||
|
className="absolute -top-36 w-36 lg:-top-64 lg:w-64"
|
||||||
|
/>
|
||||||
|
<h1 className="text-2xl font-bold lg:text-4xl">
|
||||||
|
Reset your password
|
||||||
|
</h1>
|
||||||
|
<p className="text-mti-gray-cool self-start text-sm font-normal lg:text-base">
|
||||||
|
to your registered Email Address
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Divider className="max-w-xs lg:max-w-md" />
|
<Divider className="max-w-xs lg:max-w-md" />
|
||||||
<form className="flex flex-col items-center gap-6 w-full -lg:px-8 lg:w-1/2" onSubmit={login}>
|
<form
|
||||||
<Input type="password" name="password" onChange={(e) => setPassword(e)} placeholder="Password" />
|
className="-lg:px-8 flex w-full flex-col items-center gap-6 lg:w-1/2"
|
||||||
|
onSubmit={login}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
onChange={(e) => setPassword(e)}
|
||||||
|
placeholder="Password"
|
||||||
|
/>
|
||||||
|
|
||||||
<Button className="mt-8 w-full" color="purple" disabled={isLoading}>
|
<Button
|
||||||
|
className="mt-8 w-full"
|
||||||
|
color="purple"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
{!isLoading && "Reset"}
|
{!isLoading && "Reset"}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
<BsArrowRepeat
|
||||||
|
className="animate-spin text-white"
|
||||||
|
size={25}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
<span className="text-mti-gray-cool text-sm font-normal mt-8">
|
<span className="text-mti-gray-cool mt-8 text-sm font-normal">
|
||||||
Don't have an account?{" "}
|
Don't have an account?{" "}
|
||||||
<Link className="text-mti-purple-light" href="/register">
|
<Link className="text-mti-purple-light" href="/register">
|
||||||
Sign up
|
Sign up
|
||||||
@@ -136,17 +196,27 @@ export default function Reset({code, mode, apiKey, continueUrl}: {code: string;
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{mode === "signIn" && (
|
{mode === "signIn" && (
|
||||||
<section className="h-full w-full flex flex-col items-center justify-center gap-2">
|
<section className="flex h-full w-full flex-col items-center justify-center gap-2">
|
||||||
<div className="flex flex-col gap-2 items-center relative">
|
<div className="relative flex flex-col items-center gap-2">
|
||||||
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-36 lg:w-64 absolute -top-36 lg:-top-64" />
|
<img
|
||||||
<h1 className="font-bold text-2xl lg:text-4xl">Confirm your account</h1>
|
src="/logo_title.png"
|
||||||
<p className="self-start text-sm lg:text-base font-normal text-mti-gray-cool">to your registered Email Address</p>
|
alt="EnCoach's Logo"
|
||||||
|
className="absolute -top-36 w-36 lg:-top-64 lg:w-64"
|
||||||
|
/>
|
||||||
|
<h1 className="text-2xl font-bold lg:text-4xl">
|
||||||
|
Confirm your account
|
||||||
|
</h1>
|
||||||
|
<p className="text-mti-gray-cool self-start text-sm font-normal lg:text-base">
|
||||||
|
to your registered Email Address
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Divider className="max-w-xs lg:max-w-md" />
|
<Divider className="max-w-xs lg:max-w-md" />
|
||||||
<div className="flex flex-col items-center gap-6 w-full -lg:px-8 lg:w-1/2">
|
<div className="-lg:px-8 flex w-full flex-col items-center gap-6 lg:w-1/2">
|
||||||
<span className="text-center">
|
<span className="text-center">
|
||||||
Your e-mail is currently being verified, please wait a second. <br /> <br />
|
Your e-mail is currently being verified, please wait a second.{" "}
|
||||||
Once it has been verified, you will be redirected to the home page.
|
<br /> <br />
|
||||||
|
Once it has been verified, you will be redirected to the home
|
||||||
|
page.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -106,22 +106,20 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
results: any;
|
results: any;
|
||||||
exams: {module: Module}[];
|
exams: {module: Module}[];
|
||||||
startDate: string;
|
startDate: string;
|
||||||
pdf?: string;
|
pdf: {
|
||||||
|
path: string,
|
||||||
|
version: string,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
if (!data) {
|
if (!data) {
|
||||||
res.status(400).end();
|
res.status(400).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.assigner !== req.session.user.id) {
|
if (data.pdf && data.pdf.path && data.pdf.version === process.env.PDF_VERSION) {
|
||||||
res.status(401).json({ok: false});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (data.pdf) {
|
|
||||||
// if it does, return the pdf url
|
// if it does, return the pdf url
|
||||||
const fileRef = ref(storage, data.pdf);
|
const fileRef = ref(storage, data.pdf.path);
|
||||||
const url = await getDownloadURL(fileRef);
|
const url = await getDownloadURL(fileRef);
|
||||||
|
|
||||||
res.status(200).end(url);
|
res.status(200).end(url);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -158,9 +156,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return {...e, bandScore};
|
return {...e, bandScore};
|
||||||
});
|
});
|
||||||
|
|
||||||
const moduleResults = data.exams.map(({module}) => {
|
// in order to make sure we are using unique modules, generate the set based on them
|
||||||
|
const uniqueModules = [...new Set(flattenResults.map(item => item.module))] as Module[];
|
||||||
|
const moduleResults = uniqueModules.map((module) => {
|
||||||
const moduleResults = flattenResultsWithGrade.filter((e) => e.module === module);
|
const moduleResults = flattenResultsWithGrade.filter((e) => e.module === module);
|
||||||
|
|
||||||
const baseBandScore = moduleResults.reduce((accm, curr) => accm + curr.bandScore, 0) / moduleResults.length;
|
const baseBandScore = moduleResults.reduce((accm, curr) => accm + curr.bandScore, 0) / moduleResults.length;
|
||||||
const bandScore = isNaN(baseBandScore) ? 0 : baseBandScore;
|
const bandScore = isNaN(baseBandScore) ? 0 : baseBandScore;
|
||||||
const {correct, total} = getScoreAndTotal(moduleResults);
|
const {correct, total} = getScoreAndTotal(moduleResults);
|
||||||
@@ -239,6 +238,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const result = exams.length === 0 ? "N/A" : `${correct}/${total}`;
|
const result = exams.length === 0 ? "N/A" : `${correct}/${total}`;
|
||||||
|
|
||||||
|
const userDemographicInformation = user?.demographicInformation as DemographicInformation;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name: user?.name || "N/A",
|
name: user?.name || "N/A",
|
||||||
@@ -248,6 +249,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
result,
|
result,
|
||||||
level: showLevel ? getLevelScoreForUserExams(bandScore) : undefined,
|
level: showLevel ? getLevelScoreForUserExams(bandScore) : undefined,
|
||||||
bandScore,
|
bandScore,
|
||||||
|
passportId: userDemographicInformation?.passport_id || ""
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -387,7 +389,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
// update the stats entries with the pdf url to prevent duplication
|
// update the stats entries with the pdf url to prevent duplication
|
||||||
await updateDoc(docSnap.ref, {
|
await updateDoc(docSnap.ref, {
|
||||||
pdf: refName,
|
pdf: {
|
||||||
|
path: refName,
|
||||||
|
version: process.env.PDF_VERSION,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const url = await getDownloadURL(fileRef);
|
const url = await getDownloadURL(fileRef);
|
||||||
res.status(200).end(url);
|
res.status(200).end(url);
|
||||||
@@ -419,8 +424,8 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.pdf) {
|
if (data.pdf?.path) {
|
||||||
const fileRef = ref(storage, data.pdf);
|
const fileRef = ref(storage, data.pdf.path);
|
||||||
const url = await getDownloadURL(fileRef);
|
const url = await getDownloadURL(fileRef);
|
||||||
return res.redirect(url);
|
return res.redirect(url);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import {sessionOptions} from "@/lib/session";
|
|||||||
import {uuidv4} from "@firebase/util";
|
import {uuidv4} from "@firebase/util";
|
||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import {getExams} from "@/utils/exams.be";
|
import {getExams} from "@/utils/exams.be";
|
||||||
import {Exam} from "@/interfaces/exam";
|
import {Exam, InstructorGender, Variant} from "@/interfaces/exam";
|
||||||
import {capitalize, flatten} from "lodash";
|
import {capitalize, flatten, uniqBy} from "lodash";
|
||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {sendEmail} from "@/email";
|
import {sendEmail} from "@/email";
|
||||||
@@ -52,13 +52,19 @@ function getRandomIndex(arr: any[]): number {
|
|||||||
return randomIndex;
|
return randomIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
const generateExams = async (generateMultiple: Boolean, selectedModules: Module[], assignees: string[]): Promise<ExamWithUser[]> => {
|
const generateExams = async (
|
||||||
|
generateMultiple: Boolean,
|
||||||
|
selectedModules: Module[],
|
||||||
|
assignees: string[],
|
||||||
|
variant?: Variant,
|
||||||
|
instructorGender?: InstructorGender,
|
||||||
|
): Promise<ExamWithUser[]> => {
|
||||||
if (generateMultiple) {
|
if (generateMultiple) {
|
||||||
// for optimization purposes, it would be better to create a new endpoint that returned the answers for all users at once
|
// for optimization purposes, it would be better to create a new endpoint that returned the answers for all users at once
|
||||||
const allExams = await assignees.map(async (assignee) => {
|
const allExams = assignees.map(async (assignee) => {
|
||||||
const selectedModulePromises = await selectedModules.map(async (module: Module) => {
|
const selectedModulePromises = selectedModules.map(async (module: Module) => {
|
||||||
try {
|
try {
|
||||||
const exams: Exam[] = await getExams(db, module, "true", assignee);
|
const exams: Exam[] = await getExams(db, module, "true", assignee, variant, instructorGender);
|
||||||
|
|
||||||
const exam = exams[getRandomIndex(exams)];
|
const exam = exams[getRandomIndex(exams)];
|
||||||
if (exam) {
|
if (exam) {
|
||||||
@@ -79,8 +85,8 @@ const generateExams = async (generateMultiple: Boolean, selectedModules: Module[
|
|||||||
return exams;
|
return exams;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedModulePromises = await selectedModules.map(async (module: Module) => {
|
const selectedModulePromises = selectedModules.map(async (module: Module) => {
|
||||||
const exams: Exam[] = await getExams(db, module, "false", undefined);
|
const exams: Exam[] = await getExams(db, module, "false", undefined, variant, instructorGender);
|
||||||
const exam = exams[getRandomIndex(exams)];
|
const exam = exams[getRandomIndex(exams)];
|
||||||
|
|
||||||
if (exam) {
|
if (exam) {
|
||||||
@@ -101,6 +107,8 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
// Generate multiple true would generate an unique exam for each user
|
// Generate multiple true would generate an unique exam for each user
|
||||||
// false would generate the same exam for all users
|
// false would generate the same exam for all users
|
||||||
generateMultiple = false,
|
generateMultiple = false,
|
||||||
|
variant,
|
||||||
|
instructorGender,
|
||||||
...body
|
...body
|
||||||
} = req.body as {
|
} = req.body as {
|
||||||
selectedModules: Module[];
|
selectedModules: Module[];
|
||||||
@@ -109,9 +117,11 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
name: string;
|
name: string;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
|
variant?: Variant;
|
||||||
|
instructorGender?: InstructorGender;
|
||||||
};
|
};
|
||||||
|
|
||||||
const exams: ExamWithUser[] = await generateExams(generateMultiple, selectedModules, assignees);
|
const exams: ExamWithUser[] = await generateExams(generateMultiple, selectedModules, assignees, variant, instructorGender);
|
||||||
|
|
||||||
if (exams.length === 0) {
|
if (exams.length === 0) {
|
||||||
res.status(400).json({ok: false, error: "No exams found for the selected modules"});
|
res.status(400).json({ok: false, error: "No exams found for the selected modules"});
|
||||||
@@ -123,6 +133,7 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
assignees,
|
assignees,
|
||||||
results: [],
|
results: [],
|
||||||
exams,
|
exams,
|
||||||
|
instructorGender,
|
||||||
...body,
|
...body,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -135,13 +146,24 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const assignee = {id: assigneeID, ...assigneeSnapshot.data()} as User;
|
const assignee = {id: assigneeID, ...assigneeSnapshot.data()} as User;
|
||||||
const name = body.name;
|
const name = body.name;
|
||||||
const teacher = req.session.user!;
|
const teacher = req.session.user!;
|
||||||
const examModulesLabel = exams.map((x) => capitalize(x.module)).join(", ");
|
const examModulesLabel = uniqBy(exams, (x) => x.module)
|
||||||
|
.map((x) => capitalize(x.module))
|
||||||
|
.join(", ");
|
||||||
const startDate = moment(body.startDate).format("DD/MM/YYYY - HH:mm");
|
const startDate = moment(body.startDate).format("DD/MM/YYYY - HH:mm");
|
||||||
const endDate = moment(body.endDate).format("DD/MM/YYYY - HH:mm");
|
const endDate = moment(body.endDate).format("DD/MM/YYYY - HH:mm");
|
||||||
|
|
||||||
await sendEmail(
|
await sendEmail(
|
||||||
"assignment",
|
"assignment",
|
||||||
{user: {name: assignee.name}, assignment: {name, startDate, endDate, modules: examModulesLabel, assigner: teacher.name}},
|
{
|
||||||
|
user: {name: assignee.name},
|
||||||
|
assignment: {
|
||||||
|
name,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
modules: examModulesLabel,
|
||||||
|
assigner: teacher.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
[assignee.email],
|
[assignee.email],
|
||||||
"EnCoach - New Assignment!",
|
"EnCoach - New Assignment!",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,41 +1,85 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import {app} from "@/firebase";
|
import { app } from "@/firebase";
|
||||||
import {getFirestore, setDoc, doc, query, collection, where, getDocs} from "firebase/firestore";
|
import {
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
getFirestore,
|
||||||
import {sessionOptions} from "@/lib/session";
|
setDoc,
|
||||||
import {Type} from "@/interfaces/user";
|
doc,
|
||||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
query,
|
||||||
import {uuidv4} from "@firebase/util";
|
collection,
|
||||||
import {prepareMailer, prepareMailOptions} from "@/email";
|
where,
|
||||||
|
getDocs,
|
||||||
|
} from "firebase/firestore";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import { Type } from "@/interfaces/user";
|
||||||
|
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||||
|
import { uuidv4 } from "@firebase/util";
|
||||||
|
import { prepareMailer, prepareMailOptions } from "@/email";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = getFirestore(app);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === "GET") return get(req, res);
|
||||||
|
if (req.method === "POST") return post(req, res);
|
||||||
|
|
||||||
|
return res.status(404).json({ ok: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ok: false, reason: "You must be logged in to generate a code!"});
|
res
|
||||||
|
.status(401)
|
||||||
|
.json({ ok: false, reason: "You must be logged in to generate a code!" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {type, codes, infos, expiryDate} = req.body as {
|
const { creator } = req.query as { creator?: string };
|
||||||
|
const q = query(collection(db, "codes"), where("creator", "==", creator));
|
||||||
|
const snapshot = await getDocs(creator ? q : collection(db, "codes"));
|
||||||
|
|
||||||
|
res.status(200).json(snapshot.docs.map((doc) => doc.data()));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (!req.session.user) {
|
||||||
|
res
|
||||||
|
.status(401)
|
||||||
|
.json({ ok: false, reason: "You must be logged in to generate a code!" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, codes, infos, expiryDate } = req.body as {
|
||||||
type: Type;
|
type: Type;
|
||||||
codes: string[];
|
codes: string[];
|
||||||
infos?: {email: string; name: string; passport_id: string}[];
|
infos?: { email: string; name: string; passport_id?: string }[];
|
||||||
expiryDate: null | Date;
|
expiryDate: null | Date;
|
||||||
};
|
};
|
||||||
const permission = PERMISSIONS.generateCode[type];
|
const permission = PERMISSIONS.generateCode[type];
|
||||||
|
|
||||||
if (!permission.includes(req.session.user.type)) {
|
if (!permission.includes(req.session.user.type)) {
|
||||||
res.status(403).json({ok: false, reason: "Your account type does not have permissions to generate a code for that type of user!"});
|
res
|
||||||
|
.status(403)
|
||||||
|
.json({
|
||||||
|
ok: false,
|
||||||
|
reason:
|
||||||
|
"Your account type does not have permissions to generate a code for that type of user!",
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.session.user.type === "corporate") {
|
if (req.session.user.type === "corporate") {
|
||||||
const codesGeneratedByUserSnapshot = await getDocs(query(collection(db, "codes"), where("creator", "==", req.session.user.id)));
|
const codesGeneratedByUserSnapshot = await getDocs(
|
||||||
|
query(
|
||||||
|
collection(db, "codes"),
|
||||||
|
where("creator", "==", req.session.user.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
const totalCodes = codesGeneratedByUserSnapshot.docs.length + codes.length;
|
const totalCodes = codesGeneratedByUserSnapshot.docs.length + codes.length;
|
||||||
const allowedCodes = req.session.user.corporateInformation?.companyInformation.userAmount || 0;
|
const allowedCodes =
|
||||||
|
req.session.user.corporateInformation?.companyInformation.userAmount || 0;
|
||||||
|
|
||||||
if (totalCodes > allowedCodes) {
|
if (totalCodes > allowedCodes) {
|
||||||
res.status(403).json({
|
res.status(403).json({
|
||||||
@@ -50,11 +94,15 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const codePromises = codes.map(async (code, index) => {
|
const codePromises = codes.map(async (code, index) => {
|
||||||
const codeRef = doc(db, "codes", code);
|
const codeRef = doc(db, "codes", code);
|
||||||
await setDoc(codeRef, {type, code, creator: req.session.user!.id, expiryDate});
|
const codeInformation = {
|
||||||
|
type,
|
||||||
|
code,
|
||||||
|
creator: req.session.user!.id,
|
||||||
|
expiryDate,
|
||||||
|
};
|
||||||
|
|
||||||
if (infos && infos.length > index) {
|
if (infos && infos.length > index) {
|
||||||
const {email, name, passport_id} = infos[index];
|
const { email, name, passport_id } = infos[index];
|
||||||
await setDoc(codeRef, {email: email.trim(), name: name.trim(), passport_id: passport_id.trim()}, {merge: true});
|
|
||||||
|
|
||||||
const transport = prepareMailer();
|
const transport = prepareMailer();
|
||||||
const mailOptions = prepareMailOptions(
|
const mailOptions = prepareMailOptions(
|
||||||
@@ -62,16 +110,34 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
type,
|
type,
|
||||||
code,
|
code,
|
||||||
},
|
},
|
||||||
[email.trim()],
|
[email.toLowerCase().trim()],
|
||||||
"EnCoach Registration",
|
"EnCoach Registration",
|
||||||
"main",
|
"main",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
await transport.sendMail(mailOptions);
|
await transport.sendMail(mailOptions);
|
||||||
|
await setDoc(
|
||||||
|
codeRef,
|
||||||
|
{
|
||||||
|
...codeInformation,
|
||||||
|
email: email.trim().toLowerCase(),
|
||||||
|
name: name.trim(),
|
||||||
|
...(passport_id ? { passport_id: passport_id.trim() } : {}),
|
||||||
|
},
|
||||||
|
{ merge: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await setDoc(codeRef, codeInformation);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Promise.all(codePromises).then(() => {
|
Promise.all(codePromises).then((results) => {
|
||||||
res.status(200).json({ok: true});
|
res.status(200).json({ ok: true, valid: results.filter((x) => x).length });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
if (!req.session.user) return res.status(401).json({ok: false});
|
if (!req.session.user) return res.status(401).json({ok: false});
|
||||||
if (req.session.user.type !== "developer") return res.status(403).json({ok: false});
|
if (req.session.user.type !== "developer") return res.status(403).json({ok: false});
|
||||||
|
|
||||||
const {endpoint, topic, exercises} = req.query as {module: Module; endpoint: string; topic?: string; exercises?: string[]};
|
const {endpoint, topic, exercises} = req.query as {module: Module; endpoint: string[]; topic?: string; exercises?: string[]};
|
||||||
const url = `${process.env.BACKEND_URL}/${endpoint}`;
|
const url = `${process.env.BACKEND_URL}/${endpoint.join("/")}`;
|
||||||
|
|
||||||
const result = await axios.post(
|
const result = await axios.post(
|
||||||
`${url}${topic && exercises ? `?topic=${topic.toLowerCase()}&exercises=${exercises.join("&exercises=")}` : ""}`,
|
`${url}${topic && exercises ? `?topic=${topic.toLowerCase()}&exercises=${exercises.join("&exercises=")}` : ""}`,
|
||||||
@@ -4,8 +4,8 @@ import {app} from "@/firebase";
|
|||||||
import {getFirestore, setDoc, doc} from "firebase/firestore";
|
import {getFirestore, setDoc, doc} from "firebase/firestore";
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {Exam} from "@/interfaces/exam";
|
import {Exam, InstructorGender, Variant} from "@/interfaces/exam";
|
||||||
import { getExams } from "@/utils/exams.be";
|
import {getExams} from "@/utils/exams.be";
|
||||||
const db = getFirestore(app);
|
const db = getFirestore(app);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
@@ -23,12 +23,14 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {module, avoidRepeated, variant, instructorGender} = req.query as {
|
||||||
module,
|
module: string;
|
||||||
avoidRepeated,
|
avoidRepeated: string;
|
||||||
} = req.query as {module: string; avoidRepeated: string};
|
variant?: Variant;
|
||||||
|
instructorGender?: InstructorGender;
|
||||||
|
};
|
||||||
|
|
||||||
const exams: Exam[] = await getExams(db, module, avoidRepeated, req.session.user.id);
|
const exams: Exam[] = await getExams(db, module, avoidRepeated, req.session.user.id, variant, instructorGender);
|
||||||
res.status(200).json(exams);
|
res.status(200).json(exams);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import {app} from "@/firebase";
|
import { app } from "@/firebase";
|
||||||
import {getFirestore, collection, getDocs, getDoc, doc, deleteDoc, setDoc} from "firebase/firestore";
|
import {
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
getFirestore,
|
||||||
import {sessionOptions} from "@/lib/session";
|
collection,
|
||||||
import {Group} from "@/interfaces/user";
|
getDocs,
|
||||||
|
getDoc,
|
||||||
|
doc,
|
||||||
|
deleteDoc,
|
||||||
|
setDoc,
|
||||||
|
} from "firebase/firestore";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import { Group } from "@/interfaces/user";
|
||||||
|
import { updateExpiryDateOnGroup } from "@/utils/groups.be";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = getFirestore(app);
|
||||||
|
|
||||||
@@ -20,16 +29,16 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {id} = req.query as {id: string};
|
const { id } = req.query as { id: string };
|
||||||
|
|
||||||
const snapshot = await getDoc(doc(db, "groups", id));
|
const snapshot = await getDoc(doc(db, "groups", id));
|
||||||
|
|
||||||
if (snapshot.exists()) {
|
if (snapshot.exists()) {
|
||||||
res.status(200).json({...snapshot.data(), id: snapshot.id});
|
res.status(200).json({ ...snapshot.data(), id: snapshot.id });
|
||||||
} else {
|
} else {
|
||||||
res.status(404).json(undefined);
|
res.status(404).json(undefined);
|
||||||
}
|
}
|
||||||
@@ -37,44 +46,63 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
async function del(req: NextApiRequest, res: NextApiResponse) {
|
async function del(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {id} = req.query as {id: string};
|
const { id } = req.query as { id: string };
|
||||||
|
|
||||||
const snapshot = await getDoc(doc(db, "groups", id));
|
const snapshot = await getDoc(doc(db, "groups", id));
|
||||||
const group = {...snapshot.data(), id: snapshot.id} as Group;
|
const group = { ...snapshot.data(), id: snapshot.id } as Group;
|
||||||
|
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
if (user.type === "admin" || user.type === "developer" || user.id === group.admin) {
|
if (
|
||||||
|
user.type === "admin" ||
|
||||||
|
user.type === "developer" ||
|
||||||
|
user.id === group.admin
|
||||||
|
) {
|
||||||
await deleteDoc(snapshot.ref);
|
await deleteDoc(snapshot.ref);
|
||||||
|
|
||||||
res.status(200).json({ok: true});
|
res.status(200).json({ ok: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(403).json({ok: false});
|
res.status(403).json({ ok: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function patch(req: NextApiRequest, res: NextApiResponse) {
|
async function patch(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {id} = req.query as {id: string};
|
const { id } = req.query as { id: string };
|
||||||
|
|
||||||
const snapshot = await getDoc(doc(db, "groups", id));
|
const snapshot = await getDoc(doc(db, "groups", id));
|
||||||
const group = {...snapshot.data(), id: snapshot.id} as Group;
|
const group = { ...snapshot.data(), id: snapshot.id } as Group;
|
||||||
|
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
if (user.type === "admin" || user.type === "developer" || user.id === group.admin) {
|
if (
|
||||||
await setDoc(snapshot.ref, req.body, {merge: true});
|
user.type === "admin" ||
|
||||||
|
user.type === "developer" ||
|
||||||
|
user.id === group.admin
|
||||||
|
) {
|
||||||
|
if ("participants" in req.body) {
|
||||||
|
const newParticipants = (req.body.participants as string[]).filter(
|
||||||
|
(x) => !group.participants.includes(x),
|
||||||
|
);
|
||||||
|
await Promise.all(
|
||||||
|
newParticipants.map(
|
||||||
|
async (p) => await updateExpiryDateOnGroup(p, group.admin),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
res.status(200).json({ok: true});
|
await setDoc(snapshot.ref, req.body, { merge: true });
|
||||||
|
|
||||||
|
res.status(200).json({ ok: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(403).json({ok: false});
|
res.status(403).json({ ok: false });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import {app} from "@/firebase";
|
import { app } from "@/firebase";
|
||||||
import {getFirestore, collection, getDocs, setDoc, doc, query, where} from "firebase/firestore";
|
import {
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
getFirestore,
|
||||||
import {sessionOptions} from "@/lib/session";
|
collection,
|
||||||
import {Group} from "@/interfaces/user";
|
getDocs,
|
||||||
import {v4} from "uuid";
|
setDoc,
|
||||||
|
doc,
|
||||||
|
query,
|
||||||
|
where,
|
||||||
|
} from "firebase/firestore";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import { Group } from "@/interfaces/user";
|
||||||
|
import { v4 } from "uuid";
|
||||||
|
import { updateExpiryDateOnGroup } from "@/utils/groups.be";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = getFirestore(app);
|
||||||
|
|
||||||
@@ -13,7 +22,7 @@ export default withIronSessionApiRoute(handler, sessionOptions);
|
|||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,13 +31,22 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const {admin, participant} = req.query as {admin: string; participant: string};
|
const { admin, participant } = req.query as {
|
||||||
|
admin: string;
|
||||||
|
participant: string;
|
||||||
|
};
|
||||||
|
|
||||||
const queryConstraints = [
|
const queryConstraints = [
|
||||||
...(admin ? [where("admin", "==", admin)] : []),
|
...(admin ? [where("admin", "==", admin)] : []),
|
||||||
...(participant ? [where("participants", "array-contains", participant)] : []),
|
...(participant
|
||||||
|
? [where("participants", "array-contains", participant)]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
const snapshot = await getDocs(queryConstraints.length > 0 ? query(collection(db, "groups"), ...queryConstraints) : collection(db, "groups"));
|
const snapshot = await getDocs(
|
||||||
|
queryConstraints.length > 0
|
||||||
|
? query(collection(db, "groups"), ...queryConstraints)
|
||||||
|
: collection(db, "groups"),
|
||||||
|
);
|
||||||
const groups = snapshot.docs.map((doc) => ({
|
const groups = snapshot.docs.map((doc) => ({
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
...doc.data(),
|
...doc.data(),
|
||||||
@@ -40,6 +58,16 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const body = req.body as Group;
|
const body = req.body as Group;
|
||||||
|
|
||||||
await setDoc(doc(db, "groups", v4()), {name: body.name, admin: body.admin, participants: body.participants});
|
await Promise.all(
|
||||||
res.status(200).json({ok: true});
|
body.participants.map(
|
||||||
|
async (p) => await updateExpiryDateOnGroup(p, body.admin),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await setDoc(doc(db, "groups", v4()), {
|
||||||
|
name: body.name,
|
||||||
|
admin: body.admin,
|
||||||
|
participants: body.participants,
|
||||||
|
});
|
||||||
|
res.status(200).json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|||||||
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 { NextApiRequest, NextApiResponse } from "next";
|
||||||
import {getAuth, signInWithEmailAndPassword} from "firebase/auth";
|
import { getAuth, signInWithEmailAndPassword } from "firebase/auth";
|
||||||
import {app} from "@/firebase";
|
import { app } from "@/firebase";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import {User} from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import {getFirestore, getDoc, doc} from "firebase/firestore";
|
import { getFirestore, getDoc, doc } from "firebase/firestore";
|
||||||
|
|
||||||
const auth = getAuth(app);
|
const auth = getAuth(app);
|
||||||
const db = getFirestore(app);
|
const db = getFirestore(app);
|
||||||
@@ -12,27 +12,27 @@ const db = getFirestore(app);
|
|||||||
export default withIronSessionApiRoute(login, sessionOptions);
|
export default withIronSessionApiRoute(login, sessionOptions);
|
||||||
|
|
||||||
async function login(req: NextApiRequest, res: NextApiResponse) {
|
async function login(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const {email, password} = req.body as {email: string; password: string};
|
const { email, password } = req.body as { email: string; password: string };
|
||||||
|
|
||||||
signInWithEmailAndPassword(auth, email, password)
|
signInWithEmailAndPassword(auth, email.toLowerCase(), password)
|
||||||
.then(async (userCredentials) => {
|
.then(async (userCredentials) => {
|
||||||
const userId = userCredentials.user.uid;
|
const userId = userCredentials.user.uid;
|
||||||
|
|
||||||
const docUser = await getDoc(doc(db, "users", userId));
|
const docUser = await getDoc(doc(db, "users", userId));
|
||||||
if (!docUser.exists()) {
|
if (!docUser.exists()) {
|
||||||
res.status(401).json({error: 401, message: "User does not exist!"});
|
res.status(401).json({ error: 401, message: "User does not exist!" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = docUser.data() as User;
|
const user = docUser.data() as User;
|
||||||
|
|
||||||
req.session.user = {...user, id: userId};
|
req.session.user = { ...user, id: userId };
|
||||||
await req.session.save();
|
await req.session.save();
|
||||||
|
|
||||||
res.status(200).json({user: {...user, id: userId}});
|
res.status(200).json({ user: { ...user, id: userId } });
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
res.status(401).json({error});
|
res.status(401).json({ error });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
if (user.type === "admin" || user.type === "developer") {
|
if (user.type === "admin" || user.type === "developer") {
|
||||||
await setDoc(snapshot.ref, req.body, {merge: true});
|
await setDoc(snapshot.ref, req.body, {merge: true});
|
||||||
|
|
||||||
|
if (req.body.isPaid) {
|
||||||
|
const corporateID = req.body.corporate;
|
||||||
|
await setDoc(doc(db, "users", corporateID), {status: "active"}, {merge: true});
|
||||||
|
}
|
||||||
return res.status(200).json({ok: true});
|
return res.status(200).json({ok: true});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const updatedDoc = (await getDoc(doc(db, "payments", paymentId))).data() as Payment;
|
const updatedDoc = (await getDoc(doc(db, "payments", paymentId))).data() as Payment;
|
||||||
if (updatedDoc.commissionTransfer && updatedDoc.corporateTransfer) {
|
if (updatedDoc.commissionTransfer && updatedDoc.corporateTransfer) {
|
||||||
await setDoc(doc(db, "payments", paymentId), {isPaid: true}, {merge: true});
|
await setDoc(doc(db, "payments", paymentId), {isPaid: true}, {merge: true});
|
||||||
|
|
||||||
|
await setDoc(doc(db, "users", updatedDoc.corporate), {status: "active"}, {merge: true});
|
||||||
}
|
}
|
||||||
res.status(200).json({ref});
|
res.status(200).json({ref});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import {NextApiRequest, NextApiResponse} from "next";
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
import {createUserWithEmailAndPassword, getAuth} from "firebase/auth";
|
import { createUserWithEmailAndPassword, getAuth } from "firebase/auth";
|
||||||
import {app} from "@/firebase";
|
import { app } from "@/firebase";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import {getFirestore, doc, setDoc, query, collection, where, getDocs} from "firebase/firestore";
|
import {
|
||||||
import {CorporateInformation, DemographicInformation, Type} from "@/interfaces/user";
|
getFirestore,
|
||||||
import {addUserToGroupOnCreation} from "@/utils/registration";
|
doc,
|
||||||
|
setDoc,
|
||||||
|
query,
|
||||||
|
collection,
|
||||||
|
where,
|
||||||
|
getDocs,
|
||||||
|
} from "firebase/firestore";
|
||||||
|
import {
|
||||||
|
CorporateInformation,
|
||||||
|
DemographicInformation,
|
||||||
|
Type,
|
||||||
|
} from "@/interfaces/user";
|
||||||
|
import { addUserToGroupOnCreation } from "@/utils/registration";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
|
|
||||||
const auth = getAuth(app);
|
const auth = getAuth(app);
|
||||||
@@ -28,7 +40,7 @@ const DEFAULT_LEVELS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function register(req: NextApiRequest, res: NextApiResponse) {
|
async function register(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const {type} = req.body as {
|
const { type } = req.body as {
|
||||||
type: "individual" | "corporate";
|
type: "individual" | "corporate";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,7 +49,7 @@ async function register(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function registerIndividual(req: NextApiRequest, res: NextApiResponse) {
|
async function registerIndividual(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const {email, passport_id, password, code} = req.body as {
|
const { email, passport_id, password, code } = req.body as {
|
||||||
email: string;
|
email: string;
|
||||||
passport_id?: string;
|
passport_id?: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -45,66 +57,89 @@ async function registerIndividual(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const codeQuery = query(collection(db, "codes"), where("code", "==", code));
|
const codeQuery = query(collection(db, "codes"), where("code", "==", code));
|
||||||
const codeDocs = (await getDocs(codeQuery)).docs.filter((x) => !Object.keys(x.data()).includes("userId"));
|
const codeDocs = (await getDocs(codeQuery)).docs.filter(
|
||||||
|
(x) => !Object.keys(x.data()).includes("userId"),
|
||||||
|
);
|
||||||
|
|
||||||
if (code && code.length > 0 && codeDocs.length === 0) {
|
if (code && code.length > 0 && codeDocs.length === 0) {
|
||||||
res.status(400).json({error: "Invalid Code!"});
|
res.status(400).json({ error: "Invalid Code!" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const codeData = codeDocs.length > 0 ? (codeDocs[0].data() as {code: string; type: Type; creator?: string; expiryDate: Date | null}) : undefined;
|
const codeData =
|
||||||
|
codeDocs.length > 0
|
||||||
|
? (codeDocs[0].data() as {
|
||||||
|
code: string;
|
||||||
|
type: Type;
|
||||||
|
creator?: string;
|
||||||
|
expiryDate: Date | null;
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
createUserWithEmailAndPassword(auth, email, password)
|
createUserWithEmailAndPassword(auth, email.toLowerCase(), password)
|
||||||
.then(async (userCredentials) => {
|
.then(async (userCredentials) => {
|
||||||
const userId = userCredentials.user.uid;
|
const userId = userCredentials.user.uid;
|
||||||
delete req.body.password;
|
delete req.body.password;
|
||||||
|
|
||||||
const user = {
|
const user = {
|
||||||
...req.body,
|
...req.body,
|
||||||
|
email: email.toLowerCase(),
|
||||||
desiredLevels: DEFAULT_DESIRED_LEVELS,
|
desiredLevels: DEFAULT_DESIRED_LEVELS,
|
||||||
levels: DEFAULT_LEVELS,
|
levels: DEFAULT_LEVELS,
|
||||||
bio: "",
|
bio: "",
|
||||||
isFirstLogin: codeData ? codeData.type === "student" : true,
|
isFirstLogin: codeData ? codeData.type === "student" : true,
|
||||||
focus: "academic",
|
focus: "academic",
|
||||||
type: email.endsWith("@ecrop.dev") ? "developer" : codeData ? codeData.type : "student",
|
type: email.endsWith("@ecrop.dev")
|
||||||
subscriptionExpirationDate: codeData ? codeData.expiryDate : moment().subtract(1, "days").toISOString(),
|
? "developer"
|
||||||
...(passport_id ? {demographicInformation: {passport_id}} : {}),
|
: codeData
|
||||||
registrationDate: new Date(),
|
? codeData.type
|
||||||
|
: "student",
|
||||||
|
subscriptionExpirationDate: codeData
|
||||||
|
? codeData.expiryDate
|
||||||
|
: moment().subtract(1, "days").toISOString(),
|
||||||
|
...(passport_id ? { demographicInformation: { passport_id } } : {}),
|
||||||
|
registrationDate: new Date().toISOString(),
|
||||||
status: code ? "active" : "paymentDue",
|
status: code ? "active" : "paymentDue",
|
||||||
};
|
};
|
||||||
|
|
||||||
await setDoc(doc(db, "users", userId), user);
|
await setDoc(doc(db, "users", userId), user);
|
||||||
|
|
||||||
if (codeDocs.length > 0 && codeData) {
|
if (codeDocs.length > 0 && codeData) {
|
||||||
await setDoc(codeDocs[0].ref, {userId: userId}, {merge: true});
|
await setDoc(codeDocs[0].ref, { userId: userId }, { merge: true });
|
||||||
if (codeData.creator) await addUserToGroupOnCreation(userId, codeData.type, codeData.creator);
|
if (codeData.creator)
|
||||||
|
await addUserToGroupOnCreation(
|
||||||
|
userId,
|
||||||
|
codeData.type,
|
||||||
|
codeData.creator,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
req.session.user = {...user, id: userId};
|
req.session.user = { ...user, id: userId };
|
||||||
await req.session.save();
|
await req.session.save();
|
||||||
|
|
||||||
res.status(200).json({user: {...user, id: userId}});
|
res.status(200).json({ user: { ...user, id: userId } });
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
res.status(401).json({error});
|
res.status(401).json({ error });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function registerCorporate(req: NextApiRequest, res: NextApiResponse) {
|
async function registerCorporate(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const {email, password} = req.body as {
|
const { email, password } = req.body as {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
corporateInformation: CorporateInformation;
|
corporateInformation: CorporateInformation;
|
||||||
};
|
};
|
||||||
|
|
||||||
createUserWithEmailAndPassword(auth, email, password)
|
createUserWithEmailAndPassword(auth, email.toLowerCase(), password)
|
||||||
.then(async (userCredentials) => {
|
.then(async (userCredentials) => {
|
||||||
const userId = userCredentials.user.uid;
|
const userId = userCredentials.user.uid;
|
||||||
delete req.body.password;
|
delete req.body.password;
|
||||||
|
|
||||||
const user = {
|
const user = {
|
||||||
...req.body,
|
...req.body,
|
||||||
|
email: email.toLowerCase(),
|
||||||
desiredLevels: DEFAULT_DESIRED_LEVELS,
|
desiredLevels: DEFAULT_DESIRED_LEVELS,
|
||||||
levels: DEFAULT_LEVELS,
|
levels: DEFAULT_LEVELS,
|
||||||
bio: "",
|
bio: "",
|
||||||
@@ -118,13 +153,13 @@ async function registerCorporate(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
await setDoc(doc(db, "users", userId), user);
|
await setDoc(doc(db, "users", userId), user);
|
||||||
|
|
||||||
req.session.user = {...user, id: userId};
|
req.session.user = { ...user, id: userId };
|
||||||
await req.session.save();
|
await req.session.save();
|
||||||
|
|
||||||
res.status(200).json({user: {...user, id: userId}});
|
res.status(200).json({ user: { ...user, id: userId } });
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
res.status(401).json({error});
|
res.status(401).json({ error });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
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 type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import { app, storage } from "@/firebase";
|
import {app, storage} from "@/firebase";
|
||||||
import {
|
import {getFirestore, doc, getDoc, updateDoc, getDocs, query, collection, where} from "firebase/firestore";
|
||||||
getFirestore,
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
doc,
|
import {sessionOptions} from "@/lib/session";
|
||||||
getDoc,
|
|
||||||
updateDoc,
|
|
||||||
getDocs,
|
|
||||||
query,
|
|
||||||
collection,
|
|
||||||
where,
|
|
||||||
} from "firebase/firestore";
|
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
|
||||||
import { sessionOptions } from "@/lib/session";
|
|
||||||
import ReactPDF from "@react-pdf/renderer";
|
import ReactPDF from "@react-pdf/renderer";
|
||||||
import TestReport from "@/exams/pdf/test.report";
|
import TestReport from "@/exams/pdf/test.report";
|
||||||
import { ref, uploadBytes, getDownloadURL } from "firebase/storage";
|
import {ref, uploadBytes, getDownloadURL} from "firebase/storage";
|
||||||
import { DemographicInformation, User } from "@/interfaces/user";
|
import {DemographicInformation, User} from "@/interfaces/user";
|
||||||
import { Module } from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import { ModuleScore } from "@/interfaces/module.scores";
|
import {ModuleScore} from "@/interfaces/module.scores";
|
||||||
import { SkillExamDetails } from "@/exams/pdf/details/skill.exam";
|
import {SkillExamDetails} from "@/exams/pdf/details/skill.exam";
|
||||||
import { LevelExamDetails } from "@/exams/pdf/details/level.exam";
|
import {LevelExamDetails} from "@/exams/pdf/details/level.exam";
|
||||||
import { calculateBandScore } from "@/utils/score";
|
import {calculateBandScore} from "@/utils/score";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { moduleLabels } from "@/utils/moduleUtils";
|
import {moduleLabels} from "@/utils/moduleUtils";
|
||||||
import {
|
import {generateQRCode, getRadialProgressPNG, streamToBuffer} from "@/utils/pdf";
|
||||||
generateQRCode,
|
|
||||||
getRadialProgressPNG,
|
|
||||||
streamToBuffer,
|
|
||||||
} from "@/utils/pdf";
|
|
||||||
import moment from "moment-timezone";
|
import moment from "moment-timezone";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = getFirestore(app);
|
||||||
@@ -97,21 +84,19 @@ interface SkillsFeedbackResponse extends SkillsFeedbackRequest {
|
|||||||
const getSkillsFeedback = async (sections: SkillsFeedbackRequest[]) => {
|
const getSkillsFeedback = async (sections: SkillsFeedbackRequest[]) => {
|
||||||
const backendRequest = await axios.post(
|
const backendRequest = await axios.post(
|
||||||
`${process.env.BACKEND_URL}/grading_summary`,
|
`${process.env.BACKEND_URL}/grading_summary`,
|
||||||
{ sections },
|
{sections},
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return backendRequest.data?.sections;
|
return backendRequest.data?.sections;
|
||||||
};
|
};
|
||||||
|
|
||||||
// perform the request with several retries if needed
|
// perform the request with several retries if needed
|
||||||
const handleSkillsFeedbackRequest = async (
|
const handleSkillsFeedbackRequest = async (sections: SkillsFeedbackRequest[]): Promise<SkillsFeedbackResponse[] | null> => {
|
||||||
sections: SkillsFeedbackRequest[]
|
|
||||||
): Promise<SkillsFeedbackResponse[] | null> => {
|
|
||||||
let i = 0;
|
let i = 0;
|
||||||
try {
|
try {
|
||||||
const data = await getSkillsFeedback(sections);
|
const data = await getSkillsFeedback(sections);
|
||||||
@@ -129,15 +114,9 @@ const handleSkillsFeedbackRequest = async (
|
|||||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
// verify if it's a logged user that is trying to export
|
// verify if it's a logged user that is trying to export
|
||||||
if (req.session.user) {
|
if (req.session.user) {
|
||||||
const { id } = req.query as { id: string };
|
const {id} = req.query as {id: string};
|
||||||
// fetch stats entries for this particular user with the requested exam session
|
// fetch stats entries for this particular user with the requested exam session
|
||||||
const docsSnap = await getDocs(
|
const docsSnap = await getDocs(query(collection(db, "stats"), where("session", "==", id)));
|
||||||
query(
|
|
||||||
collection(db, "stats"),
|
|
||||||
where("session", "==", id),
|
|
||||||
where("user", "==", req.session.user.id)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (docsSnap.empty) {
|
if (docsSnap.empty) {
|
||||||
res.status(400).end();
|
res.status(400).end();
|
||||||
@@ -146,11 +125,20 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const stats = docsSnap.docs.map((d) => d.data());
|
const stats = docsSnap.docs.map((d) => d.data());
|
||||||
// verify if the stats already have a pdf generated
|
// verify if the stats already have a pdf generated
|
||||||
const hasPDF = stats.find((s) => s.pdf);
|
const hasPDF = stats.find((s) => s.pdf?.path && s.pdf?.version === process.env.PDF_VERSION);
|
||||||
|
// find the user that generated the stats
|
||||||
|
const statIndex = stats.findIndex((s) => s.user);
|
||||||
|
|
||||||
|
if(statIndex === -1) {
|
||||||
|
res.status(401).json({ok: false});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userId = stats[statIndex].user;
|
||||||
|
|
||||||
|
|
||||||
if (hasPDF) {
|
if (hasPDF) {
|
||||||
// if it does, return the pdf url
|
// if it does, return the pdf url
|
||||||
const fileRef = ref(storage, hasPDF.pdf);
|
const fileRef = ref(storage, hasPDF.pdf.path);
|
||||||
const url = await getDownloadURL(fileRef);
|
const url = await getDownloadURL(fileRef);
|
||||||
|
|
||||||
res.status(200).end(url);
|
res.status(200).end(url);
|
||||||
@@ -159,27 +147,24 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// generate the pdf report
|
// generate the pdf report
|
||||||
const docUser = await getDoc(doc(db, "users", req.session.user.id));
|
const docUser = await getDoc(doc(db, "users", userId));
|
||||||
|
|
||||||
if (docUser.exists()) {
|
if (docUser.exists()) {
|
||||||
// we'll need the user in order to get the user data (name, email, focus, etc);
|
// we'll need the user in order to get the user data (name, email, focus, etc);
|
||||||
const user = docUser.data() as User;
|
const user = docUser.data() as User;
|
||||||
|
|
||||||
// generate the QR code for the report
|
// generate the QR code for the report
|
||||||
const qrcode = await generateQRCode(
|
const qrcode = await generateQRCode((req.headers.origin || "") + req.url);
|
||||||
(req.headers.origin || "") + req.url
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!qrcode) {
|
if (!qrcode) {
|
||||||
res.status(500).json({ ok: false });
|
res.status(500).json({ok: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// stats may contain multiple exams of the same type so we need to aggregate them
|
// stats may contain multiple exams of the same type so we need to aggregate them
|
||||||
const results = (
|
const results = (
|
||||||
stats.reduce((accm: ModuleScore[], { module, score }) => {
|
stats.reduce((accm: ModuleScore[], {module, score}) => {
|
||||||
const fixedModuleStr =
|
const fixedModuleStr = module[0].toUpperCase() + module.substring(1);
|
||||||
module[0].toUpperCase() + module.substring(1);
|
|
||||||
if (accm.find((e: ModuleScore) => e.module === fixedModuleStr)) {
|
if (accm.find((e: ModuleScore) => e.module === fixedModuleStr)) {
|
||||||
return accm.map((e: ModuleScore) => {
|
return accm.map((e: ModuleScore) => {
|
||||||
if (e.module === fixedModuleStr) {
|
if (e.module === fixedModuleStr) {
|
||||||
@@ -205,14 +190,9 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
];
|
];
|
||||||
}, []) as ModuleScore[]
|
}, []) as ModuleScore[]
|
||||||
).map((moduleScore) => {
|
).map((moduleScore) => {
|
||||||
const { score, total } = moduleScore;
|
const {score, total} = moduleScore;
|
||||||
// with all the scores aggreated we can calculate the band score for each module
|
// with all the scores aggreated we can calculate the band score for each module
|
||||||
const bandScore = calculateBandScore(
|
const bandScore = calculateBandScore(score, total, moduleScore.code as Module, user.focus);
|
||||||
score,
|
|
||||||
total,
|
|
||||||
moduleScore.code as Module,
|
|
||||||
user.focus
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...moduleScore,
|
...moduleScore,
|
||||||
@@ -224,23 +204,21 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
// get the skills feedback from the backend based on the module grade
|
// get the skills feedback from the backend based on the module grade
|
||||||
const skillsFeedback = (await handleSkillsFeedbackRequest(
|
const skillsFeedback = (await handleSkillsFeedbackRequest(
|
||||||
results.map(({ code, bandScore }) => ({
|
results.map(({code, bandScore}) => ({
|
||||||
code,
|
code,
|
||||||
name: moduleLabels[code],
|
name: moduleLabels[code],
|
||||||
grade: bandScore,
|
grade: bandScore,
|
||||||
}))
|
})),
|
||||||
)) as SkillsFeedbackResponse[];
|
)) as SkillsFeedbackResponse[];
|
||||||
|
|
||||||
if (!skillsFeedback) {
|
if (!skillsFeedback) {
|
||||||
res.status(500).json({ ok: false });
|
res.status(500).json({ok: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// assign the feedback to the results
|
// assign the feedback to the results
|
||||||
const finalResults = results.map((result) => {
|
const finalResults = results.map((result) => {
|
||||||
const feedback = skillsFeedback.find(
|
const feedback = skillsFeedback.find((f: SkillsFeedbackResponse) => f.code === result.code);
|
||||||
(f: SkillsFeedbackResponse) => f.code === result.code
|
|
||||||
);
|
|
||||||
|
|
||||||
if (feedback) {
|
if (feedback) {
|
||||||
return {
|
return {
|
||||||
@@ -254,14 +232,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// calculate the overall score out of all the aggregated results
|
// calculate the overall score out of all the aggregated results
|
||||||
const overallScore = results.reduce(
|
const overallScore = results.reduce((accm, {score}) => accm + score, 0);
|
||||||
(accm, { score }) => accm + score,
|
const overallTotal = results.reduce((accm, {total}) => accm + total, 0);
|
||||||
0
|
|
||||||
);
|
|
||||||
const overallTotal = results.reduce(
|
|
||||||
(accm, { total }) => accm + total,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
const overallResult = overallScore / overallTotal;
|
const overallResult = overallScore / overallTotal;
|
||||||
|
|
||||||
const overallPNG = getRadialProgressPNG("laranja", overallScore, overallTotal);
|
const overallPNG = getRadialProgressPNG("laranja", overallScore, overallTotal);
|
||||||
@@ -278,22 +250,14 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const [stat] = stats;
|
const [stat] = stats;
|
||||||
|
|
||||||
// generate the performance summary based on the overall result
|
// generate the performance summary based on the overall result
|
||||||
const performanceSummary = getPerformanceSummary(
|
const performanceSummary = getPerformanceSummary(stat.module, overallResult);
|
||||||
stat.module,
|
|
||||||
overallResult
|
|
||||||
);
|
|
||||||
|
|
||||||
// level exams have a different report structure than the skill exams
|
// level exams have a different report structure than the skill exams
|
||||||
const getCustomData = () => {
|
const getCustomData = () => {
|
||||||
if (stat.module === "level") {
|
if (stat.module === "level") {
|
||||||
return {
|
return {
|
||||||
title: "ENGLISH LEVEL TEST RESULT REPORT ",
|
title: "ENGLISH LEVEL TEST RESULT REPORT ",
|
||||||
details: (
|
details: <LevelExamDetails detail={overallDetail} title="Level as per CEFR Levels" />,
|
||||||
<LevelExamDetails
|
|
||||||
detail={overallDetail}
|
|
||||||
title="Level as per CEFR Levels"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,16 +267,18 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const { title, details } = getCustomData();
|
const {title, details} = getCustomData();
|
||||||
|
|
||||||
const demographicInformation = user.demographicInformation as DemographicInformation;
|
const demographicInformation = user.demographicInformation as DemographicInformation;
|
||||||
const pdfStream = await ReactPDF.renderToStream(
|
const pdfStream = await ReactPDF.renderToStream(
|
||||||
<TestReport
|
<TestReport
|
||||||
title={title}
|
title={title}
|
||||||
date={moment(stat.date).tz(user.demographicInformation?.timezone || 'UTC').format('ll HH:mm:ss')}
|
date={moment(stat.date)
|
||||||
|
.tz(user.demographicInformation?.timezone || "UTC")
|
||||||
|
.format("ll HH:mm:ss")}
|
||||||
name={user.name}
|
name={user.name}
|
||||||
email={user.email}
|
email={user.email}
|
||||||
id={user.id}
|
id={userId}
|
||||||
gender={demographicInformation?.gender}
|
gender={demographicInformation?.gender}
|
||||||
summary={performanceSummary}
|
summary={performanceSummary}
|
||||||
testDetails={testDetails}
|
testDetails={testDetails}
|
||||||
@@ -322,7 +288,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
summaryPNG={overallPNG}
|
summaryPNG={overallPNG}
|
||||||
summaryScore={`${(overallResult * 100).toFixed(0)}%`}
|
summaryScore={`${(overallResult * 100).toFixed(0)}%`}
|
||||||
passportId={demographicInformation?.passport_id || ""}
|
passportId={demographicInformation?.passport_id || ""}
|
||||||
/>
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// generate the file ref for storage
|
// generate the file ref for storage
|
||||||
@@ -339,7 +305,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
// update the stats entries with the pdf url to prevent duplication
|
// update the stats entries with the pdf url to prevent duplication
|
||||||
docsSnap.docs.forEach(async (doc) => {
|
docsSnap.docs.forEach(async (doc) => {
|
||||||
await updateDoc(doc.ref, {
|
await updateDoc(doc.ref, {
|
||||||
pdf: refName,
|
pdf: {
|
||||||
|
path: refName,
|
||||||
|
version: process.env.PDF_VERSION,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const url = await getDownloadURL(fileRef);
|
const url = await getDownloadURL(fileRef);
|
||||||
@@ -347,23 +316,21 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(401).json({ ok: false });
|
res.status(401).json({ok: false});
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ ok: false });
|
res.status(500).json({ok: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(401).json({ ok: false });
|
res.status(401).json({ok: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const { id } = req.query as { id: string };
|
const {id} = req.query as {id: string};
|
||||||
const docsSnap = await getDocs(
|
const docsSnap = await getDocs(query(collection(db, "stats"), where("session", "==", id)));
|
||||||
query(collection(db, "stats"), where("session", "==", id))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (docsSnap.empty) {
|
if (docsSnap.empty) {
|
||||||
res.status(404).end();
|
res.status(404).end();
|
||||||
@@ -372,10 +339,10 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const stats = docsSnap.docs.map((d) => d.data());
|
const stats = docsSnap.docs.map((d) => d.data());
|
||||||
|
|
||||||
const hasPDF = stats.find((s) => s.pdf);
|
const hasPDF = stats.find((s) => s.pdf?.path);
|
||||||
|
|
||||||
if (hasPDF) {
|
if (hasPDF) {
|
||||||
const fileRef = ref(storage, hasPDF.pdf);
|
const fileRef = ref(storage, hasPDF.pdf.path);
|
||||||
const url = await getDownloadURL(fileRef);
|
const url = await getDownloadURL(fileRef);
|
||||||
return res.redirect(url);
|
return res.redirect(url);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
import {app} from "@/firebase";
|
||||||
import {getFirestore, collection, getDocs, query, where, doc, setDoc, addDoc, getDoc} from "firebase/firestore";
|
import {getFirestore, collection, getDocs, query, where, doc, setDoc, addDoc, getDoc, deleteDoc} from "firebase/firestore";
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {Stat} from "@/interfaces/user";
|
import {Stat} from "@/interfaces/user";
|
||||||
@@ -43,6 +43,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const stats = req.body as Stat[];
|
const stats = req.body as Stat[];
|
||||||
await stats.forEach(async (stat) => await setDoc(doc(db, "stats", stat.id), stat));
|
await stats.forEach(async (stat) => await setDoc(doc(db, "stats", stat.id), stat));
|
||||||
|
await stats.forEach(async (stat) => {
|
||||||
|
const sessionDoc = await getDoc(doc(db, "sessions", stat.session));
|
||||||
|
if (sessionDoc.exists()) await deleteDoc(sessionDoc.ref);
|
||||||
|
});
|
||||||
|
|
||||||
const groupedStatsByAssignment = groupBy(
|
const groupedStatsByAssignment = groupBy(
|
||||||
stats.filter((x) => !!x.assignment),
|
stats.filter((x) => !!x.assignment),
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user