Merged in develop (pull request #35)
Updated the main branch - 13/02/24
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
|
const websiteUrl = process.env.NODE_ENV === 'production' ? "https://encoach.com" : "http://localhost:3000";
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
@@ -8,7 +9,7 @@ const nextConfig = {
|
|||||||
source: "/api/packages",
|
source: "/api/packages",
|
||||||
headers: [
|
headers: [
|
||||||
{key: "Access-Control-Allow-Credentials", value: "false"},
|
{key: "Access-Control-Allow-Credentials", value: "false"},
|
||||||
{key: "Access-Control-Allow-Origin", value: "https://encoach.com"},
|
{key: "Access-Control-Allow-Origin", value: websiteUrl},
|
||||||
{
|
{
|
||||||
key: "Access-Control-Allow-Methods",
|
key: "Access-Control-Allow-Methods",
|
||||||
value: "GET",
|
value: "GET",
|
||||||
@@ -19,6 +20,21 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source: "/api/tickets",
|
||||||
|
headers: [
|
||||||
|
{key: "Access-Control-Allow-Credentials", value: "false"},
|
||||||
|
{key: "Access-Control-Allow-Origin", value: websiteUrl},
|
||||||
|
{
|
||||||
|
key: "Access-Control-Allow-Methods",
|
||||||
|
value: "POST,OPTIONS",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Access-Control-Allow-Headers",
|
||||||
|
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export default function TicketDisplay({ user, ticket, onClose }: Props) {
|
|||||||
options={[
|
options={[
|
||||||
{ value: "me", label: "Assign to me" },
|
{ value: "me", label: "Assign to me" },
|
||||||
...users
|
...users
|
||||||
.filter((x) => ["admin", "developer"].includes(x.type))
|
.filter((x) => ["admin", "developer", "agent"].includes(x.type))
|
||||||
.map((u) => ({
|
.map((u) => ({
|
||||||
value: u.id,
|
value: u.id,
|
||||||
label: `${u.name} - ${u.email}`,
|
label: `${u.name} - ${u.email}`,
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ export default function TicketSubmission({ user, page, onClose }: Props) {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
if (!type)
|
if (!type) return toast.error("Please choose a type!", {toastId: "missing-type"});
|
||||||
return toast.error("Please choose a type!", { toastId: "missing-type" });
|
|
||||||
if (subject.trim() === "")
|
if (subject.trim() === "")
|
||||||
return toast.error("Please input a subject!", {
|
return toast.error("Please input a subject!", {
|
||||||
toastId: "missing-subject",
|
toastId: "missing-subject",
|
||||||
@@ -54,10 +53,7 @@ export default function TicketSubmission({ user, page, onClose }: Props) {
|
|||||||
axios
|
axios
|
||||||
.post(`/api/tickets`, ticket)
|
.post(`/api/tickets`, ticket)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(
|
toast.success(`Your ticket has been submitted! You will be contacted by e-mail for further discussion.`, {toastId: "submitted"});
|
||||||
`Your ticket has been submitted! You will be contacted by e-mail for further discussion.`,
|
|
||||||
{ toastId: "submitted" },
|
|
||||||
);
|
|
||||||
onClose();
|
onClose();
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
@@ -71,37 +67,20 @@ export default function TicketSubmission({ user, page, onClose }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="flex flex-col gap-4 pt-8">
|
<form className="flex flex-col gap-4 pt-8">
|
||||||
<Input
|
<Input label="Subject" type="text" name="subject" placeholder="Subject..." onChange={(e) => setSubject(e)} />
|
||||||
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="-md:flex-col flex w-full items-center gap-4">
|
||||||
<div className="flex w-full flex-col gap-3">
|
<div className="flex w-full flex-col gap-3">
|
||||||
<label className="text-mti-gray-dim text-base font-normal">
|
<label className="text-mti-gray-dim text-base font-normal">Type</label>
|
||||||
Type
|
|
||||||
</label>
|
|
||||||
<Select
|
<Select
|
||||||
options={Object.keys(TicketTypeLabel).map((x) => ({
|
options={Object.keys(TicketTypeLabel).map((x) => ({
|
||||||
value: x,
|
value: x,
|
||||||
label: TicketTypeLabel[x as keyof typeof TicketTypeLabel],
|
label: TicketTypeLabel[x as keyof typeof TicketTypeLabel],
|
||||||
}))}
|
}))}
|
||||||
onChange={(value) =>
|
onChange={(value) => setType((value?.value as TicketType) ?? undefined)}
|
||||||
setType((value?.value as TicketType) ?? undefined)
|
|
||||||
}
|
|
||||||
placeholder="Type..."
|
placeholder="Type..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input label="Reporter" type="text" name="reporter" onChange={() => null} value={`${user.name} - ${user.email}`} disabled />
|
||||||
label="Reporter"
|
|
||||||
type="text"
|
|
||||||
name="reporter"
|
|
||||||
onChange={() => null}
|
|
||||||
value={`${user.name} - ${user.email}`}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<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"
|
className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8"
|
||||||
@@ -110,22 +89,10 @@ export default function TicketSubmission({ user, page, onClose }: Props) {
|
|||||||
spellCheck
|
spellCheck
|
||||||
/>
|
/>
|
||||||
<div className="mt-2 flex w-full items-center justify-end gap-4">
|
<div className="mt-2 flex w-full items-center justify-end gap-4">
|
||||||
<Button
|
<Button type="button" color="red" className="w-full max-w-[200px]" variant="outline" onClick={onClose} isLoading={isLoading}>
|
||||||
type="button"
|
|
||||||
color="red"
|
|
||||||
className="w-full max-w-[200px]"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onClose}
|
|
||||||
isLoading={isLoading}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button type="button" className="w-full max-w-[200px]" isLoading={isLoading} onClick={submit}>
|
||||||
type="button"
|
|
||||||
className="w-full max-w-[200px]"
|
|
||||||
isLoading={isLoading}
|
|
||||||
onClick={submit}
|
|
||||||
>
|
|
||||||
Submit
|
Submit
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { ComponentProps } from "react";
|
import {ComponentProps, useEffect, useState} from "react";
|
||||||
import ReactSelect from "react-select";
|
import ReactSelect from "react-select";
|
||||||
|
|
||||||
interface Option {
|
interface Option {
|
||||||
@@ -18,27 +18,24 @@ interface Props {
|
|||||||
isClearable?: boolean;
|
isClearable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Select({
|
export default function Select({value, defaultValue, options, placeholder, disabled, onChange, isClearable}: Props) {
|
||||||
value,
|
const [target, setTarget] = useState<HTMLElement>();
|
||||||
defaultValue,
|
|
||||||
options,
|
useEffect(() => {
|
||||||
placeholder,
|
if (document) setTarget(document.body);
|
||||||
disabled,
|
}, []);
|
||||||
onChange,
|
|
||||||
isClearable,
|
|
||||||
}: Props) {
|
|
||||||
return (
|
return (
|
||||||
<ReactSelect
|
<ReactSelect
|
||||||
className={clsx(
|
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",
|
"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 &&
|
disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
|
||||||
"!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
|
|
||||||
)}
|
)}
|
||||||
options={options}
|
options={options}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
menuPortalTarget={document?.body}
|
menuPortalTarget={target}
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
styles={{
|
styles={{
|
||||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||||
@@ -53,11 +50,7 @@ export default function Select({
|
|||||||
}),
|
}),
|
||||||
option: (styles, state) => ({
|
option: (styles, state) => ({
|
||||||
...styles,
|
...styles,
|
||||||
backgroundColor: state.isFocused
|
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||||
? "#D5D9F0"
|
|
||||||
: state.isSelected
|
|
||||||
? "#7872BF"
|
|
||||||
: "white",
|
|
||||||
color: state.isFocused ? "black" : styles.color,
|
color: state.isFocused ? "black" : styles.color,
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,9 +13,10 @@ interface Props {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
path: string;
|
path: string;
|
||||||
user: User;
|
user: User;
|
||||||
|
disableNavigation?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MobileMenu({ isOpen, onClose, path, user }: Props) {
|
export default function MobileMenu({isOpen, onClose, path, user, disableNavigation}: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
@@ -34,8 +35,7 @@ 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>
|
||||||
|
|
||||||
@@ -48,146 +48,105 @@ 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="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.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
|
<Dialog.Title as="header" className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden">
|
||||||
as="header"
|
<Link href={disableNavigation ? "" : "/"}>
|
||||||
className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden"
|
<Image src="/logo_title.png" alt="EnCoach logo" width={69} height={69} />
|
||||||
>
|
|
||||||
<Link href="/">
|
|
||||||
<Image
|
|
||||||
src="/logo_title.png"
|
|
||||||
alt="EnCoach logo"
|
|
||||||
width={69}
|
|
||||||
height={69}
|
|
||||||
/>
|
|
||||||
</Link>
|
</Link>
|
||||||
<div
|
<div className="cursor-pointer" onClick={onClose} tabIndex={0}>
|
||||||
className="cursor-pointer"
|
<BsXLg className="text-mti-purple-light text-2xl" onClick={onClose} />
|
||||||
onClick={onClose}
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
<BsXLg
|
|
||||||
className="text-mti-purple-light text-2xl"
|
|
||||||
onClick={onClose}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<div className="flex h-full flex-col gap-6 px-8 text-lg">
|
<div className="flex h-full flex-col gap-6 px-8 text-lg">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href={disableNavigation ? "" : "/"}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-fit transition duration-300 ease-in-out",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/" &&
|
path === "/" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
)}>
|
||||||
)}
|
|
||||||
>
|
|
||||||
Dashboard
|
Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
{(user.type === "student" ||
|
{(user.type === "student" || user.type === "teacher" || user.type === "developer") && (
|
||||||
user.type === "teacher" ||
|
|
||||||
user.type === "developer") && (
|
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
href="/exam"
|
href={disableNavigation ? "" : "/exam"}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-fit transition duration-300 ease-in-out",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/exam" &&
|
path === "/exam" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
)}>
|
||||||
)}
|
|
||||||
>
|
|
||||||
Exams
|
Exams
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/exercises"
|
href={disableNavigation ? "" : "/exercises"}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-fit transition duration-300 ease-in-out",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/exercises" &&
|
path === "/exercises" &&
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
)}
|
)}>
|
||||||
>
|
|
||||||
Exercises
|
Exercises
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Link
|
<Link
|
||||||
href="/stats"
|
href={disableNavigation ? "" : "/stats"}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-fit transition duration-300 ease-in-out",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/stats" &&
|
path === "/stats" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
)}>
|
||||||
)}
|
|
||||||
>
|
|
||||||
Stats
|
Stats
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/record"
|
href={disableNavigation ? "" : "/record"}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-fit transition duration-300 ease-in-out",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/record" &&
|
path === "/record" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
)}>
|
||||||
)}
|
|
||||||
>
|
|
||||||
Record
|
Record
|
||||||
</Link>
|
</Link>
|
||||||
{["admin", "developer", "agent", "corporate"].includes(
|
{["admin", "developer", "agent", "corporate"].includes(user.type) && (
|
||||||
user.type,
|
|
||||||
) && (
|
|
||||||
<Link
|
<Link
|
||||||
href="/payment-record"
|
href={disableNavigation ? "" : "/payment-record"}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-fit transition duration-300 ease-in-out",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/payment-record" &&
|
path === "/payment-record" &&
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
"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(
|
{["admin", "developer", "corporate", "teacher"].includes(user.type) && (
|
||||||
user.type,
|
|
||||||
) && (
|
|
||||||
<Link
|
<Link
|
||||||
href="/settings"
|
href={disableNavigation ? "" : "/settings"}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-fit transition duration-300 ease-in-out",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/settings" &&
|
path === "/settings" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
)}>
|
||||||
)}
|
|
||||||
>
|
|
||||||
Settings
|
Settings
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{["admin", "developer", "agent"].includes(user.type) && (
|
{["admin", "developer", "agent"].includes(user.type) && (
|
||||||
<Link
|
<Link
|
||||||
href="/tickets"
|
href={disableNavigation ? "" : "/tickets"}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-fit transition duration-300 ease-in-out",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/tickets" &&
|
path === "/tickets" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
)}>
|
||||||
)}
|
|
||||||
>
|
|
||||||
Tickets
|
Tickets
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<Link
|
<Link
|
||||||
href="/profile"
|
href={disableNavigation ? "" : "/profile"}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-fit transition duration-300 ease-in-out",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/profile" &&
|
path === "/profile" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
)}>
|
||||||
)}
|
|
||||||
>
|
|
||||||
Profile
|
Profile
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
className={clsx(
|
className={clsx("w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out")}
|
||||||
"w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out",
|
onClick={logout}>
|
||||||
)}
|
|
||||||
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"
|
||||||
|
|||||||
@@ -26,13 +26,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
export default function Navbar({
|
export default function Navbar({user, path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
||||||
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 [isTicketOpen, setIsTicketOpen] = useState(false);
|
||||||
@@ -43,12 +37,9 @@ export default function Navbar({
|
|||||||
const momentDate = moment(date);
|
const momentDate = moment(date);
|
||||||
const today = moment(new Date());
|
const today = moment(new Date());
|
||||||
|
|
||||||
if (today.add(1, "days").isAfter(momentDate))
|
if (today.add(1, "days").isAfter(momentDate)) return "!bg-mti-red-ultralight border-mti-red-light";
|
||||||
return "!bg-mti-red-ultralight border-mti-red-light";
|
if (today.add(3, "days").isAfter(momentDate)) return "!bg-mti-rose-ultralight border-mti-rose-light";
|
||||||
if (today.add(3, "days").isAfter(momentDate))
|
if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light";
|
||||||
return "!bg-mti-rose-ultralight border-mti-rose-light";
|
|
||||||
if (today.add(7, "days").isAfter(momentDate))
|
|
||||||
return "!bg-mti-orange-ultralight border-mti-orange-light";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const showExpirationDate = () => {
|
const showExpirationDate = () => {
|
||||||
@@ -61,40 +52,21 @@ export default function Navbar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user.type !== "student" && user.type !== "teacher")
|
if (user.type !== "student" && user.type !== "teacher") return setDisablePaymentPage(false);
|
||||||
setDisablePaymentPage(false);
|
isUserFromCorporate(user.id).then((result) => setDisablePaymentPage(result));
|
||||||
isUserFromCorporate(user.id).then((result) =>
|
|
||||||
setDisablePaymentPage(result),
|
|
||||||
);
|
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal
|
<Modal isOpen={isTicketOpen} onClose={() => setIsTicketOpen(false)} title="Submit a ticket">
|
||||||
isOpen={isTicketOpen}
|
<TicketSubmission user={user} page={window.location.href} onClose={() => setIsTicketOpen(false)} />
|
||||||
onClose={() => setIsTicketOpen(false)}
|
|
||||||
title="Submit a ticket"
|
|
||||||
>
|
|
||||||
<TicketSubmission
|
|
||||||
user={user}
|
|
||||||
page={window.location.href}
|
|
||||||
onClose={() => setIsTicketOpen(false)}
|
|
||||||
/>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
<MobileMenu
|
<MobileMenu disableNavigation={disableNavigation} path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />
|
||||||
path={path}
|
|
||||||
isOpen={isMenuOpen}
|
|
||||||
onClose={() => setIsMenuOpen(false)}
|
|
||||||
user={user}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4">
|
<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
|
<Link href={disableNavigation ? "" : "/"} className=" flex items-center gap-8 md:px-8">
|
||||||
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="-md:hidden w-1/6 text-2xl font-bold">EnCoach</h1>
|
<h1 className="-md:hidden w-1/6 text-2xl font-bold">EnCoach</h1>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -106,8 +78,7 @@ export default function Navbar({
|
|||||||
"hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white",
|
"hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white",
|
||||||
)}
|
)}
|
||||||
data-tip="Submit a help/feedback ticket"
|
data-tip="Submit a help/feedback ticket"
|
||||||
onClick={() => setIsTicketOpen(true)}
|
onClick={() => setIsTicketOpen(true)}>
|
||||||
>
|
|
||||||
<BsQuestionCircleFill />
|
<BsQuestionCircleFill />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -122,39 +93,23 @@ export default function Navbar({
|
|||||||
? "bg-mti-green-ultralight border-mti-green-light"
|
? "bg-mti-green-ultralight border-mti-green-light"
|
||||||
: expirationDateColor(user.subscriptionExpirationDate),
|
: expirationDateColor(user.subscriptionExpirationDate),
|
||||||
"border-mti-gray-platinum bg-white",
|
"border-mti-gray-platinum bg-white",
|
||||||
)}
|
)}>
|
||||||
>
|
|
||||||
{!user.subscriptionExpirationDate && "Unlimited"}
|
{!user.subscriptionExpirationDate && "Unlimited"}
|
||||||
{user.subscriptionExpirationDate &&
|
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
|
||||||
moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
|
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<Link
|
<Link href={disableNavigation ? "" : "/profile"} className="-md:hidden flex items-center justify-end gap-6">
|
||||||
href={disableNavigation ? "" : "/profile"}
|
<img src={user.profilePicture} alt={user.name} className="h-10 w-10 rounded-full object-cover" />
|
||||||
className="-md:hidden flex items-center justify-end gap-6"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={user.profilePicture}
|
|
||||||
alt={user.name}
|
|
||||||
className="h-10 w-10 rounded-full object-cover"
|
|
||||||
/>
|
|
||||||
<span className="-md:hidden text-right">
|
<span className="-md:hidden text-right">
|
||||||
{user.type === "corporate"
|
{user.type === "corporate" ? `${user.corporateInformation?.companyInformation.name} |` : ""} {user.name} |{" "}
|
||||||
? `${user.corporateInformation?.companyInformation.name} |`
|
{USER_TYPE_LABELS[user.type]}
|
||||||
: ""}{" "}
|
|
||||||
{user.name} | {USER_TYPE_LABELS[user.type]}
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div
|
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}>
|
||||||
className="cursor-pointer md:hidden"
|
|
||||||
onClick={() => setIsMenuOpen(true)}
|
|
||||||
>
|
|
||||||
<BsList className="text-mti-purple-light h-8 w-8" />
|
<BsList className="text-mti-purple-light h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{focusMode && (
|
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
||||||
<FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
|
|
||||||
)}
|
|
||||||
</header>
|
</header>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,7 +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 {Variant} from "@/interfaces/exam";
|
import {InstructorGender, Variant} from "@/interfaces/exam";
|
||||||
|
import Select from "@/components/Low/Select";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isCreating: boolean;
|
isCreating: boolean;
|
||||||
@@ -40,6 +41,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(),
|
assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(),
|
||||||
);
|
);
|
||||||
const [variant, setVariant] = useState<Variant>("full");
|
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);
|
||||||
|
|
||||||
@@ -63,6 +65,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
selectedModules,
|
selectedModules,
|
||||||
generateMultiple,
|
generateMultiple,
|
||||||
variant,
|
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!`);
|
||||||
@@ -226,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">
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Button from "@/components/Low/Button";
|
||||||
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";
|
||||||
@@ -9,17 +10,13 @@ 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 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 {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {
|
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||||
BsBook,
|
import {toast} from "react-toastify";
|
||||||
BsClipboard,
|
|
||||||
BsHeadphones,
|
|
||||||
BsMegaphone,
|
|
||||||
BsPen,
|
|
||||||
} from "react-icons/bs";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -36,6 +33,16 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
|||||||
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||||
|
|
||||||
|
const deleteAssignment = async () => {
|
||||||
|
if (!confirm("Are you sure you want to delete this assignment?")) return;
|
||||||
|
|
||||||
|
axios
|
||||||
|
.delete(`/api/assignments/${assignment?.id}`)
|
||||||
|
.then(() => toast.success(`Successfully deleted the assignment "${assignment?.name}".`))
|
||||||
|
.catch(() => toast.error("Something went wrong, please try again later."))
|
||||||
|
.finally(onClose);
|
||||||
|
};
|
||||||
|
|
||||||
const formatTimestamp = (timestamp: string) => {
|
const formatTimestamp = (timestamp: string) => {
|
||||||
const date = moment(parseInt(timestamp));
|
const date = moment(parseInt(timestamp));
|
||||||
const formatter = "YYYY/MM/DD - HH:mm";
|
const formatter = "YYYY/MM/DD - HH:mm";
|
||||||
@@ -49,26 +56,15 @@ 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(
|
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
|
||||||
(acc, curr) => acc + curr.score.correct,
|
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
|
||||||
0,
|
|
||||||
);
|
|
||||||
const total = moduleStats.reduce(
|
|
||||||
(acc, curr) => acc + curr.score.total,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
return calculateBandScore(correct, total, module, r.type);
|
return calculateBandScore(correct, total, module, r.type);
|
||||||
});
|
});
|
||||||
|
|
||||||
return resultModuleBandScores.length === 0
|
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length;
|
||||||
? -1
|
|
||||||
: resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
|
|
||||||
assignment.results.length;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const aggregateScoresByModule = (
|
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => {
|
||||||
stats: Stat[],
|
|
||||||
): { module: Module; total: number; missing: number; correct: number }[] => {
|
|
||||||
const scores: {
|
const scores: {
|
||||||
[key in Module]: {total: number; missing: number; correct: number};
|
[key in Module]: {total: number; missing: number; correct: number};
|
||||||
} = {
|
} = {
|
||||||
@@ -112,22 +108,10 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
|||||||
.map((x) => ({module: x as Module, ...scores[x as Module]}));
|
.map((x) => ({module: x as Module, ...scores[x as Module]}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const customContent = (
|
const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => {
|
||||||
stats: Stat[],
|
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
|
||||||
user: string,
|
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
|
||||||
focus: "academic" | "general",
|
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
|
||||||
) => {
|
|
||||||
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,
|
||||||
@@ -137,9 +121,7 @@ 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) =>
|
const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam));
|
||||||
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)) {
|
||||||
@@ -161,15 +143,11 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
|||||||
<>
|
<>
|
||||||
<div className="-md:items-center flex w-full justify-between 2xl:items-center">
|
<div className="-md:items-center flex w-full justify-between 2xl:items-center">
|
||||||
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
|
<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">
|
<span className="font-medium">{formatTimestamp(stats[0].date.toString())}</span>
|
||||||
{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">
|
<span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span>
|
||||||
{Math.floor(timeSpent / 60)} minutes
|
|
||||||
</span>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -178,15 +156,9 @@ 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>
|
||||||
|
|
||||||
@@ -202,8 +174,7 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
|||||||
module === "writing" && "bg-ielts-writing",
|
module === "writing" && "bg-ielts-writing",
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
module === "level" && "bg-ielts-level",
|
module === "level" && "bg-ielts-level",
|
||||||
)}
|
)}>
|
||||||
>
|
|
||||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||||
@@ -230,14 +201,11 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
|
"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.3 && correct / total < 0.7 && "hover:border-mti-red",
|
||||||
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
|
||||||
@@ -245,14 +213,11 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden",
|
"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.3 && correct / total < 0.7 && "hover:border-mti-red",
|
||||||
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>
|
||||||
@@ -267,27 +232,14 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
|||||||
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?.results.length || 0) / (assignment?.assignees.length || 1) < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"
|
||||||
(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 items-start gap-8">
|
<div className="flex items-start gap-8">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span>
|
<span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
Start Date:{" "}
|
<span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
{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:{" "}
|
||||||
@@ -312,19 +264,14 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
|||||||
module === "writing" && "bg-ielts-writing",
|
module === "writing" && "bg-ielts-writing",
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
module === "level" && "bg-ielts-level",
|
module === "level" && "bg-ielts-level",
|
||||||
)}
|
)}>
|
||||||
>
|
|
||||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||||
{module === "listening" && (
|
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||||
<BsHeadphones className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||||
{calculateAverageModuleScore(module) > -1 && (
|
{calculateAverageModuleScore(module) > -1 && (
|
||||||
<span className="text-sm">
|
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
|
||||||
{calculateAverageModuleScore(module).toFixed(1)}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -332,22 +279,28 @@ 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 w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 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) =>
|
{assignment.results.map((r) => customContent(r.stats, r.user, r.type))}
|
||||||
customContent(r.stats, r.user, r.type),
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{assignment && assignment?.results.length === 0 && (
|
{assignment && assignment?.results.length === 0 && <span className="ml-1 font-semibold">No results yet...</span>}
|
||||||
<span className="ml-1 font-semibold">No results yet...</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 w-full items-center justify-end">
|
||||||
|
{assignment && (assignment.results.length === assignment.assignees.length || moment().isAfter(moment(assignment.endDate))) && (
|
||||||
|
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={deleteAssignment}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={onClose} className="w-full max-w-[200px]">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -86,16 +86,19 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
icon: <BsFileEarmarkText className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
|
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",
|
||||||
|
tooltip: "Number of all conducted completed exams",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BsPencil className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
|
icon: <BsPencil className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
|
||||||
value: stats.length,
|
value: stats.length,
|
||||||
label: "Exercises",
|
label: "Exercises",
|
||||||
|
tooltip: "Number of all conducted exercises including Level Test",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BsStar className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
|
icon: <BsStar className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
|
||||||
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
|
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
|
||||||
label: "Average Score",
|
label: "Average Score",
|
||||||
|
tooltip: "Average success rate for questions responded",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
onClose={() => {
|
onClose={() => {
|
||||||
setSelectedAssignment(undefined);
|
setSelectedAssignment(undefined);
|
||||||
setIsCreatingAssignment(false);
|
setIsCreatingAssignment(false);
|
||||||
|
reloadAssignments();
|
||||||
}}
|
}}
|
||||||
assignment={selectedAssignment}
|
assignment={selectedAssignment}
|
||||||
/>
|
/>
|
||||||
|
|||||||
35
src/email/templates/ticketStatusCompleted.handlebars
Normal file
35
src/email/templates/ticketStatusCompleted.handlebars
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<div style="background-color: #ffffff; color: #353338;"
|
||||||
|
class="h-full min-h-screen w-full flex flex-col p-8 gap-16 text-base">
|
||||||
|
<img src="/logo_title.png" class="w-48 h-48 self-center" />
|
||||||
|
<div>
|
||||||
|
<span>Your ticket has been completed!</span>
|
||||||
|
<br/>
|
||||||
|
<span>Here is the ticket's information:</span>
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
<span><b>ID:</b> {{id}}</span><br/>
|
||||||
|
<span><b>Subject:</b> {{subject}}</span><br/>
|
||||||
|
<span><b>Reporter:</b> {{reporter.name}} - {{reporter.email}}</span><br/>
|
||||||
|
<span><b>Date:</b> {{date}}</span><br/>
|
||||||
|
<span><b>Type:</b> {{type}}</span><br/>
|
||||||
|
<span><b>Page:</b> {{reportedFrom}}</span>
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
<span><b>Description:</b> {{description}}</span><br/>
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<div>
|
||||||
|
<span>Thanks, <br /> Your EnCoach team</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -9,16 +9,7 @@ import clsx from "clsx";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {Fragment, useEffect, useState} from "react";
|
import {Fragment, useEffect, useState} from "react";
|
||||||
import {
|
import {BsArrowCounterclockwise, BsBook, BsClipboard, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs";
|
||||||
BsArrowCounterclockwise,
|
|
||||||
BsBook,
|
|
||||||
BsClipboard,
|
|
||||||
BsEyeFill,
|
|
||||||
BsHeadphones,
|
|
||||||
BsMegaphone,
|
|
||||||
BsPen,
|
|
||||||
BsShareFill,
|
|
||||||
} from "react-icons/bs";
|
|
||||||
import {LevelScore} from "@/constants/ielts";
|
import {LevelScore} from "@/constants/ielts";
|
||||||
import {getLevelScore} from "@/utils/score";
|
import {getLevelScore} from "@/utils/score";
|
||||||
|
|
||||||
@@ -37,28 +28,15 @@ interface Props {
|
|||||||
onViewResults: () => void;
|
onViewResults: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Finish({
|
export default function Finish({user, scores, modules, isLoading, onViewResults}: Props) {
|
||||||
user,
|
|
||||||
scores,
|
|
||||||
modules,
|
|
||||||
isLoading,
|
|
||||||
onViewResults,
|
|
||||||
}: Props) {
|
|
||||||
const [selectedModule, setSelectedModule] = useState(modules[0]);
|
const [selectedModule, setSelectedModule] = useState(modules[0]);
|
||||||
const [selectedScore, setSelectedScore] = useState<Score>(
|
const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!);
|
||||||
scores.find((x) => x.module === modules[0])!,
|
|
||||||
);
|
|
||||||
|
|
||||||
const exams = useExamStore((state) => state.exams);
|
const exams = useExamStore((state) => state.exams);
|
||||||
|
|
||||||
useEffect(
|
useEffect(() => setSelectedScore(scores.find((x) => x.module === selectedModule)!), [scores, selectedModule]);
|
||||||
() => setSelectedScore(scores.find((x) => x.module === selectedModule)!),
|
|
||||||
[scores, selectedModule],
|
|
||||||
);
|
|
||||||
useEffect(() => console.log(scores), [scores]);
|
|
||||||
|
|
||||||
const moduleColors: { [key in Module]: { progress: string; inner: string } } =
|
const moduleColors: {[key in Module]: {progress: string; inner: string}} = {
|
||||||
{
|
|
||||||
reading: {
|
reading: {
|
||||||
progress: "text-ielts-reading",
|
progress: "text-ielts-reading",
|
||||||
inner: "bg-ielts-reading-light",
|
inner: "bg-ielts-reading-light",
|
||||||
@@ -90,19 +68,14 @@ export default function Finish({
|
|||||||
return exam.exercises.length;
|
return exam.exercises.length;
|
||||||
};
|
};
|
||||||
|
|
||||||
const bandScore: number = calculateBandScore(
|
const bandScore: number = calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus);
|
||||||
selectedScore.correct,
|
|
||||||
selectedScore.total,
|
|
||||||
selectedModule,
|
|
||||||
user.focus,
|
|
||||||
);
|
|
||||||
|
|
||||||
const showLevel = (level: number) => {
|
const showLevel = (level: number) => {
|
||||||
if (selectedModule === "level") {
|
if (selectedModule === "level") {
|
||||||
const [levelStr, grade] = getLevelScore(level);
|
const [levelStr, grade] = getLevelScore(level);
|
||||||
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">{grade}</span>
|
<span className="text-xl font-bold">{levelStr}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -126,11 +99,8 @@ export default function Finish({
|
|||||||
onClick={() => setSelectedModule("reading")}
|
onClick={() => setSelectedModule("reading")}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"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",
|
"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"
|
selectedModule === "reading" ? "bg-ielts-reading text-white" : "bg-mti-gray-smoke text-ielts-reading",
|
||||||
? "bg-ielts-reading text-white"
|
)}>
|
||||||
: "bg-mti-gray-smoke text-ielts-reading",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<BsBook className="h-6 w-6" />
|
<BsBook className="h-6 w-6" />
|
||||||
<span className="font-semibold">Reading</span>
|
<span className="font-semibold">Reading</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,11 +110,8 @@ export default function Finish({
|
|||||||
onClick={() => setSelectedModule("listening")}
|
onClick={() => setSelectedModule("listening")}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"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",
|
"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"
|
selectedModule === "listening" ? "bg-ielts-listening text-white" : "bg-mti-gray-smoke text-ielts-listening",
|
||||||
? "bg-ielts-listening text-white"
|
)}>
|
||||||
: "bg-mti-gray-smoke text-ielts-listening",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<BsHeadphones className="h-6 w-6" />
|
<BsHeadphones className="h-6 w-6" />
|
||||||
<span className="font-semibold">Listening</span>
|
<span className="font-semibold">Listening</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,11 +121,8 @@ export default function Finish({
|
|||||||
onClick={() => setSelectedModule("writing")}
|
onClick={() => setSelectedModule("writing")}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"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",
|
"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"
|
selectedModule === "writing" ? "bg-ielts-writing text-white" : "bg-mti-gray-smoke text-ielts-writing",
|
||||||
? "bg-ielts-writing text-white"
|
)}>
|
||||||
: "bg-mti-gray-smoke text-ielts-writing",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<BsPen className="h-6 w-6" />
|
<BsPen className="h-6 w-6" />
|
||||||
<span className="font-semibold">Writing</span>
|
<span className="font-semibold">Writing</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,11 +132,8 @@ export default function Finish({
|
|||||||
onClick={() => setSelectedModule("speaking")}
|
onClick={() => setSelectedModule("speaking")}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"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",
|
"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"
|
selectedModule === "speaking" ? "bg-ielts-speaking text-white" : "bg-mti-gray-smoke text-ielts-speaking",
|
||||||
? "bg-ielts-speaking text-white"
|
)}>
|
||||||
: "bg-mti-gray-smoke text-ielts-speaking",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<BsMegaphone className="h-6 w-6" />
|
<BsMegaphone className="h-6 w-6" />
|
||||||
<span className="font-semibold">Speaking</span>
|
<span className="font-semibold">Speaking</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -182,11 +143,8 @@ export default function Finish({
|
|||||||
onClick={() => setSelectedModule("level")}
|
onClick={() => setSelectedModule("level")}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"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",
|
"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"
|
selectedModule === "level" ? "bg-ielts-level text-white" : "bg-mti-gray-smoke text-ielts-level",
|
||||||
? "bg-ielts-level text-white"
|
)}>
|
||||||
: "bg-mti-gray-smoke text-ielts-level",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<BsClipboard className="h-6 w-6" />
|
<BsClipboard className="h-6 w-6" />
|
||||||
<span className="font-semibold">Level</span>
|
<span className="font-semibold">Level</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -194,18 +152,8 @@ export default function Finish({
|
|||||||
</div>
|
</div>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<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">
|
<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
|
<span className={clsx("loading loading-infinity w-32", moduleColors[selectedModule].progress)} />
|
||||||
className={clsx(
|
<span className={clsx("text-center text-2xl font-bold", moduleColors[selectedModule].progress)}>
|
||||||
"loading loading-infinity w-32",
|
|
||||||
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!
|
||||||
@@ -214,30 +162,22 @@ export default function Finish({
|
|||||||
)}
|
)}
|
||||||
{!isLoading && (
|
{!isLoading && (
|
||||||
<div className="mb-20 mt-32 flex w-full items-center justify-between gap-9">
|
<div className="mb-20 mt-32 flex w-full items-center justify-between gap-9">
|
||||||
<span className="max-w-3xl">
|
<span className="max-w-3xl">{moduleResultText(selectedModule, bandScore)}</span>
|
||||||
{moduleResultText(selectedModule, bandScore)}
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-9 px-16">
|
<div className="flex gap-9 px-16">
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx("radial-progress overflow-hidden", moduleColors[selectedModule].progress)}
|
||||||
"radial-progress overflow-hidden",
|
|
||||||
moduleColors[selectedModule].progress,
|
|
||||||
)}
|
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
"--value":
|
"--value": (selectedScore.correct / selectedScore.total) * 100,
|
||||||
(selectedScore.correct / selectedScore.total) * 100,
|
|
||||||
"--thickness": "12px",
|
"--thickness": "12px",
|
||||||
"--size": "13rem",
|
"--size": "13rem",
|
||||||
} as any
|
} as any
|
||||||
}
|
}>
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex h-48 w-48 flex-col items-center justify-center rounded-full",
|
"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>
|
||||||
{showLevel(bandScore)}
|
{showLevel(bandScore)}
|
||||||
</div>
|
</div>
|
||||||
@@ -247,12 +187,7 @@ export default function Finish({
|
|||||||
<div className="bg-mti-red-light mt-1 h-3 w-3 rounded-full" />
|
<div className="bg-mti-red-light mt-1 h-3 w-3 rounded-full" />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<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)}
|
|
||||||
%
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-lg">Completion</span>
|
<span className="text-lg">Completion</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -260,9 +195,7 @@ export default function Finish({
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="bg-mti-purple-light mt-1 h-3 w-3 rounded-full" />
|
<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">
|
<span className="text-mti-purple-light">{selectedScore.correct.toString().padStart(2, "0")}</span>
|
||||||
{selectedScore.correct.toString().padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
<span className="text-lg">Correct</span>
|
<span className="text-lg">Correct</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -270,9 +203,7 @@ export default function Finish({
|
|||||||
<div className="bg-mti-rose-light mt-1 h-3 w-3 rounded-full" />
|
<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)
|
{(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")}
|
||||||
.toString()
|
|
||||||
.padStart(2, "0")}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-lg">Wrong</span>
|
<span className="text-lg">Wrong</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -289,8 +220,7 @@ export default function Finish({
|
|||||||
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out"
|
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
||||||
>
|
|
||||||
<BsArrowCounterclockwise className="h-7 w-7 text-white" />
|
<BsArrowCounterclockwise className="h-7 w-7 text-white" />
|
||||||
</button>
|
</button>
|
||||||
<span>Play Again</span>
|
<span>Play Again</span>
|
||||||
@@ -298,8 +228,7 @@ export default function Finish({
|
|||||||
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={onViewResults}
|
onClick={onViewResults}
|
||||||
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out"
|
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
||||||
>
|
|
||||||
<BsEyeFill className="h-7 w-7 text-white" />
|
<BsEyeFill className="h-7 w-7 text-white" />
|
||||||
</button>
|
</button>
|
||||||
<span>Review Answers</span>
|
<span>Review Answers</span>
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -13,6 +13,10 @@ import {sortByModuleName} from "@/utils/moduleUtils";
|
|||||||
import {capitalize} from "lodash";
|
import {capitalize} from "lodash";
|
||||||
import ProfileSummary from "@/components/ProfileSummary";
|
import ProfileSummary from "@/components/ProfileSummary";
|
||||||
import {Variant} from "@/interfaces/exam";
|
import {Variant} from "@/interfaces/exam";
|
||||||
|
import useSessions, {Session} from "@/hooks/useSessions";
|
||||||
|
import SessionCard from "@/components/Medium/SessionCard";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import moment from "moment";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -25,13 +29,32 @@ export default function Selection({user, page, onStart, disableSelection = false
|
|||||||
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
|
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
|
||||||
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
|
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
|
||||||
const [variant, setVariant] = useState<Variant>("full");
|
const [variant, setVariant] = useState<Variant>("full");
|
||||||
|
|
||||||
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="relative flex h-full w-full flex-col gap-8 md:gap-16">
|
<div className="relative flex h-full w-full flex-col gap-8 md:gap-16">
|
||||||
@@ -94,7 +117,28 @@ export default function Selection({user, page, onStart, disableSelection = false
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</section>
|
</section>
|
||||||
<section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-8 flex w-full justify-between gap-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(
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -37,15 +37,7 @@ const TestReportFooter = ({ userId }: Props) => (
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{userId && (
|
|
||||||
<View>
|
<View>
|
||||||
<Text style={styles.textBold}>
|
|
||||||
User ID:{" "}
|
|
||||||
<Text style={[styles.textFont, styles.textNormal]}>{userId}</Text>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<View style={{ paddingTop: 4 }}>
|
|
||||||
<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
|
||||||
@@ -57,6 +49,22 @@ const TestReportFooter = ({ userId }: Props) => (
|
|||||||
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>
|
||||||
|
|||||||
26
src/hooks/useAcceptedTerms.tsx
Normal file
26
src/hooks/useAcceptedTerms.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
|
|
||||||
|
const useAcceptedTerms = () => {
|
||||||
|
const [acceptedTerms, setAcceptedTerms] = React.useState(false);
|
||||||
|
|
||||||
|
const renderCheckbox = () => (
|
||||||
|
<Checkbox isChecked={acceptedTerms} onChange={setAcceptedTerms}>
|
||||||
|
I agree to the
|
||||||
|
<Link href={`https://encoach.com/terms`} className="text-mti-purple-light">
|
||||||
|
{" "}
|
||||||
|
Terms and Conditions
|
||||||
|
</Link>{" "}
|
||||||
|
and
|
||||||
|
<Link href={`https://encoach.com/privacy-policy`} className="text-mti-purple-light">
|
||||||
|
{" "}
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
</Checkbox>
|
||||||
|
);
|
||||||
|
|
||||||
|
return {acceptedTerms, renderCheckbox};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useAcceptedTerms;
|
||||||
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};
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import {Module} from ".";
|
|||||||
|
|
||||||
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
|
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
|
||||||
export type Variant = "full" | "partial";
|
export type Variant = "full" | "partial";
|
||||||
|
export type InstructorGender = "male" | "female" | "varied";
|
||||||
|
export type Difficulty = "easy" | "medium" | "hard";
|
||||||
|
|
||||||
export interface ReadingExam {
|
export interface ReadingExam {
|
||||||
parts: ReadingPart[];
|
parts: ReadingPart[];
|
||||||
@@ -11,6 +13,7 @@ export interface ReadingExam {
|
|||||||
type: "academic" | "general";
|
type: "academic" | "general";
|
||||||
isDiagnostic: boolean;
|
isDiagnostic: boolean;
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
|
difficulty?: Difficulty;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReadingPart {
|
export interface ReadingPart {
|
||||||
@@ -28,6 +31,7 @@ export interface LevelExam {
|
|||||||
minTimer: number;
|
minTimer: number;
|
||||||
isDiagnostic: boolean;
|
isDiagnostic: boolean;
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
|
difficulty?: Difficulty;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListeningExam {
|
export interface ListeningExam {
|
||||||
@@ -37,6 +41,7 @@ export interface ListeningExam {
|
|||||||
minTimer: number;
|
minTimer: number;
|
||||||
isDiagnostic: boolean;
|
isDiagnostic: boolean;
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
|
difficulty?: Difficulty;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListeningPart {
|
export interface ListeningPart {
|
||||||
@@ -68,6 +73,7 @@ export interface WritingExam {
|
|||||||
minTimer: number;
|
minTimer: number;
|
||||||
isDiagnostic: boolean;
|
isDiagnostic: boolean;
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
|
difficulty?: Difficulty;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WordCounter {
|
interface WordCounter {
|
||||||
@@ -82,6 +88,8 @@ export interface SpeakingExam {
|
|||||||
minTimer: number;
|
minTimer: number;
|
||||||
isDiagnostic: boolean;
|
isDiagnostic: boolean;
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
|
instructorGender: InstructorGender;
|
||||||
|
difficulty?: Difficulty;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Exercise =
|
export type Exercise =
|
||||||
@@ -164,7 +172,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;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,62 +15,120 @@ import useUser from "@/hooks/useUser";
|
|||||||
import {Exam, UserSolution, Variant} from "@/interfaces/exam";
|
import {Exam, UserSolution, Variant} from "@/interfaces/exam";
|
||||||
import {Stat} from "@/interfaces/user";
|
import {Stat} from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {
|
import {evaluateSpeakingAnswer, evaluateWritingAnswer} from "@/utils/evaluation";
|
||||||
evaluateSpeakingAnswer,
|
import {defaultExamUserSolutions, getExam} from "@/utils/exams";
|
||||||
evaluateWritingAnswer,
|
|
||||||
} from "@/utils/evaluation";
|
|
||||||
import { getExam } from "@/utils/exams";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {toast, ToastContainer} from "react-toastify";
|
import {toast, ToastContainer} from "react-toastify";
|
||||||
import {v4 as uuidv4} from "uuid";
|
import {v4 as uuidv4} from "uuid";
|
||||||
|
import useSessions from "@/hooks/useSessions";
|
||||||
|
import 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 [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 [timeSpent, setTimeSpent] = useState(0);
|
|
||||||
const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<
|
|
||||||
string[]
|
|
||||||
>([]);
|
|
||||||
const [variant, setVariant] = useState<Variant>("full");
|
const [variant, setVariant] = useState<Variant>("full");
|
||||||
|
const [avoidRepeated, setAvoidRepeated] = useState(false);
|
||||||
|
const [hasBeenUploaded, setHasBeenUploaded] = useState(false);
|
||||||
|
const [showAbandonPopup, setShowAbandonPopup] = useState(false);
|
||||||
|
const [isEvaluationLoading, setIsEvaluationLoading] = useState(false);
|
||||||
|
const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<string[]>([]);
|
||||||
|
const [timeSpent, setTimeSpent] = useState(0);
|
||||||
|
|
||||||
const [exams, setExams] = useExamStore((state) => [
|
const resetStore = useExamStore((state) => state.reset);
|
||||||
state.exams,
|
|
||||||
state.setExams,
|
|
||||||
]);
|
|
||||||
const [userSolutions, setUserSolutions] = useExamStore((state) => [
|
|
||||||
state.userSolutions,
|
|
||||||
state.setUserSolutions,
|
|
||||||
]);
|
|
||||||
const [showSolutions, setShowSolutions] = useExamStore((state) => [
|
|
||||||
state.showSolutions,
|
|
||||||
state.setShowSolutions,
|
|
||||||
]);
|
|
||||||
const [selectedModules, setSelectedModules] = useExamStore((state) => [
|
|
||||||
state.selectedModules,
|
|
||||||
state.setSelectedModules,
|
|
||||||
]);
|
|
||||||
const 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);
|
||||||
@@ -85,16 +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 (
|
if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) {
|
||||||
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);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -105,7 +162,12 @@ export default function ExamPage({ page }: Props) {
|
|||||||
(async () => {
|
(async () => {
|
||||||
if (selectedModules.length > 0 && exams.length === 0) {
|
if (selectedModules.length > 0 && exams.length === 0) {
|
||||||
const examPromises = selectedModules.map((module) =>
|
const examPromises = selectedModules.map((module) =>
|
||||||
getExam(module, avoidRepeated, variant),
|
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)) {
|
||||||
@@ -121,20 +183,14 @@ export default function ExamPage({ page }: Props) {
|
|||||||
}, [selectedModules, setExams, exams]);
|
}, [selectedModules, setExams, exams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (selectedModules.length > 0 && exams.length !== 0 && moduleIndex >= selectedModules.length && !hasBeenUploaded && !showSolutions) {
|
||||||
selectedModules.length > 0 &&
|
|
||||||
exams.length !== 0 &&
|
|
||||||
moduleIndex >= selectedModules.length &&
|
|
||||||
!hasBeenUploaded &&
|
|
||||||
!showSolutions
|
|
||||||
) {
|
|
||||||
const newStats: Stat[] = userSolutions.map((solution) => ({
|
const newStats: Stat[] = userSolutions.map((solution) => ({
|
||||||
...solution,
|
...solution,
|
||||||
id: solution.id || uuidv4(),
|
id: solution.id || uuidv4(),
|
||||||
timeSpent,
|
timeSpent,
|
||||||
session: sessionId,
|
session: sessionId,
|
||||||
exam: 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} : {}),
|
||||||
@@ -161,12 +217,8 @@ export default function ExamPage({ page }: Props) {
|
|||||||
|
|
||||||
const checkIfStatsHaveBeenEvaluated = (ids: string[]) => {
|
const checkIfStatsHaveBeenEvaluated = (ids: string[]) => {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
const awaitedStats = await Promise.all(
|
const awaitedStats = await Promise.all(ids.map(async (id) => (await axios.get<Stat>(`/api/stats/${id}`)).data));
|
||||||
ids.map(async (id) => (await axios.get<Stat>(`/api/stats/${id}`)).data),
|
const solutionsEvaluated = awaitedStats.every((stat) => stat.solutions.every((x) => x.evaluation !== null));
|
||||||
);
|
|
||||||
const solutionsEvaluated = awaitedStats.every((stat) =>
|
|
||||||
stat.solutions.every((x) => x.evaluation !== null),
|
|
||||||
);
|
|
||||||
if (solutionsEvaluated) {
|
if (solutionsEvaluated) {
|
||||||
const statsUserSolutions: UserSolution[] = awaitedStats.map((stat) => ({
|
const statsUserSolutions: UserSolution[] = awaitedStats.map((stat) => ({
|
||||||
id: stat.id,
|
id: stat.id,
|
||||||
@@ -179,16 +231,12 @@ export default function ExamPage({ page }: Props) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const updatedUserSolutions = userSolutions.map((x) => {
|
const updatedUserSolutions = userSolutions.map((x) => {
|
||||||
const respectiveSolution = statsUserSolutions.find(
|
const respectiveSolution = statsUserSolutions.find((y) => y.exercise === x.exercise);
|
||||||
(y) => y.exercise === x.exercise,
|
|
||||||
);
|
|
||||||
return respectiveSolution ? respectiveSolution : x;
|
return respectiveSolution ? respectiveSolution : x;
|
||||||
});
|
});
|
||||||
|
|
||||||
setUserSolutions(updatedUserSolutions);
|
setUserSolutions(updatedUserSolutions);
|
||||||
return setStatsAwaitingEvaluation((prev) =>
|
return setStatsAwaitingEvaluation((prev) => prev.filter((x) => !ids.includes(x)));
|
||||||
prev.filter((x) => !ids.includes(x)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return checkIfStatsHaveBeenEvaluated(ids);
|
return checkIfStatsHaveBeenEvaluated(ids);
|
||||||
@@ -201,8 +249,7 @@ export default function ExamPage({ page }: Props) {
|
|||||||
Object.assign(p, {
|
Object.assign(p, {
|
||||||
exercises: p.exercises.map((x) =>
|
exercises: p.exercises.map((x) =>
|
||||||
Object.assign(x, {
|
Object.assign(x, {
|
||||||
userSolutions: userSolutions.find((y) => x.id === y.exercise)
|
userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions,
|
||||||
?.solutions,
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
@@ -212,8 +259,7 @@ export default function ExamPage({ page }: Props) {
|
|||||||
|
|
||||||
const exercises = exam.exercises.map((x) =>
|
const exercises = exam.exercises.map((x) =>
|
||||||
Object.assign(x, {
|
Object.assign(x, {
|
||||||
userSolutions: userSolutions.find((y) => x.id === y.exercise)
|
userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions,
|
||||||
?.solutions,
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return Object.assign(exam, {exercises});
|
return Object.assign(exam, {exercises});
|
||||||
@@ -225,12 +271,7 @@ export default function ExamPage({ page }: Props) {
|
|||||||
|
|
||||||
if (exam && !solutionExams.includes(exam.id)) return;
|
if (exam && !solutionExams.includes(exam.id)) return;
|
||||||
|
|
||||||
if (
|
if (exam && (exam.module === "writing" || exam.module === "speaking") && solutions.length > 0 && !showSolutions) {
|
||||||
exam &&
|
|
||||||
(exam.module === "writing" || exam.module === "speaking") &&
|
|
||||||
solutions.length > 0 &&
|
|
||||||
!showSolutions
|
|
||||||
) {
|
|
||||||
setHasBeenUploaded(true);
|
setHasBeenUploaded(true);
|
||||||
setIsEvaluationLoading(true);
|
setIsEvaluationLoading(true);
|
||||||
|
|
||||||
@@ -238,32 +279,15 @@ export default function ExamPage({ page }: Props) {
|
|||||||
exam.exercises.map(async (exercise) => {
|
exam.exercises.map(async (exercise) => {
|
||||||
const evaluationID = uuidv4();
|
const evaluationID = uuidv4();
|
||||||
if (exercise.type === "writing")
|
if (exercise.type === "writing")
|
||||||
return await evaluateWritingAnswer(
|
return await evaluateWritingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!, evaluationID);
|
||||||
exercise,
|
|
||||||
solutions.find((x) => x.exercise === exercise.id)!,
|
|
||||||
evaluationID,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking")
|
||||||
exercise.type === "interactiveSpeaking" ||
|
return await evaluateSpeakingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!, evaluationID);
|
||||||
exercise.type === "speaking"
|
|
||||||
)
|
|
||||||
return await evaluateSpeakingAnswer(
|
|
||||||
exercise,
|
|
||||||
solutions.find((x) => x.exercise === exercise.id)!,
|
|
||||||
evaluationID,
|
|
||||||
);
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.then((responses) => {
|
.then((responses) => {
|
||||||
setStatsAwaitingEvaluation((prev) => [
|
setStatsAwaitingEvaluation((prev) => [...prev, ...responses.filter((x) => !!x).map((r) => (r as any).id)]);
|
||||||
...prev,
|
setUserSolutions([...userSolutions, ...responses.filter((x) => !!x)] as any);
|
||||||
...responses.filter((x) => !!x).map((r) => (r as any).id),
|
|
||||||
]);
|
|
||||||
setUserSolutions([
|
|
||||||
...userSolutions,
|
|
||||||
...responses.filter((x) => !!x),
|
|
||||||
] as any);
|
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setHasBeenUploaded(false);
|
setHasBeenUploaded(false);
|
||||||
@@ -272,16 +296,15 @@ export default function ExamPage({ page }: Props) {
|
|||||||
|
|
||||||
axios.get("/api/stats/update");
|
axios.get("/api/stats/update");
|
||||||
|
|
||||||
setUserSolutions([
|
setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...solutions]);
|
||||||
...userSolutions.filter((x) => !solutionIds.includes(x.exercise)),
|
setModuleIndex(moduleIndex + 1);
|
||||||
...solutions,
|
|
||||||
]);
|
setPartIndex(-1);
|
||||||
setModuleIndex((prev) => prev + 1);
|
setExerciseIndex(-1);
|
||||||
|
setQuestionIndex(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const aggregateScoresByModule = (
|
const aggregateScoresByModule = (answers: UserSolution[]): {module: Module; total: number; missing: number; correct: number}[] => {
|
||||||
answers: UserSolution[],
|
|
||||||
): { module: Module; total: number; missing: number; correct: number }[] => {
|
|
||||||
const scores: {
|
const scores: {
|
||||||
[key in Module]: {total: number; missing: number; correct: number};
|
[key in Module]: {total: number; missing: number; correct: number};
|
||||||
} = {
|
} = {
|
||||||
@@ -313,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,
|
||||||
@@ -351,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)}
|
||||||
@@ -359,49 +386,23 @@ export default function ExamPage({ page }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (exam && exam.module === "reading") {
|
if (exam && exam.module === "reading") {
|
||||||
return (
|
return <Reading exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
|
||||||
<Reading
|
|
||||||
exam={exam}
|
|
||||||
onFinish={onFinish}
|
|
||||||
showSolutions={showSolutions}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exam && exam.module === "listening") {
|
if (exam && exam.module === "listening") {
|
||||||
return (
|
return <Listening exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
|
||||||
<Listening
|
|
||||||
exam={exam}
|
|
||||||
onFinish={onFinish}
|
|
||||||
showSolutions={showSolutions}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exam && exam.module === "writing") {
|
if (exam && exam.module === "writing") {
|
||||||
return (
|
return <Writing exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
|
||||||
<Writing
|
|
||||||
exam={exam}
|
|
||||||
onFinish={onFinish}
|
|
||||||
showSolutions={showSolutions}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exam && exam.module === "speaking") {
|
if (exam && exam.module === "speaking") {
|
||||||
return (
|
return <Speaking exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
|
||||||
<Speaking
|
|
||||||
exam={exam}
|
|
||||||
onFinish={onFinish}
|
|
||||||
showSolutions={showSolutions}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exam && exam.module === "level") {
|
if (exam && exam.module === "level") {
|
||||||
return (
|
return <Level exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
|
||||||
<Level exam={exam} onFinish={onFinish} showSolutions={showSolutions} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>Loading...</>;
|
return <>Loading...</>;
|
||||||
@@ -414,13 +415,8 @@ export default function ExamPage({ page }: Props) {
|
|||||||
<Layout
|
<Layout
|
||||||
user={user}
|
user={user}
|
||||||
className="justify-between"
|
className="justify-between"
|
||||||
focusMode={
|
focusMode={selectedModules.length !== 0 && !showSolutions && moduleIndex < selectedModules.length}
|
||||||
selectedModules.length !== 0 &&
|
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}>
|
||||||
!showSolutions &&
|
|
||||||
moduleIndex < selectedModules.length
|
|
||||||
}
|
|
||||||
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}
|
|
||||||
>
|
|
||||||
<>
|
<>
|
||||||
{renderScreen()}
|
{renderScreen()}
|
||||||
{!showSolutions && moduleIndex < selectedModules.length && (
|
{!showSolutions && moduleIndex < selectedModules.length && (
|
||||||
@@ -429,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)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,23 +1,30 @@
|
|||||||
import {LevelExam, MultipleChoiceExercise} from "@/interfaces/exam";
|
import Select from "@/components/Low/Select";
|
||||||
|
import {Difficulty, LevelExam, MultipleChoiceExercise} 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";
|
||||||
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} from "lodash";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
import {BsArrowRepeat} from "react-icons/bs";
|
import {BsArrowRepeat} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import {v4} from "uuid";
|
import {v4} from "uuid";
|
||||||
|
|
||||||
const TaskTab = ({exam, setExam}: {exam?: LevelExam; setExam: (exam: LevelExam) => void}) => {
|
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
||||||
|
|
||||||
|
const TaskTab = ({exam, difficulty, setExam}: {exam?: LevelExam; difficulty: Difficulty; setExam: (exam: LevelExam) => void}) => {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const generate = () => {
|
const generate = () => {
|
||||||
|
const url = new URLSearchParams();
|
||||||
|
url.append("difficulty", difficulty);
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.get(`/api/exam/level/generate/level`)
|
.get(`/api/exam/level/generate/level?${url.toString()}`)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
playSound(typeof result.data === "string" ? "error" : "check");
|
playSound(typeof result.data === "string" ? "error" : "check");
|
||||||
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
||||||
@@ -107,6 +114,7 @@ const LevelGeneration = () => {
|
|||||||
const [generatedExam, setGeneratedExam] = useState<LevelExam>();
|
const [generatedExam, setGeneratedExam] = useState<LevelExam>();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [resultingExam, setResultingExam] = useState<LevelExam>();
|
const [resultingExam, setResultingExam] = useState<LevelExam>();
|
||||||
|
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -163,6 +171,16 @@ const LevelGeneration = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className="flex gap-4 w-1/2">
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||||
|
<Select
|
||||||
|
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
|
||||||
|
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
|
||||||
|
value={{value: difficulty, label: capitalize(difficulty)}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Tab.Group>
|
<Tab.Group>
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1">
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1">
|
||||||
<Tab
|
<Tab
|
||||||
@@ -178,7 +196,7 @@ const LevelGeneration = () => {
|
|||||||
</Tab>
|
</Tab>
|
||||||
</Tab.List>
|
</Tab.List>
|
||||||
<Tab.Panels>
|
<Tab.Panels>
|
||||||
<TaskTab exam={generatedExam} setExam={setGeneratedExam} />
|
<TaskTab difficulty={difficulty} exam={generatedExam} setExam={setGeneratedExam} />
|
||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
<div className="w-full flex justify-end gap-4">
|
<div className="w-full flex justify-end gap-4">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import {Exercise, ListeningExam} from "@/interfaces/exam";
|
import Select from "@/components/Low/Select";
|
||||||
|
import {Difficulty, Exercise, ListeningExam} from "@/interfaces/exam";
|
||||||
import useExamStore from "@/stores/examStore";
|
import 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,17 +8,34 @@ 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} from "lodash";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {BsArrowRepeat, BsCheck} 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 DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
||||||
|
|
||||||
|
const PartTab = ({
|
||||||
|
part,
|
||||||
|
types,
|
||||||
|
difficulty,
|
||||||
|
index,
|
||||||
|
setPart,
|
||||||
|
}: {
|
||||||
|
part?: ListeningPart;
|
||||||
|
difficulty: Difficulty;
|
||||||
|
types: string[];
|
||||||
|
index: number;
|
||||||
|
setPart: (part?: ListeningPart) => void;
|
||||||
|
}) => {
|
||||||
const [topic, setTopic] = useState("");
|
const [topic, setTopic] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const generate = () => {
|
const generate = () => {
|
||||||
const url = new URLSearchParams();
|
const url = new URLSearchParams();
|
||||||
|
url.append("difficulty", difficulty);
|
||||||
|
|
||||||
if (topic) url.append("topic", topic);
|
if (topic) url.append("topic", topic);
|
||||||
if (types) types.forEach((t) => url.append("exercises", t));
|
if (types) types.forEach((t) => url.append("exercises", t));
|
||||||
|
|
||||||
@@ -115,6 +133,7 @@ const ListeningGeneration = () => {
|
|||||||
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[]>([]);
|
||||||
|
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const part1Timer = part1 ? 5 : 0;
|
const part1Timer = part1 ? 5 : 0;
|
||||||
@@ -148,7 +167,7 @@ const ListeningGeneration = () => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post(`/api/exam/listening/generate/listening`, {parts, minTimer})
|
.post(`/api/exam/listening/generate/listening`, {parts, minTimer, difficulty})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
playSound("sent");
|
playSound("sent");
|
||||||
console.log(`Generated Exam ID: ${result.data.id}`);
|
console.log(`Generated Exam ID: ${result.data.id}`);
|
||||||
@@ -159,6 +178,7 @@ const ListeningGeneration = () => {
|
|||||||
setPart2(undefined);
|
setPart2(undefined);
|
||||||
setPart3(undefined);
|
setPart3(undefined);
|
||||||
setPart4(undefined);
|
setPart4(undefined);
|
||||||
|
setDifficulty(sample(DIFFICULTIES)!);
|
||||||
setTypes([]);
|
setTypes([]);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -186,6 +206,7 @@ const ListeningGeneration = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className="flex gap-4 w-1/2">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -196,6 +217,16 @@ const ListeningGeneration = () => {
|
|||||||
className="max-w-[300px]"
|
className="max-w-[300px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||||
|
<Select
|
||||||
|
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
|
||||||
|
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
|
||||||
|
value={{value: difficulty, label: capitalize(difficulty)}}
|
||||||
|
disabled={!!part1 || !!part2 || !!part3 || !!part4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<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>
|
||||||
@@ -271,7 +302,7 @@ const ListeningGeneration = () => {
|
|||||||
{part: part3, setPart: setPart3},
|
{part: part3, setPart: setPart3},
|
||||||
{part: part4, setPart: setPart4},
|
{part: part4, setPart: setPart4},
|
||||||
].map(({part, setPart}, index) => (
|
].map(({part, setPart}, index) => (
|
||||||
<PartTab part={part} types={types} index={index + 1} key={index} setPart={setPart} />
|
<PartTab part={part} difficulty={difficulty} types={types} index={index + 1} key={index} setPart={setPart} />
|
||||||
))}
|
))}
|
||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import {ReadingExam, ReadingPart} from "@/interfaces/exam";
|
import Select from "@/components/Low/Select";
|
||||||
|
import {Difficulty, ReadingExam, ReadingPart} from "@/interfaces/exam";
|
||||||
import useExamStore from "@/stores/examStore";
|
import 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,18 +8,35 @@ 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} from "lodash";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {BsArrowRepeat, BsCheck} 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";
|
||||||
|
|
||||||
const PartTab = ({part, types, index, setPart}: {part?: ReadingPart; types: string[]; index: number; setPart: (part?: ReadingPart) => void}) => {
|
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
||||||
|
|
||||||
|
const PartTab = ({
|
||||||
|
part,
|
||||||
|
types,
|
||||||
|
difficulty,
|
||||||
|
index,
|
||||||
|
setPart,
|
||||||
|
}: {
|
||||||
|
part?: ReadingPart;
|
||||||
|
types: string[];
|
||||||
|
index: number;
|
||||||
|
difficulty: Difficulty;
|
||||||
|
setPart: (part?: ReadingPart) => void;
|
||||||
|
}) => {
|
||||||
const [topic, setTopic] = useState("");
|
const [topic, setTopic] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const generate = () => {
|
const generate = () => {
|
||||||
const url = new URLSearchParams();
|
const url = new URLSearchParams();
|
||||||
|
url.append("difficulty", difficulty);
|
||||||
|
|
||||||
if (topic) url.append("topic", topic);
|
if (topic) url.append("topic", topic);
|
||||||
if (types) types.forEach((t) => url.append("exercises", t));
|
if (types) types.forEach((t) => url.append("exercises", t));
|
||||||
|
|
||||||
@@ -92,6 +110,7 @@ const ReadingGeneration = () => {
|
|||||||
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>();
|
||||||
|
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const parts = [part1, part2, part3].filter((x) => !!x);
|
const parts = [part1, part2, part3].filter((x) => !!x);
|
||||||
@@ -144,6 +163,7 @@ const ReadingGeneration = () => {
|
|||||||
id: v4(),
|
id: v4(),
|
||||||
type: "academic",
|
type: "academic",
|
||||||
variant: parts.length === 3 ? "full" : "partial",
|
variant: parts.length === 3 ? "full" : "partial",
|
||||||
|
difficulty,
|
||||||
};
|
};
|
||||||
|
|
||||||
axios
|
axios
|
||||||
@@ -157,6 +177,7 @@ const ReadingGeneration = () => {
|
|||||||
setPart1(undefined);
|
setPart1(undefined);
|
||||||
setPart2(undefined);
|
setPart2(undefined);
|
||||||
setPart3(undefined);
|
setPart3(undefined);
|
||||||
|
setDifficulty(sample(DIFFICULTIES)!);
|
||||||
setMinTimer(60);
|
setMinTimer(60);
|
||||||
setTypes([]);
|
setTypes([]);
|
||||||
})
|
})
|
||||||
@@ -169,6 +190,7 @@ const ReadingGeneration = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className="flex gap-4 w-1/2">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -179,6 +201,16 @@ const ReadingGeneration = () => {
|
|||||||
className="max-w-[300px]"
|
className="max-w-[300px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||||
|
<Select
|
||||||
|
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
|
||||||
|
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
|
||||||
|
value={{value: difficulty, label: capitalize(difficulty)}}
|
||||||
|
disabled={!!part1 || !!part2 || !!part3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<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>
|
||||||
@@ -240,7 +272,7 @@ const ReadingGeneration = () => {
|
|||||||
{part: part2, setPart: setPart2},
|
{part: part2, setPart: setPart2},
|
||||||
{part: part3, setPart: setPart3},
|
{part: part3, setPart: setPart3},
|
||||||
].map(({part, setPart}, index) => (
|
].map(({part, setPart}, index) => (
|
||||||
<PartTab part={part} types={types} index={index + 1} key={index} setPart={setPart} />
|
<PartTab part={part} types={types} difficulty={difficulty} index={index + 1} key={index} setPart={setPart} />
|
||||||
))}
|
))}
|
||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import {Exercise, InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise} from "@/interfaces/exam";
|
import Select from "@/components/Low/Select";
|
||||||
|
import {Difficulty, Exercise, InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise} from "@/interfaces/exam";
|
||||||
|
import {AVATARS} from "@/resources/speakingAvatars";
|
||||||
import useExamStore from "@/stores/examStore";
|
import 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,6 +9,7 @@ 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 moment from "moment";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
@@ -14,15 +17,31 @@ 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";
|
||||||
|
|
||||||
const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; setPart: (part?: SpeakingPart) => void}) => {
|
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
||||||
|
|
||||||
|
const PartTab = ({
|
||||||
|
part,
|
||||||
|
index,
|
||||||
|
difficulty,
|
||||||
|
setPart,
|
||||||
|
}: {
|
||||||
|
part?: SpeakingPart;
|
||||||
|
difficulty: Difficulty;
|
||||||
|
index: number;
|
||||||
|
setPart: (part?: SpeakingPart) => void;
|
||||||
|
}) => {
|
||||||
|
const [gender, setGender] = useState<"male" | "female">("male");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const generate = () => {
|
const generate = () => {
|
||||||
setPart(undefined);
|
setPart(undefined);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const url = new URLSearchParams();
|
||||||
|
url.append("difficulty", difficulty);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.get(`/api/exam/speaking/generate/speaking_task_${index}`)
|
.get(`/api/exam/speaking/generate/speaking_task_${index}?${url.toString()}`)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
playSound(typeof result.data === "string" ? "error" : "check");
|
playSound(typeof result.data === "string" ? "error" : "check");
|
||||||
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
||||||
@@ -39,17 +58,19 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
|
|||||||
if (!part) return toast.error("Please generate the first part before generating the video!");
|
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.");
|
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);
|
setIsLoading(true);
|
||||||
const initialTime = moment();
|
const initialTime = moment();
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post(`/api/exam/speaking/generate/speaking/generate_${index === 3 ? "interactive" : "speaking"}_video`, part)
|
.post(`/api/exam/speaking/generate/speaking/generate_${index === 3 ? "interactive" : "speaking"}_video`, {...part, avatar: avatar?.id})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
const isError = typeof result.data === "string" || moment().diff(initialTime, "seconds") < 60;
|
const isError = typeof result.data === "string" || moment().diff(initialTime, "seconds") < 60;
|
||||||
|
|
||||||
playSound(isError ? "error" : "check");
|
playSound(isError ? "error" : "check");
|
||||||
if (isError) return toast.error("Something went wrong, please try to generate the video again.");
|
if (isError) return toast.error("Something went wrong, please try to generate the video again.");
|
||||||
setPart({...part, result: result.data});
|
setPart({...part, result: result.data, gender, avatar});
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast.error("Something went wrong!");
|
toast.error("Something went wrong!");
|
||||||
@@ -60,6 +81,18 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
|
|||||||
|
|
||||||
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}
|
||||||
@@ -128,6 +161,11 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{part.result && <span className="font-bold mt-4">Video Generated: ✅</span>}
|
{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>
|
||||||
@@ -140,6 +178,8 @@ interface SpeakingPart {
|
|||||||
questions?: string[];
|
questions?: string[];
|
||||||
topic: string;
|
topic: string;
|
||||||
result?: SpeakingExercise | InteractiveSpeakingExercise;
|
result?: SpeakingExercise | InteractiveSpeakingExercise;
|
||||||
|
gender?: "male" | "female";
|
||||||
|
avatar?: (typeof AVATARS)[number];
|
||||||
}
|
}
|
||||||
|
|
||||||
const SpeakingGeneration = () => {
|
const SpeakingGeneration = () => {
|
||||||
@@ -149,6 +189,7 @@ const SpeakingGeneration = () => {
|
|||||||
const [minTimer, setMinTimer] = useState(14);
|
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>();
|
||||||
|
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const parts = [part1, part2, part3].filter((x) => !!x);
|
const parts = [part1, part2, part3].filter((x) => !!x);
|
||||||
@@ -165,6 +206,8 @@ const SpeakingGeneration = () => {
|
|||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const genders = [part1?.gender, part2?.gender, part3?.gender].filter((x) => !!x);
|
||||||
|
|
||||||
const exam: SpeakingExam = {
|
const exam: SpeakingExam = {
|
||||||
id: v4(),
|
id: v4(),
|
||||||
isDiagnostic: false,
|
isDiagnostic: false,
|
||||||
@@ -172,6 +215,7 @@ const SpeakingGeneration = () => {
|
|||||||
minTimer,
|
minTimer,
|
||||||
variant: minTimer >= 14 ? "full" : "partial",
|
variant: minTimer >= 14 ? "full" : "partial",
|
||||||
module: "speaking",
|
module: "speaking",
|
||||||
|
instructorGender: genders.every((x) => x === "male") ? "male" : genders.every((x) => x === "female") ? "female" : "varied",
|
||||||
};
|
};
|
||||||
|
|
||||||
axios
|
axios
|
||||||
@@ -185,6 +229,7 @@ const SpeakingGeneration = () => {
|
|||||||
setPart1(undefined);
|
setPart1(undefined);
|
||||||
setPart2(undefined);
|
setPart2(undefined);
|
||||||
setPart3(undefined);
|
setPart3(undefined);
|
||||||
|
setDifficulty(sample(DIFFICULTIES)!);
|
||||||
setMinTimer(14);
|
setMinTimer(14);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -212,6 +257,7 @@ const SpeakingGeneration = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className="flex gap-4 w-1/2">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -222,6 +268,16 @@ const SpeakingGeneration = () => {
|
|||||||
className="max-w-[300px]"
|
className="max-w-[300px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||||
|
<Select
|
||||||
|
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
|
||||||
|
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
|
||||||
|
value={{value: difficulty, label: capitalize(difficulty)}}
|
||||||
|
disabled={!!part1 || !!part2 || !!part3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Tab.Group>
|
<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">
|
||||||
@@ -265,7 +321,7 @@ const SpeakingGeneration = () => {
|
|||||||
{part: part2, setPart: setPart2},
|
{part: part2, setPart: setPart2},
|
||||||
{part: part3, setPart: setPart3},
|
{part: part3, setPart: setPart3},
|
||||||
].map(({part, setPart}, index) => (
|
].map(({part, setPart}, index) => (
|
||||||
<PartTab part={part} index={index + 1} key={index} setPart={setPart} />
|
<PartTab difficulty={difficulty} part={part} index={index + 1} key={index} setPart={setPart} />
|
||||||
))}
|
))}
|
||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
|
|||||||
@@ -1,24 +1,32 @@
|
|||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import {WritingExam, WritingExercise} from "@/interfaces/exam";
|
import Select from "@/components/Low/Select";
|
||||||
|
import {Difficulty, WritingExam, WritingExercise} from "@/interfaces/exam";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {getExamById} from "@/utils/exams";
|
import {getExamById} from "@/utils/exams";
|
||||||
import {playSound} from "@/utils/sound";
|
import {playSound} from "@/utils/sound";
|
||||||
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} from "lodash";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {BsArrowRepeat, BsCheck} 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";
|
||||||
|
|
||||||
const TaskTab = ({task, index, setTask}: {task?: string; index: number; setTask: (task: string) => void}) => {
|
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
||||||
|
|
||||||
|
const TaskTab = ({task, index, difficulty, setTask}: {task?: string; difficulty: Difficulty; index: number; setTask: (task: string) => void}) => {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const generate = () => {
|
const generate = () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const url = new URLSearchParams();
|
||||||
|
url.append("difficulty", difficulty);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.get(`/api/exam/writing/generate/writing_task${index}_general`)
|
.get(`/api/exam/writing/generate/writing_task${index}_general?${url.toString()}`)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
playSound(typeof result.data === "string" ? "error" : "check");
|
playSound(typeof result.data === "string" ? "error" : "check");
|
||||||
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
||||||
@@ -72,6 +80,7 @@ const WritingGeneration = () => {
|
|||||||
const [minTimer, setMinTimer] = useState(60);
|
const [minTimer, setMinTimer] = useState(60);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [resultingExam, setResultingExam] = useState<WritingExam>();
|
const [resultingExam, setResultingExam] = useState<WritingExam>();
|
||||||
|
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const task1Timer = task1 ? 20 : 0;
|
const task1Timer = task1 ? 20 : 0;
|
||||||
@@ -144,6 +153,7 @@ const WritingGeneration = () => {
|
|||||||
exercises: [...(exercise1 ? [exercise1] : []), ...(exercise2 ? [exercise2] : [])],
|
exercises: [...(exercise1 ? [exercise1] : []), ...(exercise2 ? [exercise2] : [])],
|
||||||
id: v4(),
|
id: v4(),
|
||||||
variant: exercise1 && exercise2 ? "full" : "partial",
|
variant: exercise1 && exercise2 ? "full" : "partial",
|
||||||
|
difficulty,
|
||||||
};
|
};
|
||||||
|
|
||||||
axios
|
axios
|
||||||
@@ -156,6 +166,7 @@ const WritingGeneration = () => {
|
|||||||
|
|
||||||
setTask1(undefined);
|
setTask1(undefined);
|
||||||
setTask2(undefined);
|
setTask2(undefined);
|
||||||
|
setDifficulty(sample(DIFFICULTIES)!);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
@@ -166,6 +177,7 @@ const WritingGeneration = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className="flex gap-4 w-1/2">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -176,6 +188,16 @@ const WritingGeneration = () => {
|
|||||||
className="max-w-[300px]"
|
className="max-w-[300px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||||
|
<Select
|
||||||
|
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
|
||||||
|
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
|
||||||
|
value={{value: difficulty, label: capitalize(difficulty)}}
|
||||||
|
disabled={!!task1 || !!task2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Tab.Group>
|
<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">
|
||||||
@@ -207,7 +229,7 @@ const WritingGeneration = () => {
|
|||||||
{task: task1, setTask: setTask1},
|
{task: task1, setTask: setTask1},
|
||||||
{task: task2, setTask: setTask2},
|
{task: task2, setTask: setTask2},
|
||||||
].map(({task, setTask}, index) => (
|
].map(({task, setTask}, index) => (
|
||||||
<TaskTab task={task} index={index + 1} key={index} setTask={setTask} />
|
<TaskTab difficulty={difficulty} task={task} index={index + 1} key={index} setTask={setTask} />
|
||||||
))}
|
))}
|
||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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";
|
||||||
|
import useAcceptedTerms from "@/hooks/useAcceptedTerms";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -40,6 +41,7 @@ export default function RegisterCorporate({
|
|||||||
const [companyName, setCompanyName] = useState("");
|
const [companyName, setCompanyName] = useState("");
|
||||||
const [companyUsers, setCompanyUsers] = useState(0);
|
const [companyUsers, setCompanyUsers] = useState(0);
|
||||||
const [subscriptionDuration, setSubscriptionDuration] = useState(1);
|
const [subscriptionDuration, setSubscriptionDuration] = useState(1);
|
||||||
|
const {acceptedTerms, renderCheckbox} = useAcceptedTerms();
|
||||||
|
|
||||||
const { users } = useUsers();
|
const { users } = useUsers();
|
||||||
|
|
||||||
@@ -257,7 +259,9 @@ export default function RegisterCorporate({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex w-full flex-col items-start gap-4">
|
||||||
|
{renderCheckbox()}
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="w-full lg:mt-8"
|
className="w-full lg:mt-8"
|
||||||
color="purple"
|
color="purple"
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ 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 { useState } from "react";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { KeyedMutator } from "swr";
|
import { KeyedMutator } from "swr";
|
||||||
|
import useAcceptedTerms from "@/hooks/useAcceptedTerms";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
queryCode?: string;
|
queryCode?: string;
|
||||||
@@ -35,6 +36,7 @@ export default function RegisterIndividual({
|
|||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [code, setCode] = useState(queryCode || "");
|
const [code, setCode] = useState(queryCode || "");
|
||||||
const [hasCode, setHasCode] = useState<boolean>(!!queryCode);
|
const [hasCode, setHasCode] = useState<boolean>(!!queryCode);
|
||||||
|
const {acceptedTerms, renderCheckbox} = useAcceptedTerms();
|
||||||
|
|
||||||
const onSuccess = () =>
|
const onSuccess = () =>
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -146,7 +148,9 @@ export default function RegisterIndividual({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex w-full flex-col items-start gap-4">
|
||||||
|
{renderCheckbox()}
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="w-full lg:mt-8"
|
className="w-full lg:mt-8"
|
||||||
color="purple"
|
color="purple"
|
||||||
@@ -156,6 +160,7 @@ export default function RegisterIndividual({
|
|||||||
!name ||
|
!name ||
|
||||||
!password ||
|
!password ||
|
||||||
!confirmPassword ||
|
!confirmPassword ||
|
||||||
|
!acceptedTerms ||
|
||||||
password !== confirmPassword ||
|
password !== confirmPassword ||
|
||||||
(hasCode ? !code : false)
|
(hasCode ? !code : false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -106,18 +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.pdf) {
|
if (data.pdf && data.pdf.path && data.pdf.version === process.env.PDF_VERSION) {
|
||||||
// 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;
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,13 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
import {app} from "@/firebase";
|
||||||
import {
|
import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc} from "firebase/firestore";
|
||||||
getFirestore,
|
|
||||||
collection,
|
|
||||||
getDocs,
|
|
||||||
query,
|
|
||||||
where,
|
|
||||||
setDoc,
|
|
||||||
doc,
|
|
||||||
getDoc,
|
|
||||||
} 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 {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, Variant } from "@/interfaces/exam";
|
import {Exam, InstructorGender, Variant} from "@/interfaces/exam";
|
||||||
import {capitalize, flatten, uniqBy} 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";
|
||||||
@@ -66,20 +57,14 @@ const generateExams = async (
|
|||||||
selectedModules: Module[],
|
selectedModules: Module[],
|
||||||
assignees: string[],
|
assignees: string[],
|
||||||
variant?: Variant,
|
variant?: Variant,
|
||||||
|
instructorGender?: InstructorGender,
|
||||||
): Promise<ExamWithUser[]> => {
|
): 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 = assignees.map(async (assignee) => {
|
const allExams = assignees.map(async (assignee) => {
|
||||||
const selectedModulePromises = selectedModules.map(
|
const selectedModulePromises = selectedModules.map(async (module: Module) => {
|
||||||
async (module: Module) => {
|
|
||||||
try {
|
try {
|
||||||
const exams: Exam[] = await getExams(
|
const exams: Exam[] = await getExams(db, module, "true", assignee, variant, instructorGender);
|
||||||
db,
|
|
||||||
module,
|
|
||||||
"true",
|
|
||||||
assignee,
|
|
||||||
variant,
|
|
||||||
);
|
|
||||||
|
|
||||||
const exam = exams[getRandomIndex(exams)];
|
const exam = exams[getRandomIndex(exams)];
|
||||||
if (exam) {
|
if (exam) {
|
||||||
@@ -90,22 +75,18 @@ const generateExams = async (
|
|||||||
console.error(e);
|
console.error(e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
}, []);
|
||||||
[],
|
|
||||||
);
|
|
||||||
const newModules = await Promise.all(selectedModulePromises);
|
const newModules = await Promise.all(selectedModulePromises);
|
||||||
|
|
||||||
return newModules;
|
return newModules;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const exams = flatten(await Promise.all(allExams)).filter(
|
const exams = flatten(await Promise.all(allExams)).filter((x) => x !== null) as ExamWithUser[];
|
||||||
(x) => x !== null,
|
|
||||||
) as ExamWithUser[];
|
|
||||||
return exams;
|
return exams;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedModulePromises = 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) {
|
||||||
@@ -116,11 +97,7 @@ const generateExams = async (
|
|||||||
|
|
||||||
const exams = await Promise.all(selectedModulePromises);
|
const exams = await Promise.all(selectedModulePromises);
|
||||||
const examesFiltered = exams.filter((x) => x !== null) as ExamWithUser[];
|
const examesFiltered = exams.filter((x) => x !== null) as ExamWithUser[];
|
||||||
return flatten(
|
return flatten(assignees.map((assignee) => examesFiltered.map((exam) => ({...exam, assignee}))));
|
||||||
assignees.map((assignee) =>
|
|
||||||
examesFiltered.map((exam) => ({ ...exam, assignee })),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function POST(req: NextApiRequest, res: NextApiResponse) {
|
async function POST(req: NextApiRequest, res: NextApiResponse) {
|
||||||
@@ -131,6 +108,7 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
// false would generate the same exam for all users
|
// false would generate the same exam for all users
|
||||||
generateMultiple = false,
|
generateMultiple = false,
|
||||||
variant,
|
variant,
|
||||||
|
instructorGender,
|
||||||
...body
|
...body
|
||||||
} = req.body as {
|
} = req.body as {
|
||||||
selectedModules: Module[];
|
selectedModules: Module[];
|
||||||
@@ -140,19 +118,13 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
|
instructorGender?: InstructorGender;
|
||||||
};
|
};
|
||||||
|
|
||||||
const exams: ExamWithUser[] = await generateExams(
|
const exams: ExamWithUser[] = await generateExams(generateMultiple, selectedModules, assignees, variant, instructorGender);
|
||||||
generateMultiple,
|
|
||||||
selectedModules,
|
|
||||||
assignees,
|
|
||||||
variant,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (exams.length === 0) {
|
if (exams.length === 0) {
|
||||||
res
|
res.status(400).json({ok: false, error: "No exams found for the selected modules"});
|
||||||
.status(400)
|
|
||||||
.json({ ok: false, error: "No exams found for the selected modules" });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +133,7 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
assignees,
|
assignees,
|
||||||
results: [],
|
results: [],
|
||||||
exams,
|
exams,
|
||||||
|
instructorGender,
|
||||||
...body,
|
...body,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {getFirestore, collection, getDocs, query, where} from "firebase/firestor
|
|||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {shuffle} from "lodash";
|
import {shuffle} from "lodash";
|
||||||
import {Exam} from "@/interfaces/exam";
|
import {Difficulty, Exam} from "@/interfaces/exam";
|
||||||
import {Stat} from "@/interfaces/user";
|
import {Stat} from "@/interfaces/user";
|
||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -25,10 +25,21 @@ async function get(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, difficulty} = req.query as {
|
||||||
|
module: Module;
|
||||||
|
endpoint: string;
|
||||||
|
topic?: string;
|
||||||
|
exercises?: string[];
|
||||||
|
difficulty?: Difficulty;
|
||||||
|
};
|
||||||
const url = `${process.env.BACKEND_URL}/${endpoint}`;
|
const url = `${process.env.BACKEND_URL}/${endpoint}`;
|
||||||
|
|
||||||
const result = await axios.get(`${url}${topic && exercises ? `?topic=${topic.toLowerCase()}&exercises=${exercises.join("&exercises=")}` : ""}`, {
|
const params = new URLSearchParams();
|
||||||
|
if (topic) params.append("topic", topic);
|
||||||
|
if (exercises) exercises.forEach((exercise) => params.append("exercises", exercise));
|
||||||
|
if (difficulty) params.append("difficulty", difficulty);
|
||||||
|
|
||||||
|
const result = await axios.get(`${url}${params.toString().length > 0 ? `?${params.toString()}` : ""}`, {
|
||||||
headers: {Authorization: `Bearer ${process.env.BACKEND_JWT}`},
|
headers: {Authorization: `Bearer ${process.env.BACKEND_JWT}`},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ 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, Variant} from "@/interfaces/exam";
|
import {Exam, InstructorGender, Variant} from "@/interfaces/exam";
|
||||||
import {getExams} from "@/utils/exams.be";
|
import {getExams} from "@/utils/exams.be";
|
||||||
|
import {Module} from "@/interfaces";
|
||||||
const db = getFirestore(app);
|
const db = getFirestore(app);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
@@ -23,9 +24,14 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {module, avoidRepeated, variant} = req.query as {module: string; avoidRepeated: string; variant?: Variant};
|
const {module, avoidRepeated, variant, instructorGender} = req.query as {
|
||||||
|
module: Module;
|
||||||
|
avoidRepeated: string;
|
||||||
|
variant?: Variant;
|
||||||
|
instructorGender?: InstructorGender;
|
||||||
|
};
|
||||||
|
|
||||||
const exams: Exam[] = await getExams(db, module, avoidRepeated, req.session.user.id, variant);
|
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,22 +1,12 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
import {app} from "@/firebase";
|
||||||
import {
|
import {getFirestore, getDoc, doc, deleteDoc, setDoc, getDocs, collection, where, query} from "firebase/firestore";
|
||||||
getFirestore,
|
|
||||||
getDoc,
|
|
||||||
doc,
|
|
||||||
deleteDoc,
|
|
||||||
setDoc,
|
|
||||||
getDocs,
|
|
||||||
collection,
|
|
||||||
where,
|
|
||||||
query,
|
|
||||||
} from "firebase/firestore";
|
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {Ticket} from "@/interfaces/ticket";
|
import {Ticket} from "@/interfaces/ticket";
|
||||||
import {Invite} from "@/interfaces/invite";
|
import {Invite} from "@/interfaces/invite";
|
||||||
import { Group, User } from "@/interfaces/user";
|
import {CorporateUser, Group, User} from "@/interfaces/user";
|
||||||
import {v4} from "uuid";
|
import {v4} from "uuid";
|
||||||
import {sendEmail} from "@/email";
|
import {sendEmail} from "@/email";
|
||||||
import {updateExpiryDateOnGroup} from "@/utils/groups.be";
|
import {updateExpiryDateOnGroup} from "@/utils/groups.be";
|
||||||
@@ -31,6 +21,67 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
res.status(404).json(undefined);
|
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) {
|
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});
|
||||||
@@ -42,8 +93,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
if (snapshot.exists()) {
|
if (snapshot.exists()) {
|
||||||
const invite = {...snapshot.data(), id: snapshot.id} as Invite;
|
const invite = {...snapshot.data(), id: snapshot.id} as Invite;
|
||||||
if (invite.to !== req.session.user.id)
|
if (invite.to !== req.session.user.id) return res.status(403).json({ok: false});
|
||||||
return res.status(403).json({ ok: false });
|
|
||||||
|
|
||||||
await deleteDoc(snapshot.ref);
|
await deleteDoc(snapshot.ref);
|
||||||
const invitedByRef = await getDoc(doc(db, "users", invite.from));
|
const invitedByRef = await getDoc(doc(db, "users", invite.from));
|
||||||
@@ -52,68 +102,9 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
await updateExpiryDateOnGroup(invite.to, invite.from);
|
await updateExpiryDateOnGroup(invite.to, invite.from);
|
||||||
|
|
||||||
const invitedBy = {...invitedByRef.data(), id: invitedByRef.id} as User;
|
const invitedBy = {...invitedByRef.data(), id: invitedByRef.id} as 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 =
|
if (invitedBy.type === "corporate") await deleteFromPreviousCorporateGroups(req.session.user, invitedBy);
|
||||||
req.session.user.type === "student"
|
await addToInviterGroup(req.session.user, invitedBy);
|
||||||
? "Students"
|
|
||||||
: req.session.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 !== req.session.user!.id),
|
|
||||||
req.session.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 !== req.session.user!.id,
|
|
||||||
),
|
|
||||||
req.session.user.id,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
merge: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendEmail(
|
await sendEmail(
|
||||||
|
|||||||
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});
|
||||||
|
}
|
||||||
@@ -125,7 +125,7 @@ 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
|
// find the user that generated the stats
|
||||||
const statIndex = stats.findIndex((s) => s.user);
|
const statIndex = stats.findIndex((s) => s.user);
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
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);
|
||||||
@@ -305,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);
|
||||||
@@ -336,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,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -10,7 +10,9 @@ import {
|
|||||||
} from "firebase/firestore";
|
} from "firebase/firestore";
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import { Ticket } from "@/interfaces/ticket";
|
import { Ticket, TicketTypeLabel, TicketStatusLabel } from "@/interfaces/ticket";
|
||||||
|
import moment from "moment";
|
||||||
|
import { sendEmail } from "@/email";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = getFirestore(app);
|
||||||
|
|
||||||
@@ -69,12 +71,38 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { id } = req.query as { id: string };
|
const { id } = req.query as { id: string };
|
||||||
|
const body = req.body as Ticket;
|
||||||
|
|
||||||
const snapshot = await getDoc(doc(db, "tickets", id));
|
const snapshot = await getDoc(doc(db, "tickets", id));
|
||||||
|
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
if (user.type === "admin" || user.type === "developer") {
|
if (user.type === "admin" || user.type === "developer") {
|
||||||
await setDoc(snapshot.ref, req.body, { merge: true });
|
const data = snapshot.data() as Ticket;
|
||||||
return res.status(200).json({ ok: true });
|
await setDoc(snapshot.ref, body, { merge: true });
|
||||||
|
try {
|
||||||
|
// send email if the status actually changed to completed
|
||||||
|
if(data.status !== req.body.status && req.body.status === 'completed') {
|
||||||
|
await sendEmail(
|
||||||
|
"ticketStatusCompleted",
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
[data.reporter.email],
|
||||||
|
`Ticket ${id}: ${data.subject}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch(err) {
|
||||||
|
console.error(err);
|
||||||
|
// doesnt matter if the email fails
|
||||||
|
}
|
||||||
|
res.status(200).json({ ok: true });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(403).json({ ok: false });
|
res.status(403).json({ ok: false });
|
||||||
|
|||||||
@@ -20,13 +20,25 @@ const db = getFirestore(app);
|
|||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
// due to integration with the homepage the POST request should be public
|
||||||
|
if (req.method === "POST") {
|
||||||
|
await post(req, res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// specific logic for the preflight request
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
res.status(200).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ ok: false });
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "GET") await get(req, res);
|
if (req.method === "GET") {
|
||||||
if (req.method === "POST") await post(req, res);
|
await get(req, res);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
@@ -36,7 +48,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
snapshot.docs.map((doc) => ({
|
snapshot.docs.map((doc) => ({
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
...doc.data(),
|
...doc.data(),
|
||||||
})),
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +73,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
description: body.description,
|
description: body.description,
|
||||||
},
|
},
|
||||||
[body.reporter.email],
|
[body.reporter.email],
|
||||||
`Ticket ${id}: ${body.subject}`,
|
`Ticket ${id}: ${body.subject}`
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.log(e);
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ import TimezoneSelect from "@/components/Low/TImezoneSelect";
|
|||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import ModuleLevelSelector from "@/components/Medium/ModuleLevelSelector";
|
import ModuleLevelSelector from "@/components/Medium/ModuleLevelSelector";
|
||||||
|
import Select from "@/components/Low/Select";
|
||||||
|
import {InstructorGender} from "@/interfaces/exam";
|
||||||
|
import {capitalize} from "lodash";
|
||||||
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
|
|
||||||
@@ -83,6 +86,11 @@ function UserProfile({user, mutateUser}: Props) {
|
|||||||
user.type === "corporate" ? undefined : user.demographicInformation?.employment,
|
user.type === "corporate" ? undefined : user.demographicInformation?.employment,
|
||||||
);
|
);
|
||||||
const [passport_id, setPassportID] = useState<string | undefined>(user.type === "student" ? user.demographicInformation?.passport_id : undefined);
|
const [passport_id, setPassportID] = useState<string | undefined>(user.type === "student" ? user.demographicInformation?.passport_id : undefined);
|
||||||
|
|
||||||
|
const [preferredGender, setPreferredGender] = useState<InstructorGender | undefined>(
|
||||||
|
user.type === "student" || user.type === "developer" ? user.preferredGender || "varied" : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
const [position, setPosition] = useState<string | undefined>(user.type === "corporate" ? user.demographicInformation?.position : undefined);
|
const [position, setPosition] = useState<string | undefined>(user.type === "corporate" ? user.demographicInformation?.position : undefined);
|
||||||
const [corporateInformation, setCorporateInformation] = useState(user.type === "corporate" ? user.corporateInformation : undefined);
|
const [corporateInformation, setCorporateInformation] = useState(user.type === "corporate" ? user.corporateInformation : undefined);
|
||||||
const [companyName, setCompanyName] = useState<string | undefined>(user.type === "agent" ? user.agentInformation?.companyName : undefined);
|
const [companyName, setCompanyName] = useState<string | undefined>(user.type === "agent" ? user.agentInformation?.companyName : undefined);
|
||||||
@@ -90,6 +98,7 @@ function UserProfile({user, mutateUser}: Props) {
|
|||||||
user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined,
|
user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined,
|
||||||
);
|
);
|
||||||
const [timezone, setTimezone] = useState<string>(user.demographicInformation?.timezone || moment.tz.guess());
|
const [timezone, setTimezone] = useState<string>(user.demographicInformation?.timezone || moment.tz.guess());
|
||||||
|
|
||||||
const {groups} = useGroups();
|
const {groups} = useGroups();
|
||||||
const {users} = useUsers();
|
const {users} = useUsers();
|
||||||
|
|
||||||
@@ -146,6 +155,7 @@ function UserProfile({user, mutateUser}: Props) {
|
|||||||
newPassword,
|
newPassword,
|
||||||
profilePicture,
|
profilePicture,
|
||||||
desiredLevels,
|
desiredLevels,
|
||||||
|
preferredGender,
|
||||||
demographicInformation: {
|
demographicInformation: {
|
||||||
phone,
|
phone,
|
||||||
country,
|
country,
|
||||||
@@ -337,6 +347,24 @@ function UserProfile({user, mutateUser}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{preferredGender && ["developer", "student"].includes(user.type) && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<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: preferredGender, label: capitalize(preferredGender)}}
|
||||||
|
onChange={(value) => (value ? setPreferredGender(value.value as InstructorGender) : null)}
|
||||||
|
options={[
|
||||||
|
{value: "male", label: "Male"},
|
||||||
|
{value: "female", label: "Female"},
|
||||||
|
{value: "varied", label: "Varied"},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{user.type === "corporate" && (
|
{user.type === "corporate" && (
|
||||||
|
|||||||
@@ -178,7 +178,10 @@ export default function History({user}: {user: User}) {
|
|||||||
const {timeSpent, session} = dateStats[0];
|
const {timeSpent, session} = dateStats[0];
|
||||||
|
|
||||||
const selectExam = () => {
|
const selectExam = () => {
|
||||||
const examPromises = uniqBy(dateStats, "exam").map((stat) => getExamById(stat.module, stat.exam));
|
const examPromises = uniqBy(dateStats, "exam").map((stat) => {
|
||||||
|
console.log({stat});
|
||||||
|
return 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)) {
|
||||||
|
|||||||
37
src/resources/speakingAvatars.ts
Normal file
37
src/resources/speakingAvatars.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export const AVATARS = [
|
||||||
|
{
|
||||||
|
name: "Matthew Noah",
|
||||||
|
id: "5912afa7c77c47d3883af3d874047aaf",
|
||||||
|
gender: "male",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Vera Cerise",
|
||||||
|
id: "9e58d96a383e4568a7f1e49df549e0e4",
|
||||||
|
gender: "female",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Edward Tony",
|
||||||
|
id: "d2cdd9c0379a4d06ae2afb6e5039bd0c",
|
||||||
|
gender: "male",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Tanya Molly",
|
||||||
|
id: "045cb5dcd00042b3a1e4f3bc1c12176b",
|
||||||
|
gender: "female",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Kayla Abbi",
|
||||||
|
id: "1ae1e5396cc444bfad332155fdb7a934",
|
||||||
|
gender: "female",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Jerome Ryan",
|
||||||
|
id: "0ee6aa7cc1084063a630ae514fccaa31",
|
||||||
|
gender: "male",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Tyler Christopher",
|
||||||
|
id: "5772cff935844516ad7eeff21f839e43",
|
||||||
|
gender: "male",
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -10,32 +10,65 @@ export interface ExamState {
|
|||||||
hasExamEnded: boolean;
|
hasExamEnded: boolean;
|
||||||
selectedModules: Module[];
|
selectedModules: Module[];
|
||||||
assignment?: Assignment;
|
assignment?: Assignment;
|
||||||
setHasExamEnded: (hasExamEnded: boolean) => void;
|
timeSpent: number;
|
||||||
setUserSolutions: (userSolutions: UserSolution[]) => void;
|
sessionId: string;
|
||||||
|
moduleIndex: number;
|
||||||
|
exam?: Exam;
|
||||||
|
partIndex: number;
|
||||||
|
exerciseIndex: number;
|
||||||
|
questionIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamFunctions {
|
||||||
setExams: (exams: Exam[]) => void;
|
setExams: (exams: Exam[]) => void;
|
||||||
|
setUserSolutions: (userSolutions: UserSolution[]) => void;
|
||||||
setShowSolutions: (showSolutions: boolean) => void;
|
setShowSolutions: (showSolutions: boolean) => void;
|
||||||
|
setHasExamEnded: (hasExamEnded: boolean) => void;
|
||||||
setSelectedModules: (modules: Module[]) => void;
|
setSelectedModules: (modules: Module[]) => void;
|
||||||
setAssignment: (assignment: Assignment) => void;
|
setAssignment: (assignment?: Assignment) => void;
|
||||||
|
setTimeSpent: (timeSpent: number) => void;
|
||||||
|
setSessionId: (sessionId: string) => void;
|
||||||
|
setModuleIndex: (moduleIndex: number) => void;
|
||||||
|
setExam: (exam?: Exam) => void;
|
||||||
|
setPartIndex: (partIndex: number) => void;
|
||||||
|
setExerciseIndex: (exerciseIndex: number) => void;
|
||||||
|
setQuestionIndex: (questionIndex: number) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const initialState = {
|
export const initialState: ExamState = {
|
||||||
exams: [],
|
exams: [],
|
||||||
userSolutions: [],
|
userSolutions: [],
|
||||||
showSolutions: false,
|
showSolutions: false,
|
||||||
selectedModules: [],
|
selectedModules: [],
|
||||||
hasExamEnded: false,
|
hasExamEnded: false,
|
||||||
assignment: undefined,
|
assignment: undefined,
|
||||||
|
timeSpent: 0,
|
||||||
|
sessionId: "",
|
||||||
|
exam: undefined,
|
||||||
|
moduleIndex: 0,
|
||||||
|
partIndex: -1,
|
||||||
|
exerciseIndex: -1,
|
||||||
|
questionIndex: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const useExamStore = create<ExamState>((set) => ({
|
const useExamStore = create<ExamState & ExamFunctions>((set) => ({
|
||||||
...initialState,
|
...initialState,
|
||||||
|
|
||||||
setUserSolutions: (userSolutions: UserSolution[]) => set(() => ({userSolutions})),
|
setUserSolutions: (userSolutions: UserSolution[]) => set(() => ({userSolutions})),
|
||||||
setExams: (exams: Exam[]) => set(() => ({exams})),
|
setExams: (exams: Exam[]) => set(() => ({exams})),
|
||||||
setShowSolutions: (showSolutions: boolean) => set(() => ({showSolutions})),
|
setShowSolutions: (showSolutions: boolean) => set(() => ({showSolutions})),
|
||||||
setSelectedModules: (modules: Module[]) => set(() => ({selectedModules: modules})),
|
setSelectedModules: (modules: Module[]) => set(() => ({selectedModules: modules})),
|
||||||
setHasExamEnded: (hasExamEnded: boolean) => set(() => ({hasExamEnded})),
|
setHasExamEnded: (hasExamEnded: boolean) => set(() => ({hasExamEnded})),
|
||||||
setAssignment: (assignment: Assignment) => set(() => ({assignment})),
|
setAssignment: (assignment?: Assignment) => set(() => ({assignment})),
|
||||||
|
setTimeSpent: (timeSpent) => set(() => ({timeSpent})),
|
||||||
|
setSessionId: (sessionId: string) => set(() => ({sessionId})),
|
||||||
|
setExam: (exam?: Exam) => set(() => ({exam})),
|
||||||
|
setModuleIndex: (moduleIndex: number) => set(() => ({moduleIndex})),
|
||||||
|
setPartIndex: (partIndex: number) => set(() => ({partIndex})),
|
||||||
|
setExerciseIndex: (exerciseIndex: number) => set(() => ({exerciseIndex})),
|
||||||
|
setQuestionIndex: (questionIndex: number) => set(() => ({questionIndex})),
|
||||||
|
|
||||||
reset: () => set(() => initialState),
|
reset: () => set(() => initialState),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,15 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html {
|
||||||
|
min-height: 100vh !important;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 100vw;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: "Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
min-height: 100vh !important;
|
min-height: 100vh !important;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -45,16 +45,20 @@ export const evaluateSpeakingAnswer = async (exercise: SpeakingExercise | Intera
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadBlob = async (url: string): Promise<Buffer> => {
|
export const downloadBlob = async (url: string): Promise<Buffer> => {
|
||||||
const blobResponse = await axios.get(url, {responseType: "arraybuffer"});
|
const blobResponse = await axios.get(url, {responseType: "arraybuffer"});
|
||||||
return Buffer.from(blobResponse.data, "binary");
|
return Buffer.from(blobResponse.data, "binary");
|
||||||
};
|
};
|
||||||
|
|
||||||
const evaluateSpeakingExercise = async (exercise: SpeakingExercise, exerciseId: string, solution: UserSolution, id: string) => {
|
const evaluateSpeakingExercise = async (exercise: SpeakingExercise, exerciseId: string, solution: UserSolution, id: string) => {
|
||||||
const audioBlob = await downloadBlob(solution.solutions[0].solution.trim());
|
const formData = new FormData();
|
||||||
|
|
||||||
|
const url = solution.solutions[0].solution.trim() as string;
|
||||||
|
const audioBlob = await downloadBlob(url);
|
||||||
const audioFile = new File([audioBlob], "audio.wav", {type: "audio/wav"});
|
const audioFile = new File([audioBlob], "audio.wav", {type: "audio/wav"});
|
||||||
|
|
||||||
const formData = new FormData();
|
if (url && !url.startsWith("blob")) await axios.post("/api/storage/delete", {path: url});
|
||||||
|
|
||||||
formData.append("audio", audioFile, "audio.wav");
|
formData.append("audio", audioFile, "audio.wav");
|
||||||
|
|
||||||
const evaluationQuestion =
|
const evaluationQuestion =
|
||||||
@@ -64,7 +68,7 @@ const evaluateSpeakingExercise = async (exercise: SpeakingExercise, exerciseId:
|
|||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "audio/mp3",
|
"Content-Type": "audio/wav",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -86,10 +90,15 @@ const evaluateSpeakingExercise = async (exercise: SpeakingExercise, exerciseId:
|
|||||||
};
|
};
|
||||||
|
|
||||||
const evaluateInteractiveSpeakingExercise = async (exerciseId: string, solution: UserSolution, id: string) => {
|
const evaluateInteractiveSpeakingExercise = async (exerciseId: string, solution: UserSolution, id: string) => {
|
||||||
const promiseParts = solution.solutions.map(async (x: {prompt: string; blob: string}) => ({
|
const promiseParts = solution.solutions.map(async (x: {prompt: string; blob: string}) => {
|
||||||
|
const blob = await downloadBlob(x.blob);
|
||||||
|
if (!x.blob.startsWith("blob")) await axios.post("/api/storage/delete", {path: x.blob});
|
||||||
|
|
||||||
|
return {
|
||||||
question: x.prompt,
|
question: x.prompt,
|
||||||
answer: await downloadBlob(x.blob),
|
answer: blob,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
const body = await Promise.all(promiseParts);
|
const body = await Promise.all(promiseParts);
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -110,6 +119,7 @@ const evaluateInteractiveSpeakingExercise = async (exerciseId: string, solution:
|
|||||||
};
|
};
|
||||||
|
|
||||||
const response = await axios.post("/api/evaluate/interactiveSpeaking", formData, config);
|
const response = await axios.post("/api/evaluate/interactiveSpeaking", formData, config);
|
||||||
|
console.log({data: response.data, status: response.status});
|
||||||
|
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
return {
|
return {
|
||||||
@@ -119,6 +129,7 @@ const evaluateInteractiveSpeakingExercise = async (exerciseId: string, solution:
|
|||||||
missing: 0,
|
missing: 0,
|
||||||
total: 100,
|
total: 100,
|
||||||
},
|
},
|
||||||
|
module: "speaking",
|
||||||
solutions: [{id: exerciseId, solution: response.data ? response.data.answer : null, evaluation: response.data}],
|
solutions: [{id: exerciseId, solution: response.data ? response.data.answer : null, evaluation: response.data}],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,36 @@
|
|||||||
import {collection, getDocs, query, where, setDoc, doc, Firestore} from "firebase/firestore";
|
import {collection, getDocs, query, where, setDoc, doc, Firestore, getDoc} from "firebase/firestore";
|
||||||
import {shuffle} from "lodash";
|
import {shuffle} from "lodash";
|
||||||
import {Exam, Variant} from "@/interfaces/exam";
|
import {Difficulty, Exam, InstructorGender, Variant} from "@/interfaces/exam";
|
||||||
import {Stat} from "@/interfaces/user";
|
import {Stat, User} from "@/interfaces/user";
|
||||||
|
import {Module} from "@/interfaces";
|
||||||
|
|
||||||
export const getExams = async (
|
export const getExams = async (
|
||||||
db: Firestore,
|
db: Firestore,
|
||||||
module: string,
|
module: Module,
|
||||||
avoidRepeated: string,
|
avoidRepeated: string,
|
||||||
// added userId as due to assignments being set from the teacher to the student
|
// added userId as due to assignments being set from the teacher to the student
|
||||||
// we need to make sure we are serving exams not executed by the user and not
|
// we need to make sure we are serving exams not executed by the user and not
|
||||||
// by the teacher that performed the request
|
// by the teacher that performed the request
|
||||||
userId: string | undefined,
|
userId: string | undefined,
|
||||||
variant?: Variant,
|
variant?: Variant,
|
||||||
|
instructorGender?: InstructorGender,
|
||||||
): Promise<Exam[]> => {
|
): Promise<Exam[]> => {
|
||||||
const moduleRef = collection(db, module);
|
const moduleRef = collection(db, module);
|
||||||
|
|
||||||
const q = query(moduleRef, where("isDiagnostic", "==", false));
|
const q = query(moduleRef, where("isDiagnostic", "==", false));
|
||||||
const snapshot = await getDocs(q);
|
const snapshot = await getDocs(q);
|
||||||
|
|
||||||
const exams: Exam[] = filterByVariant(
|
const allExams = shuffle(
|
||||||
shuffle(
|
|
||||||
snapshot.docs.map((doc) => ({
|
snapshot.docs.map((doc) => ({
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
...doc.data(),
|
...doc.data(),
|
||||||
module,
|
module,
|
||||||
})),
|
})),
|
||||||
) as Exam[],
|
) as Exam[];
|
||||||
variant,
|
|
||||||
);
|
const variantExams: Exam[] = filterByVariant(allExams, variant);
|
||||||
|
const genderedExams: Exam[] = filterByInstructorGender(variantExams, instructorGender);
|
||||||
|
const difficultyExams: Exam[] = await filterByDifficulty(db, genderedExams, module, userId);
|
||||||
|
|
||||||
if (avoidRepeated === "true") {
|
if (avoidRepeated === "true") {
|
||||||
const statsQ = query(collection(db, "stats"), where("user", "==", userId));
|
const statsQ = query(collection(db, "stats"), where("user", "==", userId));
|
||||||
@@ -37,15 +40,33 @@ export const getExams = async (
|
|||||||
id: doc.id,
|
id: doc.id,
|
||||||
...doc.data(),
|
...doc.data(),
|
||||||
})) as unknown as Stat[];
|
})) as unknown as Stat[];
|
||||||
const filteredExams = exams.filter((x) => !stats.map((s) => s.exam).includes(x.id));
|
const filteredExams = difficultyExams.filter((x) => !stats.map((s) => s.exam).includes(x.id));
|
||||||
|
|
||||||
return filteredExams.length > 0 ? filteredExams : exams;
|
return filteredExams.length > 0 ? filteredExams : difficultyExams;
|
||||||
}
|
}
|
||||||
|
|
||||||
return exams;
|
return difficultyExams;
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterByInstructorGender = (exams: Exam[], instructorGender?: InstructorGender) => {
|
||||||
|
if (!instructorGender || instructorGender === "varied") return exams;
|
||||||
|
|
||||||
|
return exams.filter((e) => (e.module === "speaking" ? e.instructorGender === instructorGender : true));
|
||||||
};
|
};
|
||||||
|
|
||||||
const filterByVariant = (exams: Exam[], variant?: Variant) => {
|
const filterByVariant = (exams: Exam[], variant?: Variant) => {
|
||||||
const filtered = variant && variant === "partial" ? exams.filter((x) => x.variant === "partial") : exams.filter((x) => x.variant !== "partial");
|
const filtered = variant && variant === "partial" ? exams.filter((x) => x.variant === "partial") : exams.filter((x) => x.variant !== "partial");
|
||||||
return filtered.length > 0 ? filtered : exams;
|
return filtered.length > 0 ? filtered : exams;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filterByDifficulty = async (db: Firestore, exams: Exam[], module: Module, userID?: string) => {
|
||||||
|
if (!userID) return exams;
|
||||||
|
const userRef = await getDoc(doc(db, "users", userID));
|
||||||
|
if (!userRef.exists()) return exams;
|
||||||
|
|
||||||
|
const user = {...userRef.data(), id: userRef.id} as User;
|
||||||
|
const difficulty = user.levels[module] <= 3 ? "easy" : user.levels[module] <= 6 ? "medium" : "hard";
|
||||||
|
|
||||||
|
const filteredExams = exams.filter((exam) => exam.difficulty === difficulty);
|
||||||
|
return filteredExams.length === 0 ? exams : filteredExams;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,11 +1,29 @@
|
|||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import {Exam, ReadingExam, ListeningExam, WritingExam, SpeakingExam, Exercise, UserSolution, LevelExam, Variant} from "@/interfaces/exam";
|
import {
|
||||||
|
Exam,
|
||||||
|
ReadingExam,
|
||||||
|
ListeningExam,
|
||||||
|
WritingExam,
|
||||||
|
SpeakingExam,
|
||||||
|
Exercise,
|
||||||
|
UserSolution,
|
||||||
|
LevelExam,
|
||||||
|
Variant,
|
||||||
|
InstructorGender,
|
||||||
|
} from "@/interfaces/exam";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
export const getExam = async (module: Module, avoidRepeated: boolean, variant?: Variant): Promise<Exam | undefined> => {
|
export const getExam = async (
|
||||||
|
module: Module,
|
||||||
|
avoidRepeated: boolean,
|
||||||
|
variant?: Variant,
|
||||||
|
instructorGender?: InstructorGender,
|
||||||
|
): Promise<Exam | undefined> => {
|
||||||
const url = new URLSearchParams();
|
const url = new URLSearchParams();
|
||||||
url.append("avoidRepeated", avoidRepeated.toString());
|
url.append("avoidRepeated", avoidRepeated.toString());
|
||||||
|
|
||||||
if (variant) url.append("variant", variant);
|
if (variant) url.append("variant", variant);
|
||||||
|
if (module === "speaking" && instructorGender) url.append("instructorGender", instructorGender);
|
||||||
|
|
||||||
const examRequest = await axios<Exam[]>(`/api/exam/${module}?${url.toString()}`);
|
const examRequest = await axios<Exam[]>(`/api/exam/${module}?${url.toString()}`);
|
||||||
if (examRequest.status !== 200) {
|
if (examRequest.status !== 200) {
|
||||||
@@ -50,6 +68,13 @@ export const getExamById = async (module: Module, id: string): Promise<Exam | un
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const defaultExamUserSolutions = (exam: Exam) => {
|
||||||
|
if (exam.module === "reading" || exam.module === "listening")
|
||||||
|
return exam.parts.flatMap((x) => x.exercises).map((x) => defaultUserSolutions(x, exam));
|
||||||
|
|
||||||
|
return exam.exercises.map((x) => defaultUserSolutions(x, exam));
|
||||||
|
};
|
||||||
|
|
||||||
export const defaultUserSolutions = (exercise: Exercise, exam: Exam): UserSolution => {
|
export const defaultUserSolutions = (exercise: Exercise, exam: Exam): UserSolution => {
|
||||||
const defaultSettings = {
|
const defaultSettings = {
|
||||||
exam: exam.id,
|
exam: exam.id,
|
||||||
@@ -73,6 +98,9 @@ export const defaultUserSolutions = (exercise: Exercise, exam: Exam): UserSoluti
|
|||||||
case "writeBlanks":
|
case "writeBlanks":
|
||||||
total = exercise.text.match(/({{\d+}})/g)?.length || 0;
|
total = exercise.text.match(/({{\d+}})/g)?.length || 0;
|
||||||
return {...defaultSettings, score: {correct: 0, total, missing: total}};
|
return {...defaultSettings, score: {correct: 0, total, missing: total}};
|
||||||
|
case "trueFalse":
|
||||||
|
total = exercise.questions.length;
|
||||||
|
return {...defaultSettings, score: {correct: 0, total, missing: total}};
|
||||||
case "writing":
|
case "writing":
|
||||||
total = 1;
|
total = 1;
|
||||||
return {...defaultSettings, score: {correct: 0, total, missing: total}};
|
return {...defaultSettings, score: {correct: 0, total, missing: total}};
|
||||||
|
|||||||
Reference in New Issue
Block a user