Merged in develop (pull request #35)

Updated the main branch - 13/02/24
This commit is contained in:
Tiago Ribeiro
2024-02-13 00:52:45 +00:00
61 changed files with 2886 additions and 2081 deletions

View File

@@ -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",
},
],
},
]; ];
}, },
}; };

View File

@@ -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>

View File

@@ -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 (

View File

@@ -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>

View File

@@ -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;

View File

@@ -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}

View File

@@ -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}`,

View File

@@ -1,134 +1,101 @@
import { Ticket, TicketType, TicketTypeLabel } from "@/interfaces/ticket"; import {Ticket, TicketType, TicketTypeLabel} from "@/interfaces/ticket";
import { User } from "@/interfaces/user"; import {User} from "@/interfaces/user";
import axios from "axios"; import axios from "axios";
import { useState } from "react"; import {useState} from "react";
import { toast } from "react-toastify"; import {toast} from "react-toastify";
import ShortUniqueId from "short-unique-id"; import ShortUniqueId from "short-unique-id";
import Button from "../Low/Button"; import Button from "../Low/Button";
import Input from "../Low/Input"; import Input from "../Low/Input";
import Select from "../Low/Select"; import Select from "../Low/Select";
interface Props { interface Props {
user: User; user: User;
page: string; page: string;
onClose: () => void; onClose: () => void;
} }
export default function TicketSubmission({ user, page, onClose }: Props) { export default function TicketSubmission({user, page, onClose}: Props) {
const [subject, setSubject] = useState(""); const [subject, setSubject] = useState("");
const [type, setType] = useState<TicketType>(); const [type, setType] = useState<TicketType>();
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
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", });
}); if (description.trim() === "")
if (description.trim() === "") return toast.error("Please describe your ticket!", {
return toast.error("Please describe your ticket!", { toastId: "missing-desc",
toastId: "missing-desc", });
});
setIsLoading(true); setIsLoading(true);
const shortUID = new ShortUniqueId(); const shortUID = new ShortUniqueId();
const ticket: Ticket = { const ticket: Ticket = {
id: shortUID.randomUUID(8), id: shortUID.randomUUID(8),
date: new Date().toISOString(), date: new Date().toISOString(),
reporter: { reporter: {
id: user.id, id: user.id,
email: user.email, email: user.email,
name: user.name, name: user.name,
type: user.type, type: user.type,
}, },
status: "submitted", status: "submitted",
subject, subject,
type, type,
reportedFrom: page, reportedFrom: page,
description, description,
}; };
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.`, onClose();
{ toastId: "submitted" }, })
); .catch((e) => {
onClose(); console.error(e);
}) toast.error("Something went wrong, please try again later!", {
.catch((e) => { toastId: "error",
console.error(e); });
toast.error("Something went wrong, please try again later!", { })
toastId: "error", .finally(() => setIsLoading(false));
}); };
})
.finally(() => setIsLoading(false));
};
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" <div className="-md:flex-col flex w-full items-center gap-4">
type="text" <div className="flex w-full flex-col gap-3">
name="subject" <label className="text-mti-gray-dim text-base font-normal">Type</label>
placeholder="Subject..." <Select
onChange={(e) => setSubject(e)} options={Object.keys(TicketTypeLabel).map((x) => ({
/> value: x,
<div className="-md:flex-col flex w-full items-center gap-4"> label: TicketTypeLabel[x as keyof typeof TicketTypeLabel],
<div className="flex w-full flex-col gap-3"> }))}
<label className="text-mti-gray-dim text-base font-normal"> onChange={(value) => setType((value?.value as TicketType) ?? undefined)}
Type placeholder="Type..."
</label> />
<Select </div>
options={Object.keys(TicketTypeLabel).map((x) => ({ <Input label="Reporter" type="text" name="reporter" onChange={() => null} value={`${user.name} - ${user.email}`} disabled />
value: x, </div>
label: TicketTypeLabel[x as keyof typeof TicketTypeLabel], <textarea
}))} className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8"
onChange={(value) => onChange={(e) => setDescription(e.target.value)}
setType((value?.value as TicketType) ?? undefined) placeholder="Write your ticket's description here..."
} spellCheck
placeholder="Type..." />
/> <div className="mt-2 flex w-full items-center justify-end gap-4">
</div> <Button type="button" color="red" className="w-full max-w-[200px]" variant="outline" onClick={onClose} isLoading={isLoading}>
<Input Cancel
label="Reporter" </Button>
type="text" <Button type="button" className="w-full max-w-[200px]" isLoading={isLoading} onClick={submit}>
name="reporter" Submit
onChange={() => null} </Button>
value={`${user.name} - ${user.email}`} </div>
disabled </form>
/> );
</div>
<textarea
className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8"
onChange={(e) => setDescription(e.target.value)}
placeholder="Write your ticket's description here..."
spellCheck
/>
<div className="mt-2 flex w-full items-center justify-end gap-4">
<Button
type="button"
color="red"
className="w-full max-w-[200px]"
variant="outline"
onClick={onClose}
isLoading={isLoading}
>
Cancel
</Button>
<Button
type="button"
className="w-full max-w-[200px]"
isLoading={isLoading}
onClick={submit}
>
Submit
</Button>
</div>
</form>
);
} }

View File

@@ -1,68 +1,61 @@
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 {
[key: string]: any; [key: string]: any;
value: string; value: string;
label: string; label: string;
} }
interface Props { interface Props {
defaultValue?: Option; defaultValue?: Option;
value?: Option | null; value?: Option | null;
options: Option[]; options: Option[];
disabled?: boolean; disabled?: boolean;
placeholder?: string; placeholder?: string;
onChange: (value: Option | null) => void; onChange: (value: Option | null) => void;
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, return (
}: Props) { <ReactSelect
return ( className={clsx(
<ReactSelect "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",
className={clsx( disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
"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 && options={options}
"!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed", value={value}
)} onChange={onChange}
options={options} placeholder={placeholder}
value={value} menuPortalTarget={target}
onChange={onChange} defaultValue={defaultValue}
placeholder={placeholder} styles={{
menuPortalTarget={document?.body} menuPortal: (base) => ({...base, zIndex: 9999}),
defaultValue={defaultValue} control: (styles) => ({
styles={{ ...styles,
menuPortal: (base) => ({ ...base, zIndex: 9999 }), paddingLeft: "4px",
control: (styles) => ({ border: "none",
...styles, outline: "none",
paddingLeft: "4px", ":focus": {
border: "none", outline: "none",
outline: "none", },
":focus": { }),
outline: "none", option: (styles, state) => ({
}, ...styles,
}), backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
option: (styles, state) => ({ color: state.isFocused ? "black" : styles.color,
...styles, }),
backgroundColor: state.isFocused }}
? "#D5D9F0" isDisabled={disabled}
: state.isSelected isClearable={isClearable}
? "#7872BF" />
: "white", );
color: state.isFocused ? "black" : styles.color,
}),
}}
isDisabled={disabled}
isClearable={isClearable}
/>
);
} }

View 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>
);
}

View File

@@ -1,201 +1,160 @@
import { User } from "@/interfaces/user"; import {User} from "@/interfaces/user";
import { Dialog, Transition } from "@headlessui/react"; import {Dialog, Transition} from "@headlessui/react";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import {useRouter} from "next/router";
import { Fragment } from "react"; import {Fragment} from "react";
import { BsXLg } from "react-icons/bs"; import {BsXLg} from "react-icons/bs";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
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 () => {
axios.post("/api/logout").finally(() => { axios.post("/api/logout").finally(() => {
setTimeout(() => router.reload(), 500); setTimeout(() => router.reload(), 500);
}); });
}; };
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-10" onClose={onClose}>
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
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>
<div className="fixed inset-0 overflow-y-auto"> <div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center text-center"> <div className="flex min-h-full items-center justify-center text-center">
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0 scale-95" enterFrom="opacity-0 scale-95"
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 as="header" className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden">
<Dialog.Title <Link href={disableNavigation ? "" : "/"}>
as="header" <Image src="/logo_title.png" alt="EnCoach logo" width={69} height={69} />
className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden" </Link>
> <div className="cursor-pointer" onClick={onClose} tabIndex={0}>
<Link href="/"> <BsXLg className="text-mti-purple-light text-2xl" onClick={onClose} />
<Image </div>
src="/logo_title.png" </Dialog.Title>
alt="EnCoach logo" <div className="flex h-full flex-col gap-6 px-8 text-lg">
width={69} <Link
height={69} href={disableNavigation ? "" : "/"}
/> className={clsx(
</Link> "w-fit transition duration-300 ease-in-out",
<div path === "/" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
className="cursor-pointer" )}>
onClick={onClose} Dashboard
tabIndex={0} </Link>
> {(user.type === "student" || user.type === "teacher" || user.type === "developer") && (
<BsXLg <>
className="text-mti-purple-light text-2xl" <Link
onClick={onClose} href={disableNavigation ? "" : "/exam"}
/> className={clsx(
</div> "w-fit transition duration-300 ease-in-out",
</Dialog.Title> path === "/exam" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
<div className="flex h-full flex-col gap-6 px-8 text-lg"> )}>
<Link Exams
href="/" </Link>
className={clsx( <Link
"w-fit transition duration-300 ease-in-out", href={disableNavigation ? "" : "/exercises"}
path === "/" && className={clsx(
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", "w-fit transition duration-300 ease-in-out",
)} path === "/exercises" &&
> "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
Dashboard )}>
</Link> Exercises
{(user.type === "student" || </Link>
user.type === "teacher" || </>
user.type === "developer") && ( )}
<> <Link
<Link href={disableNavigation ? "" : "/stats"}
href="/exam" className={clsx(
className={clsx( "w-fit transition duration-300 ease-in-out",
"w-fit transition duration-300 ease-in-out", path === "/stats" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
path === "/exam" && )}>
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", Stats
)} </Link>
> <Link
Exams href={disableNavigation ? "" : "/record"}
</Link> className={clsx(
<Link "w-fit transition duration-300 ease-in-out",
href="/exercises" path === "/record" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
className={clsx( )}>
"w-fit transition duration-300 ease-in-out", Record
path === "/exercises" && </Link>
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", {["admin", "developer", "agent", "corporate"].includes(user.type) && (
)} <Link
> href={disableNavigation ? "" : "/payment-record"}
Exercises className={clsx(
</Link> "w-fit transition duration-300 ease-in-out",
</> path === "/payment-record" &&
)} "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
<Link )}>
href="/stats" Payment Record
className={clsx( </Link>
"w-fit transition duration-300 ease-in-out", )}
path === "/stats" && {["admin", "developer", "corporate", "teacher"].includes(user.type) && (
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", <Link
)} href={disableNavigation ? "" : "/settings"}
> className={clsx(
Stats "w-fit transition duration-300 ease-in-out",
</Link> path === "/settings" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
<Link )}>
href="/record" Settings
className={clsx( </Link>
"w-fit transition duration-300 ease-in-out", )}
path === "/record" && {["admin", "developer", "agent"].includes(user.type) && (
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", <Link
)} href={disableNavigation ? "" : "/tickets"}
> className={clsx(
Record "w-fit transition duration-300 ease-in-out",
</Link> path === "/tickets" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
{["admin", "developer", "agent", "corporate"].includes( )}>
user.type, Tickets
) && ( </Link>
<Link )}
href="/payment-record" <Link
className={clsx( href={disableNavigation ? "" : "/profile"}
"w-fit transition duration-300 ease-in-out", className={clsx(
path === "/payment-record" && "w-fit transition duration-300 ease-in-out",
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", path === "/profile" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
)} )}>
> Profile
Payment Record </Link>
</Link>
)}
{["admin", "developer", "corporate", "teacher"].includes(
user.type,
) && (
<Link
href="/settings"
className={clsx(
"w-fit transition duration-300 ease-in-out",
path === "/settings" &&
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
)}
>
Settings
</Link>
)}
{["admin", "developer", "agent"].includes(user.type) && (
<Link
href="/tickets"
className={clsx(
"w-fit transition duration-300 ease-in-out",
path === "/tickets" &&
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
)}
>
Tickets
</Link>
)}
<Link
href="/profile"
className={clsx(
"w-fit transition duration-300 ease-in-out",
path === "/profile" &&
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
)}
>
Profile
</Link>
<span <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}>
)} Logout
onClick={logout} </span>
> </div>
Logout </Dialog.Panel>
</span> </Transition.Child>
</div> </div>
</Dialog.Panel> </div>
</Transition.Child> </Dialog>
</div> </Transition>
</div> );
</Dialog>
</Transition>
);
} }

View File

@@ -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"

View File

@@ -1,161 +1,116 @@
import { User } from "@/interfaces/user"; import {User} from "@/interfaces/user";
import Link from "next/link"; import Link from "next/link";
import FocusLayer from "@/components/FocusLayer"; import FocusLayer from "@/components/FocusLayer";
import { preventNavigation } from "@/utils/navigation.disabled"; import {preventNavigation} from "@/utils/navigation.disabled";
import { useRouter } from "next/router"; import {useRouter} from "next/router";
import { BsList, BsQuestionCircle, BsQuestionCircleFill } from "react-icons/bs"; import {BsList, BsQuestionCircle, BsQuestionCircleFill} from "react-icons/bs";
import clsx from "clsx"; import clsx from "clsx";
import moment from "moment"; import moment from "moment";
import MobileMenu from "./MobileMenu"; import MobileMenu from "./MobileMenu";
import { useEffect, useState } from "react"; import {useEffect, useState} from "react";
import { Type } from "@/interfaces/user"; import {Type} from "@/interfaces/user";
import { USER_TYPE_LABELS } from "@/resources/user"; import {USER_TYPE_LABELS} from "@/resources/user";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import { isUserFromCorporate } from "@/utils/groups"; import {isUserFromCorporate} from "@/utils/groups";
import Button from "./Low/Button"; import Button from "./Low/Button";
import Modal from "./Modal"; import Modal from "./Modal";
import Input from "./Low/Input"; import Input from "./Low/Input";
import TicketSubmission from "./High/TicketSubmission"; import TicketSubmission from "./High/TicketSubmission";
interface Props { interface Props {
user: User; user: User;
navDisabled?: boolean; navDisabled?: boolean;
focusMode?: boolean; focusMode?: boolean;
onFocusLayerMouseEnter?: () => void; onFocusLayerMouseEnter?: () => void;
path: string; path: string;
} }
/* 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, const [isMenuOpen, setIsMenuOpen] = useState(false);
path, const [disablePaymentPage, setDisablePaymentPage] = useState(true);
navDisabled = false, const [isTicketOpen, setIsTicketOpen] = useState(false);
focusMode = false,
onFocusLayerMouseEnter,
}: Props) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
const [isTicketOpen, setIsTicketOpen] = useState(false);
const disableNavigation = preventNavigation(navDisabled, focusMode); const disableNavigation = preventNavigation(navDisabled, focusMode);
const expirationDateColor = (date: Date) => { const expirationDateColor = (date: Date) => {
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 = () => {
if (!user.subscriptionExpirationDate) return false; if (!user.subscriptionExpirationDate) return false;
const momentDate = moment(user.subscriptionExpirationDate); const momentDate = moment(user.subscriptionExpirationDate);
const today = moment(new Date()); const today = moment(new Date());
return today.add(7, "days").isAfter(momentDate); return today.add(7, "days").isAfter(momentDate);
}; };
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) => }, [user]);
setDisablePaymentPage(result),
);
}, [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)} </Modal>
title="Submit a ticket"
>
<TicketSubmission
user={user}
page={window.location.href}
onClose={() => setIsTicketOpen(false)}
/>
</Modal>
{user && ( {user && (
<MobileMenu <MobileMenu disableNavigation={disableNavigation} path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />
path={path} )}
isOpen={isMenuOpen} <header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4">
onClose={() => setIsMenuOpen(false)} <Link href={disableNavigation ? "" : "/"} className=" flex items-center gap-8 md:px-8">
user={user} <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>
)} </Link>
<header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4"> <div className="flex items-center justify-end gap-4 md:mr-8 md:w-5/6">
<Link {/* OPEN TICKET SYSTEM */}
href={disableNavigation ? "" : "/"} <button
className=" flex items-center gap-8 md:px-8" className={clsx(
> "border-mti-purple-light tooltip tooltip-bottom flex h-8 w-8 flex-col items-center justify-center rounded-full border p-1",
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" /> "hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white",
<h1 className="-md:hidden w-1/6 text-2xl font-bold">EnCoach</h1> )}
</Link> data-tip="Submit a help/feedback ticket"
<div className="flex items-center justify-end gap-4 md:mr-8 md:w-5/6"> onClick={() => setIsTicketOpen(true)}>
{/* OPEN TICKET SYSTEM */} <BsQuestionCircleFill />
<button </button>
className={clsx(
"border-mti-purple-light tooltip tooltip-bottom flex h-8 w-8 flex-col items-center justify-center rounded-full border p-1",
"hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white",
)}
data-tip="Submit a help/feedback ticket"
onClick={() => setIsTicketOpen(true)}
>
<BsQuestionCircleFill />
</button>
{showExpirationDate() && ( {showExpirationDate() && (
<Link <Link
href={disablePaymentPage ? "/payment" : ""} href={disablePaymentPage ? "/payment" : ""}
data-tip="Expiry date" data-tip="Expiry date"
className={clsx( className={clsx(
"flex w-fit cursor-pointer justify-center rounded-full border px-6 py-2 text-sm font-normal focus:outline-none", "flex w-fit cursor-pointer justify-center rounded-full border px-6 py-2 text-sm font-normal focus:outline-none",
"tooltip tooltip-bottom transition duration-300 ease-in-out", "tooltip tooltip-bottom transition duration-300 ease-in-out",
!user.subscriptionExpirationDate !user.subscriptionExpirationDate
? "bg-mti-green-ultralight border-mti-green-light" ? "bg-mti-green-ultralight border-mti-green-light"
: expirationDateColor(user.subscriptionExpirationDate), : expirationDateColor(user.subscriptionExpirationDate),
"border-mti-gray-platinum bg-white", "border-mti-gray-platinum bg-white",
)} )}>
> {!user.subscriptionExpirationDate && "Unlimited"}
{!user.subscriptionExpirationDate && "Unlimited"} {user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
{user.subscriptionExpirationDate && </Link>
moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")} )}
</Link> <Link href={disableNavigation ? "" : "/profile"} className="-md:hidden flex items-center justify-end gap-6">
)} <img src={user.profilePicture} alt={user.name} className="h-10 w-10 rounded-full object-cover" />
<Link <span className="-md:hidden text-right">
href={disableNavigation ? "" : "/profile"} {user.type === "corporate" ? `${user.corporateInformation?.companyInformation.name} |` : ""} {user.name} |{" "}
className="-md:hidden flex items-center justify-end gap-6" {USER_TYPE_LABELS[user.type]}
> </span>
<img </Link>
src={user.profilePicture} <div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}>
alt={user.name} <BsList className="text-mti-purple-light h-8 w-8" />
className="h-10 w-10 rounded-full object-cover" </div>
/> </div>
<span className="-md:hidden text-right"> {focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
{user.type === "corporate" </header>
? `${user.corporateInformation?.companyInformation.name} |` </>
: ""}{" "} );
{user.name} | {USER_TYPE_LABELS[user.type]}
</span>
</Link>
<div
className="cursor-pointer md:hidden"
onClick={() => setIsMenuOpen(true)}
>
<BsList className="text-mti-purple-light h-8 w-8" />
</div>
</div>
{focusMode && (
<FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
)}
</header>
</>
);
} }

View File

@@ -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&apos;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">

View File

@@ -1,354 +1,307 @@
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";
import { Module } from "@/interfaces"; import {Module} from "@/interfaces";
import { Assignment } from "@/interfaces/results"; import {Assignment} from "@/interfaces/results";
import { Stat, User } from "@/interfaces/user"; import {Stat, User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import { getExamById } from "@/utils/exams"; import {getExamById} from "@/utils/exams";
import { sortByModule } from "@/utils/moduleUtils"; import {sortByModule} from "@/utils/moduleUtils";
import { calculateBandScore } from "@/utils/score"; import {calculateBandScore} from "@/utils/score";
import { convertToUserSolutions } from "@/utils/stats"; import {convertToUserSolutions} from "@/utils/stats";
import 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;
assignment?: Assignment; assignment?: Assignment;
onClose: () => void; onClose: () => void;
} }
export default function AssignmentView({ isOpen, assignment, onClose }: Props) { export default function AssignmentView({isOpen, assignment, onClose}: Props) {
const { users } = useUsers(); const {users} = useUsers();
const router = useRouter(); const router = useRouter();
const setExams = useExamStore((state) => state.setExams); const setExams = useExamStore((state) => state.setExams);
const setShowSolutions = useExamStore((state) => state.setShowSolutions); const setShowSolutions = useExamStore((state) => state.setShowSolutions);
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 formatTimestamp = (timestamp: string) => { const deleteAssignment = async () => {
const date = moment(parseInt(timestamp)); if (!confirm("Are you sure you want to delete this assignment?")) return;
const formatter = "YYYY/MM/DD - HH:mm";
return date.format(formatter); axios
}; .delete(`/api/assignments/${assignment?.id}`)
.then(() => toast.success(`Successfully deleted the assignment "${assignment?.name}".`))
.catch(() => toast.error("Something went wrong, please try again later."))
.finally(onClose);
};
const calculateAverageModuleScore = (module: Module) => { const formatTimestamp = (timestamp: string) => {
if (!assignment) return -1; const date = moment(parseInt(timestamp));
const formatter = "YYYY/MM/DD - HH:mm";
const resultModuleBandScores = assignment.results.map((r) => { return date.format(formatter);
const moduleStats = r.stats.filter((s) => s.module === module); };
const correct = moduleStats.reduce( const calculateAverageModuleScore = (module: Module) => {
(acc, curr) => acc + curr.score.correct, if (!assignment) return -1;
0,
);
const total = moduleStats.reduce(
(acc, curr) => acc + curr.score.total,
0,
);
return calculateBandScore(correct, total, module, r.type);
});
return resultModuleBandScores.length === 0 const resultModuleBandScores = assignment.results.map((r) => {
? -1 const moduleStats = r.stats.filter((s) => s.module === module);
: resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
assignment.results.length;
};
const aggregateScoresByModule = ( const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
stats: Stat[], const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
): { module: Module; total: number; missing: number; correct: number }[] => { return calculateBandScore(correct, total, module, r.type);
const scores: { });
[key in Module]: { total: number; missing: number; correct: number };
} = {
reading: {
total: 0,
correct: 0,
missing: 0,
},
listening: {
total: 0,
correct: 0,
missing: 0,
},
writing: {
total: 0,
correct: 0,
missing: 0,
},
speaking: {
total: 0,
correct: 0,
missing: 0,
},
level: {
total: 0,
correct: 0,
missing: 0,
},
};
stats.forEach((x) => { return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length;
scores[x.module!] = { };
total: scores[x.module!].total + x.score.total,
correct: scores[x.module!].correct + x.score.correct,
missing: scores[x.module!].missing + x.score.missing,
};
});
return Object.keys(scores) const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => {
.filter((x) => scores[x as Module].total > 0) const scores: {
.map((x) => ({ module: x as Module, ...scores[x as Module] })); [key in Module]: {total: number; missing: number; correct: number};
}; } = {
reading: {
total: 0,
correct: 0,
missing: 0,
},
listening: {
total: 0,
correct: 0,
missing: 0,
},
writing: {
total: 0,
correct: 0,
missing: 0,
},
speaking: {
total: 0,
correct: 0,
missing: 0,
},
level: {
total: 0,
correct: 0,
missing: 0,
},
};
const customContent = ( stats.forEach((x) => {
stats: Stat[], scores[x.module!] = {
user: string, total: scores[x.module!].total + x.score.total,
focus: "academic" | "general", correct: scores[x.module!].correct + x.score.correct,
) => { missing: scores[x.module!].missing + x.score.missing,
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) => ({ return Object.keys(scores)
module: x.module, .filter((x) => scores[x as Module].total > 0)
level: calculateBandScore(x.correct, x.total, x.module, focus), .map((x) => ({module: x as Module, ...scores[x as Module]}));
})); };
const timeSpent = stats[0].timeSpent; const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => {
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
const selectExam = () => { const aggregatedLevels = aggregatedScores.map((x) => ({
const examPromises = uniqBy(stats, "exam").map((stat) => module: x.module,
getExamById(stat.module, stat.exam), level: calculateBandScore(x.correct, x.total, x.module, focus),
); }));
Promise.all(examPromises).then((exams) => { const timeSpent = stats[0].timeSpent;
if (exams.every((x) => !!x)) {
setUserSolutions(convertToUserSolutions(stats));
setShowSolutions(true);
setExams(exams.map((x) => x!).sort(sortByModule));
setSelectedModules(
exams
.map((x) => x!)
.sort(sortByModule)
.map((x) => x!.module),
);
router.push("/exercises");
}
});
};
const content = ( const selectExam = () => {
<> const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam));
<div className="-md:items-center flex w-full justify-between 2xl:items-center">
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
<span className="font-medium">
{formatTimestamp(stats[0].date.toString())}
</span>
{timeSpent && (
<>
<span className="md:hidden 2xl:flex"> </span>
<span className="text-sm">
{Math.floor(timeSpent / 60)} minutes
</span>
</>
)}
</div>
<span
className={clsx(
correct / total >= 0.7 && "text-mti-purple",
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
correct / total < 0.3 && "text-mti-rose",
)}
>
Level{" "}
{(
aggregatedLevels.reduce(
(accumulator, current) => accumulator + current.level,
0,
) / aggregatedLevels.length
).toFixed(1)}
</span>
</div>
<div className="flex w-full flex-col gap-1"> Promise.all(examPromises).then((exams) => {
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2"> if (exams.every((x) => !!x)) {
{aggregatedLevels.map(({ module, level }) => ( setUserSolutions(convertToUserSolutions(stats));
<div setShowSolutions(true);
key={module} setExams(exams.map((x) => x!).sort(sortByModule));
className={clsx( setSelectedModules(
"-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4", exams
module === "reading" && "bg-ielts-reading", .map((x) => x!)
module === "listening" && "bg-ielts-listening", .sort(sortByModule)
module === "writing" && "bg-ielts-writing", .map((x) => x!.module),
module === "speaking" && "bg-ielts-speaking", );
module === "level" && "bg-ielts-level", router.push("/exercises");
)} }
> });
{module === "reading" && <BsBook className="h-4 w-4" />} };
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
{module === "writing" && <BsPen className="h-4 w-4" />}
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
{module === "level" && <BsClipboard className="h-4 w-4" />}
<span className="text-sm">{level.toFixed(1)}</span>
</div>
))}
</div>
</div>
</>
);
return ( const content = (
<div className="flex flex-col gap-2"> <>
<span> <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">
const student = users.find((u) => u.id === user); <span className="font-medium">{formatTimestamp(stats[0].date.toString())}</span>
return `${student?.name} (${student?.email})`; {timeSpent && (
})()} <>
</span> <span className="md:hidden 2xl:flex"> </span>
<div <span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span>
key={user} </>
className={clsx( )}
"border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out", </div>
correct / total >= 0.7 && "hover:border-mti-purple", <span
correct / total >= 0.3 && className={clsx(
correct / total < 0.7 && correct / total >= 0.7 && "text-mti-purple",
"hover:border-mti-red", correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
correct / total < 0.3 && "hover:border-mti-rose", correct / total < 0.3 && "text-mti-rose",
)} )}>
onClick={selectExam} Level{" "}
role="button" {(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
> </span>
{content} </div>
</div>
<div
key={user}
className={clsx(
"border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden",
correct / total >= 0.7 && "hover:border-mti-purple",
correct / total >= 0.3 &&
correct / total < 0.7 &&
"hover:border-mti-red",
correct / total < 0.3 && "hover:border-mti-rose",
)}
data-tip="Your screen size is too small to view previous exams."
role="button"
>
{content}
</div>
</div>
);
};
return ( <div className="flex w-full flex-col gap-1">
<Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}> <div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
<div className="mt-4 flex w-full flex-col gap-4"> {aggregatedLevels.map(({module, level}) => (
<ProgressBar <div
color="purple" key={module}
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`} className={clsx(
className="h-6" "-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
textClassName={ module === "reading" && "bg-ielts-reading",
(assignment?.results.length || 0) / module === "listening" && "bg-ielts-listening",
(assignment?.assignees.length || 1) < module === "writing" && "bg-ielts-writing",
0.5 module === "speaking" && "bg-ielts-speaking",
? "!text-mti-gray-dim font-light" module === "level" && "bg-ielts-level",
: "text-white" )}>
} {module === "reading" && <BsBook className="h-4 w-4" />}
percentage={ {module === "listening" && <BsHeadphones className="h-4 w-4" />}
((assignment?.results.length || 0) / {module === "writing" && <BsPen className="h-4 w-4" />}
(assignment?.assignees.length || 1)) * {module === "speaking" && <BsMegaphone className="h-4 w-4" />}
100 {module === "level" && <BsClipboard className="h-4 w-4" />}
} <span className="text-sm">{level.toFixed(1)}</span>
/> </div>
<div className="flex items-start gap-8"> ))}
<div className="flex flex-col gap-2"> </div>
<span> </div>
Start Date:{" "} </>
{moment(assignment?.startDate).format("DD/MM/YY, HH:mm")} );
</span>
<span> return (
End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")} <div className="flex flex-col gap-2">
</span> <span>
</div> {(() => {
<span> const student = users.find((u) => u.id === user);
Assignees:{" "} return `${student?.name} (${student?.email})`;
{users })()}
.filter((u) => assignment?.assignees.includes(u.id)) </span>
.map((u) => `${u.name} (${u.email})`) <div
.join(", ")} key={user}
</span> className={clsx(
</div> "border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
<div className="flex flex-col gap-2"> correct / total >= 0.7 && "hover:border-mti-purple",
<span className="text-xl font-bold">Average Scores</span> correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
<div className="-md:mt-2 flex w-full items-center gap-4"> correct / total < 0.3 && "hover:border-mti-rose",
{assignment && )}
uniqBy(assignment.exams, (x) => x.module).map(({ module }) => ( onClick={selectExam}
<div role="button">
data-tip={capitalize(module)} {content}
key={module} </div>
className={clsx( <div
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4", key={user}
module === "reading" && "bg-ielts-reading", className={clsx(
module === "listening" && "bg-ielts-listening", "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",
module === "writing" && "bg-ielts-writing", correct / total >= 0.7 && "hover:border-mti-purple",
module === "speaking" && "bg-ielts-speaking", correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
module === "level" && "bg-ielts-level", correct / total < 0.3 && "hover:border-mti-rose",
)} )}
> data-tip="Your screen size is too small to view previous exams."
{module === "reading" && <BsBook className="h-4 w-4" />} role="button">
{module === "listening" && ( {content}
<BsHeadphones className="h-4 w-4" /> </div>
)} </div>
{module === "writing" && <BsPen className="h-4 w-4" />} );
{module === "speaking" && <BsMegaphone className="h-4 w-4" />} };
{module === "level" && <BsClipboard className="h-4 w-4" />}
{calculateAverageModuleScore(module) > -1 && ( return (
<span className="text-sm"> <Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}>
{calculateAverageModuleScore(module).toFixed(1)} <div className="mt-4 flex w-full flex-col gap-4">
</span> <ProgressBar
)} color="purple"
</div> label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
))} className="h-6"
</div> textClassName={
</div> (assignment?.results.length || 0) / (assignment?.assignees.length || 1) < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"
<div className="flex flex-col gap-2"> }
<span className="text-xl font-bold"> percentage={((assignment?.results.length || 0) / (assignment?.assignees.length || 1)) * 100}
Results ({assignment?.results.length}/{assignment?.assignees.length} />
) <div className="flex items-start gap-8">
</span> <div className="flex flex-col gap-2">
<div> <span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span>
{assignment && assignment?.results.length > 0 && ( <span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span>
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6"> </div>
{assignment.results.map((r) => <span>
customContent(r.stats, r.user, r.type), Assignees:{" "}
)} {users
</div> .filter((u) => assignment?.assignees.includes(u.id))
)} .map((u) => `${u.name} (${u.email})`)
{assignment && assignment?.results.length === 0 && ( .join(", ")}
<span className="ml-1 font-semibold">No results yet...</span> </span>
)} </div>
</div> <div className="flex flex-col gap-2">
</div> <span className="text-xl font-bold">Average Scores</span>
</div> <div className="-md:mt-2 flex w-full items-center gap-4">
</Modal> {assignment &&
); uniqBy(assignment.exams, (x) => x.module).map(({module}) => (
<div
data-tip={capitalize(module)}
key={module}
className={clsx(
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
module === "reading" && "bg-ielts-reading",
module === "listening" && "bg-ielts-listening",
module === "writing" && "bg-ielts-writing",
module === "speaking" && "bg-ielts-speaking",
module === "level" && "bg-ielts-level",
)}>
{module === "reading" && <BsBook className="h-4 w-4" />}
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
{module === "writing" && <BsPen className="h-4 w-4" />}
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
{module === "level" && <BsClipboard className="h-4 w-4" />}
{calculateAverageModuleScore(module) > -1 && (
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
)}
</div>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-xl font-bold">
Results ({assignment?.results.length}/{assignment?.assignees.length})
</span>
<div>
{assignment && assignment?.results.length > 0 && (
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
{assignment.results.map((r) => customContent(r.stats, r.user, r.type))}
</div>
)}
{assignment && assignment?.results.length === 0 && <span className="ml-1 font-semibold">No results yet...</span>}
</div>
</div>
<div className="flex gap-4 w-full items-center justify-end">
{assignment && (assignment.results.length === assignment.assignees.length || moment().isAfter(moment(assignment.endDate))) && (
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={deleteAssignment}>
Delete
</Button>
)}
<Button onClick={onClose} className="w-full max-w-[200px]">
Close
</Button>
</div>
</div>
</Modal>
);
} }

View File

@@ -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",
}, },
]} ]}
/> />

View File

@@ -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}
/> />

View 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>

View File

@@ -1,318 +1,247 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import ModuleTitle from "@/components/Medium/ModuleTitle"; import ModuleTitle from "@/components/Medium/ModuleTitle";
import { moduleResultText } from "@/constants/ielts"; import {moduleResultText} from "@/constants/ielts";
import { Module } from "@/interfaces"; import {Module} from "@/interfaces";
import { User } from "@/interfaces/user"; import {User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import { calculateBandScore } from "@/utils/score"; import {calculateBandScore} from "@/utils/score";
import clsx from "clsx"; 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, import {LevelScore} from "@/constants/ielts";
BsBook, import {getLevelScore} from "@/utils/score";
BsClipboard,
BsEyeFill,
BsHeadphones,
BsMegaphone,
BsPen,
BsShareFill,
} from "react-icons/bs";
import { LevelScore } from "@/constants/ielts";
import { getLevelScore } from "@/utils/score";
interface Score { interface Score {
module: Module; module: Module;
correct: number; correct: number;
total: number; total: number;
missing: number; missing: number;
} }
interface Props { interface Props {
user: User; user: User;
modules: Module[]; modules: Module[];
scores: Score[]; scores: Score[];
isLoading: boolean; isLoading: boolean;
onViewResults: () => void; onViewResults: () => void;
} }
export default function Finish({ export default function Finish({user, scores, modules, isLoading, onViewResults}: Props) {
user, const [selectedModule, setSelectedModule] = useState(modules[0]);
scores, const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!);
modules,
isLoading,
onViewResults,
}: Props) {
const [selectedModule, setSelectedModule] = useState(modules[0]);
const [selectedScore, setSelectedScore] = useState<Score>(
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", },
}, listening: {
listening: { progress: "text-ielts-listening",
progress: "text-ielts-listening", inner: "bg-ielts-listening-light",
inner: "bg-ielts-listening-light", },
}, writing: {
writing: { progress: "text-ielts-writing",
progress: "text-ielts-writing", inner: "bg-ielts-writing-light",
inner: "bg-ielts-writing-light", },
}, speaking: {
speaking: { progress: "text-ielts-speaking",
progress: "text-ielts-speaking", inner: "bg-ielts-speaking-light",
inner: "bg-ielts-speaking-light", },
}, level: {
level: { progress: "text-ielts-level",
progress: "text-ielts-level", inner: "bg-ielts-level-light",
inner: "bg-ielts-level-light", },
}, };
};
const getTotalExercises = () => { const getTotalExercises = () => {
const exam = exams.find((x) => x.module === selectedModule)!; const exam = exams.find((x) => x.module === selectedModule)!;
if (exam.module === "reading" || exam.module === "listening") { if (exam.module === "reading" || exam.module === "listening") {
return exam.parts.flatMap((x) => x.exercises).length; return exam.parts.flatMap((x) => x.exercises).length;
} }
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>
); );
} }
return <span className="text-3xl font-bold">{level}</span>; return <span className="text-3xl font-bold">{level}</span>;
}; };
return ( return (
<> <>
<div className="flex h-fit min-h-full w-full flex-col items-center justify-between gap-8"> <div className="flex h-fit min-h-full w-full flex-col items-center justify-between gap-8">
<ModuleTitle <ModuleTitle
module={selectedModule} module={selectedModule}
totalExercises={getTotalExercises()} totalExercises={getTotalExercises()}
exerciseIndex={getTotalExercises()} exerciseIndex={getTotalExercises()}
minTimer={exams.find((x) => x.module === selectedModule)!.minTimer} minTimer={exams.find((x) => x.module === selectedModule)!.minTimer}
disableTimer disableTimer
/> />
<div className="flex gap-4 self-start"> <div className="flex gap-4 self-start">
{modules.includes("reading") && ( {modules.includes("reading") && (
<div <div
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" />
)} <span className="font-semibold">Reading</span>
> </div>
<BsBook className="h-6 w-6" /> )}
<span className="font-semibold">Reading</span> {modules.includes("listening") && (
</div> <div
)} onClick={() => setSelectedModule("listening")}
{modules.includes("listening") && ( className={clsx(
<div "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",
onClick={() => setSelectedModule("listening")} selectedModule === "listening" ? "bg-ielts-listening text-white" : "bg-mti-gray-smoke text-ielts-listening",
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", <BsHeadphones className="h-6 w-6" />
selectedModule === "listening" <span className="font-semibold">Listening</span>
? "bg-ielts-listening text-white" </div>
: "bg-mti-gray-smoke text-ielts-listening", )}
)} {modules.includes("writing") && (
> <div
<BsHeadphones className="h-6 w-6" /> onClick={() => setSelectedModule("writing")}
<span className="font-semibold">Listening</span> className={clsx(
</div> "hover:bg-ielts-writing flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
)} selectedModule === "writing" ? "bg-ielts-writing text-white" : "bg-mti-gray-smoke text-ielts-writing",
{modules.includes("writing") && ( )}>
<div <BsPen className="h-6 w-6" />
onClick={() => setSelectedModule("writing")} <span className="font-semibold">Writing</span>
className={clsx( </div>
"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" {modules.includes("speaking") && (
? "bg-ielts-writing text-white" <div
: "bg-mti-gray-smoke text-ielts-writing", onClick={() => setSelectedModule("speaking")}
)} 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",
<BsPen className="h-6 w-6" /> selectedModule === "speaking" ? "bg-ielts-speaking text-white" : "bg-mti-gray-smoke text-ielts-speaking",
<span className="font-semibold">Writing</span> )}>
</div> <BsMegaphone className="h-6 w-6" />
)} <span className="font-semibold">Speaking</span>
{modules.includes("speaking") && ( </div>
<div )}
onClick={() => setSelectedModule("speaking")} {modules.includes("level") && (
className={clsx( <div
"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", onClick={() => setSelectedModule("level")}
selectedModule === "speaking" className={clsx(
? "bg-ielts-speaking text-white" "hover:bg-ielts-level flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
: "bg-mti-gray-smoke text-ielts-speaking", selectedModule === "level" ? "bg-ielts-level text-white" : "bg-mti-gray-smoke text-ielts-level",
)} )}>
> <BsClipboard className="h-6 w-6" />
<BsMegaphone className="h-6 w-6" /> <span className="font-semibold">Level</span>
<span className="font-semibold">Speaking</span> </div>
</div> )}
)} </div>
{modules.includes("level") && ( {isLoading && (
<div <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">
onClick={() => setSelectedModule("level")} <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)}>
"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", Evaluating your answers, please be patient...
selectedModule === "level" <br />
? "bg-ielts-level text-white" You can also check it later on your records page!
: "bg-mti-gray-smoke text-ielts-level", </span>
)} </div>
> )}
<BsClipboard className="h-6 w-6" /> {!isLoading && (
<span className="font-semibold">Level</span> <div className="mb-20 mt-32 flex w-full items-center justify-between gap-9">
</div> <span className="max-w-3xl">{moduleResultText(selectedModule, bandScore)}</span>
)} <div className="flex gap-9 px-16">
</div> <div
{isLoading && ( className={clsx("radial-progress overflow-hidden", moduleColors[selectedModule].progress)}
<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"> style={
<span {
className={clsx( "--value": (selectedScore.correct / selectedScore.total) * 100,
"loading loading-infinity w-32", "--thickness": "12px",
moduleColors[selectedModule].progress, "--size": "13rem",
)} } as any
/> }>
<span <div
className={clsx( className={clsx(
"text-center text-2xl font-bold", "flex h-48 w-48 flex-col items-center justify-center rounded-full",
moduleColors[selectedModule].progress, moduleColors[selectedModule].inner,
)} )}>
> <span className="text-xl">Level</span>
Evaluating your answers, please be patient... {showLevel(bandScore)}
<br /> </div>
You can also check it later on your records page! </div>
</span> <div className="flex flex-col gap-5">
</div> <div className="flex gap-2">
)} <div className="bg-mti-red-light mt-1 h-3 w-3 rounded-full" />
{!isLoading && ( <div className="flex flex-col">
<div className="mb-20 mt-32 flex w-full items-center justify-between gap-9"> <span className="text-mti-red-light">
<span className="max-w-3xl"> {(((selectedScore.total - selectedScore.missing) / selectedScore.total) * 100).toFixed(0)}%
{moduleResultText(selectedModule, bandScore)} </span>
</span> <span className="text-lg">Completion</span>
<div className="flex gap-9 px-16"> </div>
<div </div>
className={clsx( <div className="flex gap-2">
"radial-progress overflow-hidden", <div className="bg-mti-purple-light mt-1 h-3 w-3 rounded-full" />
moduleColors[selectedModule].progress, <div className="flex flex-col">
)} <span className="text-mti-purple-light">{selectedScore.correct.toString().padStart(2, "0")}</span>
style={ <span className="text-lg">Correct</span>
{ </div>
"--value": </div>
(selectedScore.correct / selectedScore.total) * 100, <div className="flex gap-2">
"--thickness": "12px", <div className="bg-mti-rose-light mt-1 h-3 w-3 rounded-full" />
"--size": "13rem", <div className="flex flex-col">
} as any <span className="text-mti-rose-light">
} {(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")}
> </span>
<div <span className="text-lg">Wrong</span>
className={clsx( </div>
"flex h-48 w-48 flex-col items-center justify-center rounded-full", </div>
moduleColors[selectedModule].inner, </div>
)} </div>
> </div>
<span className="text-xl">Level</span> )}
{showLevel(bandScore)} </div>
</div>
</div>
<div className="flex flex-col gap-5">
<div className="flex gap-2">
<div className="bg-mti-red-light mt-1 h-3 w-3 rounded-full" />
<div className="flex flex-col">
<span className="text-mti-red-light">
{(
((selectedScore.total - selectedScore.missing) /
selectedScore.total) *
100
).toFixed(0)}
%
</span>
<span className="text-lg">Completion</span>
</div>
</div>
<div className="flex gap-2">
<div className="bg-mti-purple-light mt-1 h-3 w-3 rounded-full" />
<div className="flex flex-col">
<span className="text-mti-purple-light">
{selectedScore.correct.toString().padStart(2, "0")}
</span>
<span className="text-lg">Correct</span>
</div>
</div>
<div className="flex gap-2">
<div className="bg-mti-rose-light mt-1 h-3 w-3 rounded-full" />
<div className="flex flex-col">
<span className="text-mti-rose-light">
{(selectedScore.total - selectedScore.correct)
.toString()
.padStart(2, "0")}
</span>
<span className="text-lg">Wrong</span>
</div>
</div>
</div>
</div>
</div>
)}
</div>
{!isLoading && ( {!isLoading && (
<div className="absolute bottom-8 left-0 flex w-full justify-between gap-8 self-end px-8"> <div className="absolute bottom-8 left-0 flex w-full justify-between gap-8 self-end px-8">
<div className="flex gap-8"> <div className="flex gap-8">
<div className="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> </div>
</div> <div className="flex w-fit cursor-pointer flex-col items-center gap-1">
<div className="flex w-fit cursor-pointer flex-col items-center gap-1"> <button
<button onClick={onViewResults}
onClick={onViewResults} className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out" <BsEyeFill className="h-7 w-7 text-white" />
> </button>
<BsEyeFill className="h-7 w-7 text-white" /> <span>Review Answers</span>
</button> </div>
<span>Review Answers</span> </div>
</div>
</div>
<Link href="/" className="w-full max-w-[200px] self-end"> <Link href="/" className="w-full max-w-[200px] self-end">
<Button color="purple" className="w-full max-w-[200px] self-end"> <Button color="purple" className="w-full max-w-[200px] self-end">
Dashboard Dashboard
</Button> </Button>
</Link> </Link>
</div> </div>
)} )}
</> </>
); );
} }

View File

@@ -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 &&

View File

@@ -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,38 +156,53 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
<div className="flex flex-col h-full w-full gap-8 justify-between"> <div className="flex flex-col h-full w-full gap-8 justify-between">
<ModuleTitle <ModuleTitle
exerciseIndex={ exerciseIndex={
(exam.parts partIndex === -1
.flatMap((x) => x.exercises) ? 0
.findIndex( : (exam.parts
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id, .flatMap((x) => x.exercises)
) || 0) + .findIndex(
(exerciseIndex === -1 ? 0 : 1) + (x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
questionIndex + ) || 0) +
currentQuestionIndex (exerciseIndex === -1 ? 0 : 1) +
questionIndex +
currentQuestionIndex
} }
minTimer={exam.minTimer} minTimer={exam.minTimer}
module="listening" module="listening"
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))} totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
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>

View File

@@ -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

View File

@@ -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(

View File

@@ -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 &&

View File

@@ -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 &&

View File

@@ -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>

View 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
View 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};
}

View File

@@ -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;
}[]; }[];
} }

View File

@@ -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;
} }

View File

@@ -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;
} }

View File

@@ -1,6 +1,6 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import { Module } from "@/interfaces"; import {Module} from "@/interfaces";
import { useEffect, useState } from "react"; import {useEffect, useState} from "react";
import AbandonPopup from "@/components/AbandonPopup"; import AbandonPopup from "@/components/AbandonPopup";
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
@@ -12,430 +12,428 @@ import Selection from "@/exams/Selection";
import Speaking from "@/exams/Speaking"; import Speaking from "@/exams/Speaking";
import Writing from "@/exams/Writing"; import Writing from "@/exams/Writing";
import useUser from "@/hooks/useUser"; 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 [variant, setVariant] = useState<Variant>("full");
const [moduleIndex, setModuleIndex] = useState(0); const [avoidRepeated, setAvoidRepeated] = useState(false);
const [sessionId, setSessionId] = useState(""); const [hasBeenUploaded, setHasBeenUploaded] = useState(false);
const [exam, setExam] = useState<Exam>(); const [showAbandonPopup, setShowAbandonPopup] = useState(false);
const [isEvaluationLoading, setIsEvaluationLoading] = useState(false); const [isEvaluationLoading, setIsEvaluationLoading] = useState(false);
const [showAbandonPopup, setShowAbandonPopup] = useState(false); const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<string[]>([]);
const [avoidRepeated, setAvoidRepeated] = useState(false); const [timeSpent, setTimeSpent] = useState(0);
const [timeSpent, setTimeSpent] = useState(0);
const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<
string[]
>([]);
const [variant, setVariant] = useState<Variant>("full");
const [exams, setExams] = useExamStore((state) => [ const resetStore = useExamStore((state) => state.reset);
state.exams, const assignment = useExamStore((state) => state.assignment);
state.setExams, const initialTimeSpent = useExamStore((state) => state.timeSpent);
]);
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 { user } = useUser({ redirectTo: "/login" }); const {exam, setExam} = useExamStore((state) => state);
const router = useRouter(); 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);
useEffect(() => setSessionId(uuidv4()), []); const {user} = useUser({redirectTo: "/login"});
useEffect(() => { const router = useRouter();
if (user?.type === "developer") console.log(exam);
}, [exam, user]);
useEffect(() => { const reset = () => {
selectedModules.length > 0 && timeSpent === 0 && !showSolutions; resetStore();
if (selectedModules.length > 0 && timeSpent === 0 && !showSolutions) { setVariant("full");
const timerInterval = setInterval(() => { setAvoidRepeated(false);
setTimeSpent((prev) => prev + 1); setHasBeenUploaded(false);
}, 1000); setShowAbandonPopup(false);
setIsEvaluationLoading(false);
setStatsAwaitingEvaluation([]);
setTimeSpent(0);
};
return () => { // eslint-disable-next-line react-hooks/exhaustive-deps
clearInterval(timerInterval); const saveSession = async () => {
}; console.log("Saving your session...");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules.length]);
useEffect(() => { await axios.post("/api/sessions", {
if (showSolutions) setModuleIndex(-1); id: sessionId,
}, [showSolutions]); sessionId,
date: new Date().toISOString(),
userSolutions,
moduleIndex,
selectedModules,
assignment,
timeSpent,
exams,
exam,
partIndex,
exerciseIndex,
questionIndex,
user: user?.id,
});
};
useEffect(() => { useEffect(() => setTimeSpent((prev) => prev + initialTimeSpent), [initialTimeSpent]);
(async () => {
if (
selectedModules.length > 0 &&
exams.length > 0 &&
moduleIndex < selectedModules.length
) {
const nextExam = exams[moduleIndex];
setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules, moduleIndex, exams]);
useEffect(() => { useEffect(() => {
(async () => { if (userSolutions.length === 0 && exams.length > 0) {
if (selectedModules.length > 0 && exams.length === 0) { const defaultSolutions = exams.map(defaultExamUserSolutions).flat();
const examPromises = selectedModules.map((module) => setUserSolutions(defaultSolutions);
getExam(module, avoidRepeated, variant), }
); }, [exams, setUserSolutions, userSolutions]);
Promise.all(examPromises).then((values) => {
if (values.every((x) => !!x)) {
setExams(values.map((x) => x!));
} else {
toast.error("Something went wrong, please try again");
setTimeout(router.reload, 500);
}
});
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules, setExams, exams]);
useEffect(() => { useEffect(() => {
if ( if (
selectedModules.length > 0 && sessionId.length > 0 &&
exams.length !== 0 && userSolutions.length > 0 &&
moduleIndex >= selectedModules.length && selectedModules.length > 0 &&
!hasBeenUploaded && exams.length > 0 &&
!showSolutions !!exam &&
) { timeSpent > 0 &&
const newStats: Stat[] = userSolutions.map((solution) => ({ !showSolutions &&
...solution, moduleIndex < selectedModules.length
id: solution.id || uuidv4(), )
timeSpent, saveSession();
session: sessionId, // eslint-disable-next-line react-hooks/exhaustive-deps
exam: solution.exam!, }, [assignment, exam, exams, moduleIndex, selectedModules, sessionId, userSolutions, user, exerciseIndex, partIndex, questionIndex]);
module: solution.module!,
user: user?.id || "",
date: new Date().getTime(),
...(assignment ? { assignment: assignment.id } : {}),
}));
axios useEffect(() => {
.post<{ ok: boolean }>("/api/stats", newStats) if (timeSpent % 20 === 0 && timeSpent > 0 && moduleIndex < selectedModules.length && !showSolutions) saveSession();
.then((response) => setHasBeenUploaded(response.data.ok)) // eslint-disable-next-line react-hooks/exhaustive-deps
.catch(() => setHasBeenUploaded(false)); }, [timeSpent]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules, moduleIndex, hasBeenUploaded]);
useEffect(() => { useEffect(() => {
setIsEvaluationLoading(statsAwaitingEvaluation.length !== 0); if (selectedModules.length > 0 && sessionId.length === 0) {
}, [statsAwaitingEvaluation]); const shortUID = new ShortUniqueId();
setSessionId(shortUID.randomUUID(8));
}
}, [setSessionId, selectedModules, sessionId]);
useEffect(() => { useEffect(() => {
if (statsAwaitingEvaluation.length > 0) { if (user?.type === "developer") console.log(exam);
checkIfStatsHaveBeenEvaluated(statsAwaitingEvaluation); }, [exam, user]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [statsAwaitingEvaluation]);
const checkIfStatsHaveBeenEvaluated = (ids: string[]) => { useEffect(() => {
setTimeout(async () => { if (selectedModules.length > 0 && timeSpent === 0 && !showSolutions) {
const awaitedStats = await Promise.all( const timerInterval = setInterval(() => {
ids.map(async (id) => (await axios.get<Stat>(`/api/stats/${id}`)).data), setTimeSpent((prev) => prev + 1);
); }, 1000);
const solutionsEvaluated = awaitedStats.every((stat) =>
stat.solutions.every((x) => x.evaluation !== null),
);
if (solutionsEvaluated) {
const statsUserSolutions: UserSolution[] = awaitedStats.map((stat) => ({
id: stat.id,
exercise: stat.exercise,
score: stat.score,
solutions: stat.solutions,
type: stat.type,
exam: stat.exam,
module: stat.module,
}));
const updatedUserSolutions = userSolutions.map((x) => { return () => {
const respectiveSolution = statsUserSolutions.find( clearInterval(timerInterval);
(y) => y.exercise === x.exercise, };
); }
return respectiveSolution ? respectiveSolution : x; // eslint-disable-next-line react-hooks/exhaustive-deps
}); }, [selectedModules.length]);
setUserSolutions(updatedUserSolutions); useEffect(() => {
return setStatsAwaitingEvaluation((prev) => if (showSolutions) setModuleIndex(-1);
prev.filter((x) => !ids.includes(x)), }, [setModuleIndex, showSolutions]);
);
}
return checkIfStatsHaveBeenEvaluated(ids); useEffect(() => {
}, 5 * 1000); (async () => {
}; if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) {
const nextExam = exams[moduleIndex];
const updateExamWithUserSolutions = (exam: Exam): Exam => { if (partIndex === -1 && nextExam.module !== "listening") setPartIndex(0);
if (exam.module === "reading" || exam.module === "listening") { if (exerciseIndex === -1 && !["reading", "listening"].includes(nextExam.module)) setExerciseIndex(0);
const parts = exam.parts.map((p) => setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined);
Object.assign(p, { }
exercises: p.exercises.map((x) => })();
Object.assign(x, { // eslint-disable-next-line react-hooks/exhaustive-deps
userSolutions: userSolutions.find((y) => x.id === y.exercise) }, [selectedModules, moduleIndex, exams]);
?.solutions,
}),
),
}),
);
return Object.assign(exam, { parts });
}
const exercises = exam.exercises.map((x) => useEffect(() => {
Object.assign(x, { (async () => {
userSolutions: userSolutions.find((y) => x.id === y.exercise) if (selectedModules.length > 0 && exams.length === 0) {
?.solutions, const examPromises = selectedModules.map((module) =>
}), getExam(
); module,
return Object.assign(exam, { exercises }); avoidRepeated,
}; variant,
user?.type === "student" || user?.type === "developer" ? user.preferredGender : undefined,
),
);
Promise.all(examPromises).then((values) => {
if (values.every((x) => !!x)) {
setExams(values.map((x) => x!));
} else {
toast.error("Something went wrong, please try again");
setTimeout(router.reload, 500);
}
});
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules, setExams, exams]);
const onFinish = (solutions: UserSolution[]) => { useEffect(() => {
const solutionIds = solutions.map((x) => x.exercise); if (selectedModules.length > 0 && exams.length !== 0 && moduleIndex >= selectedModules.length && !hasBeenUploaded && !showSolutions) {
const solutionExams = solutions.map((x) => x.exam); const newStats: Stat[] = userSolutions.map((solution) => ({
...solution,
id: solution.id || uuidv4(),
timeSpent,
session: sessionId,
exam: exam!.id,
module: exam!.module,
user: user?.id || "",
date: new Date().getTime(),
...(assignment ? {assignment: assignment.id} : {}),
}));
if (exam && !solutionExams.includes(exam.id)) return; axios
.post<{ok: boolean}>("/api/stats", newStats)
.then((response) => setHasBeenUploaded(response.data.ok))
.catch(() => setHasBeenUploaded(false));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules, moduleIndex, hasBeenUploaded]);
if ( useEffect(() => {
exam && setIsEvaluationLoading(statsAwaitingEvaluation.length !== 0);
(exam.module === "writing" || exam.module === "speaking") && }, [statsAwaitingEvaluation]);
solutions.length > 0 &&
!showSolutions
) {
setHasBeenUploaded(true);
setIsEvaluationLoading(true);
Promise.all( useEffect(() => {
exam.exercises.map(async (exercise) => { if (statsAwaitingEvaluation.length > 0) {
const evaluationID = uuidv4(); checkIfStatsHaveBeenEvaluated(statsAwaitingEvaluation);
if (exercise.type === "writing") }
return await evaluateWritingAnswer( // eslint-disable-next-line react-hooks/exhaustive-deps
exercise, }, [statsAwaitingEvaluation]);
solutions.find((x) => x.exercise === exercise.id)!,
evaluationID,
);
if ( const checkIfStatsHaveBeenEvaluated = (ids: string[]) => {
exercise.type === "interactiveSpeaking" || setTimeout(async () => {
exercise.type === "speaking" const awaitedStats = await Promise.all(ids.map(async (id) => (await axios.get<Stat>(`/api/stats/${id}`)).data));
) const solutionsEvaluated = awaitedStats.every((stat) => stat.solutions.every((x) => x.evaluation !== null));
return await evaluateSpeakingAnswer( if (solutionsEvaluated) {
exercise, const statsUserSolutions: UserSolution[] = awaitedStats.map((stat) => ({
solutions.find((x) => x.exercise === exercise.id)!, id: stat.id,
evaluationID, exercise: stat.exercise,
); score: stat.score,
}), solutions: stat.solutions,
) type: stat.type,
.then((responses) => { exam: stat.exam,
setStatsAwaitingEvaluation((prev) => [ module: stat.module,
...prev, }));
...responses.filter((x) => !!x).map((r) => (r as any).id),
]);
setUserSolutions([
...userSolutions,
...responses.filter((x) => !!x),
] as any);
})
.finally(() => {
setHasBeenUploaded(false);
});
}
axios.get("/api/stats/update"); const updatedUserSolutions = userSolutions.map((x) => {
const respectiveSolution = statsUserSolutions.find((y) => y.exercise === x.exercise);
return respectiveSolution ? respectiveSolution : x;
});
setUserSolutions([ setUserSolutions(updatedUserSolutions);
...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), return setStatsAwaitingEvaluation((prev) => prev.filter((x) => !ids.includes(x)));
...solutions, }
]);
setModuleIndex((prev) => prev + 1);
};
const aggregateScoresByModule = ( return checkIfStatsHaveBeenEvaluated(ids);
answers: UserSolution[], }, 5 * 1000);
): { module: Module; total: number; missing: number; correct: number }[] => { };
const scores: {
[key in Module]: { total: number; missing: number; correct: number };
} = {
reading: {
total: 0,
correct: 0,
missing: 0,
},
listening: {
total: 0,
correct: 0,
missing: 0,
},
writing: {
total: 0,
correct: 0,
missing: 0,
},
speaking: {
total: 0,
correct: 0,
missing: 0,
},
level: {
total: 0,
correct: 0,
missing: 0,
},
};
answers.forEach((x) => { const updateExamWithUserSolutions = (exam: Exam): Exam => {
scores[x.module!] = { if (exam.module === "reading" || exam.module === "listening") {
total: scores[x.module!].total + x.score.total, const parts = exam.parts.map((p) =>
correct: scores[x.module!].correct + x.score.correct, Object.assign(p, {
missing: scores[x.module!].missing + x.score.missing, exercises: p.exercises.map((x) =>
}; Object.assign(x, {
}); userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions,
}),
),
}),
);
return Object.assign(exam, {parts});
}
return Object.keys(scores) const exercises = exam.exercises.map((x) =>
.filter((x) => scores[x as Module].total > 0) Object.assign(x, {
.map((x) => ({ module: x as Module, ...scores[x as Module] })); userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions,
}; }),
);
return Object.assign(exam, {exercises});
};
const renderScreen = () => { const onFinish = (solutions: UserSolution[]) => {
if (selectedModules.length === 0) { const solutionIds = solutions.map((x) => x.exercise);
return ( const solutionExams = solutions.map((x) => x.exam);
<Selection
page={page}
user={user!}
disableSelection={page === "exams"}
onStart={(modules: Module[], avoid: boolean, variant: Variant) => {
setModuleIndex(0);
setAvoidRepeated(avoid);
setSelectedModules(modules);
setVariant(variant);
}}
/>
);
}
if (moduleIndex >= selectedModules.length || moduleIndex === -1) { if (exam && !solutionExams.includes(exam.id)) return;
return (
<Finish
isLoading={isEvaluationLoading}
user={user!}
modules={selectedModules}
onViewResults={() => {
setShowSolutions(true);
setModuleIndex(0);
setExam(exams[0]);
}}
scores={aggregateScoresByModule(userSolutions)}
/>
);
}
if (exam && exam.module === "reading") { if (exam && (exam.module === "writing" || exam.module === "speaking") && solutions.length > 0 && !showSolutions) {
return ( setHasBeenUploaded(true);
<Reading setIsEvaluationLoading(true);
exam={exam}
onFinish={onFinish}
showSolutions={showSolutions}
/>
);
}
if (exam && exam.module === "listening") { Promise.all(
return ( exam.exercises.map(async (exercise) => {
<Listening const evaluationID = uuidv4();
exam={exam} if (exercise.type === "writing")
onFinish={onFinish} return await evaluateWritingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!, evaluationID);
showSolutions={showSolutions}
/>
);
}
if (exam && exam.module === "writing") { if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking")
return ( return await evaluateSpeakingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!, evaluationID);
<Writing }),
exam={exam} )
onFinish={onFinish} .then((responses) => {
showSolutions={showSolutions} setStatsAwaitingEvaluation((prev) => [...prev, ...responses.filter((x) => !!x).map((r) => (r as any).id)]);
/> setUserSolutions([...userSolutions, ...responses.filter((x) => !!x)] as any);
); })
} .finally(() => {
setHasBeenUploaded(false);
});
}
if (exam && exam.module === "speaking") { axios.get("/api/stats/update");
return (
<Speaking
exam={exam}
onFinish={onFinish}
showSolutions={showSolutions}
/>
);
}
if (exam && exam.module === "level") { setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...solutions]);
return ( setModuleIndex(moduleIndex + 1);
<Level exam={exam} onFinish={onFinish} showSolutions={showSolutions} />
);
}
return <>Loading...</>; setPartIndex(-1);
}; setExerciseIndex(-1);
setQuestionIndex(0);
};
return ( const aggregateScoresByModule = (answers: UserSolution[]): {module: Module; total: number; missing: number; correct: number}[] => {
<> const scores: {
<ToastContainer /> [key in Module]: {total: number; missing: number; correct: number};
{user && ( } = {
<Layout reading: {
user={user} total: 0,
className="justify-between" correct: 0,
focusMode={ missing: 0,
selectedModules.length !== 0 && },
!showSolutions && listening: {
moduleIndex < selectedModules.length total: 0,
} correct: 0,
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)} missing: 0,
> },
<> writing: {
{renderScreen()} total: 0,
{!showSolutions && moduleIndex < selectedModules.length && ( correct: 0,
<AbandonPopup missing: 0,
isOpen={showAbandonPopup} },
abandonPopupTitle="Leave Exercise" speaking: {
abandonPopupDescription="Are you sure you want to leave the exercise? You will lose all your progress." total: 0,
abandonConfirmButtonText="Confirm" correct: 0,
onAbandon={() => router.reload()} missing: 0,
onCancel={() => setShowAbandonPopup(false)} },
/> level: {
)} total: 0,
</> correct: 0,
</Layout> missing: 0,
)} },
</> };
);
answers.forEach((x) => {
console.log({x});
scores[x.module!] = {
total: scores[x.module!].total + x.score.total,
correct: scores[x.module!].correct + x.score.correct,
missing: scores[x.module!].missing + x.score.missing,
};
});
return Object.keys(scores)
.filter((x) => scores[x as Module].total > 0)
.map((x) => ({module: x as Module, ...scores[x as Module]}));
};
const renderScreen = () => {
if (selectedModules.length === 0) {
return (
<Selection
page={page}
user={user!}
disableSelection={page === "exams"}
onStart={(modules: Module[], avoid: boolean, variant: Variant) => {
setModuleIndex(0);
setAvoidRepeated(avoid);
setSelectedModules(modules);
setVariant(variant);
}}
/>
);
}
if (moduleIndex >= selectedModules.length || moduleIndex === -1) {
return (
<Finish
isLoading={isEvaluationLoading}
user={user!}
modules={selectedModules}
onViewResults={() => {
setShowSolutions(true);
setModuleIndex(0);
setExerciseIndex(["reading", "listening"].includes(exams[0].module) ? -1 : 0);
setPartIndex(exams[0].module === "listening" ? -1 : 0);
setExam(exams[0]);
}}
scores={aggregateScoresByModule(userSolutions)}
/>
);
}
if (exam && exam.module === "reading") {
return <Reading exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
}
if (exam && exam.module === "listening") {
return <Listening exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
}
if (exam && exam.module === "writing") {
return <Writing exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
}
if (exam && exam.module === "speaking") {
return <Speaking exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
}
if (exam && exam.module === "level") {
return <Level exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
}
return <>Loading...</>;
};
return (
<>
<ToastContainer />
{user && (
<Layout
user={user}
className="justify-between"
focusMode={selectedModules.length !== 0 && !showSolutions && moduleIndex < selectedModules.length}
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}>
<>
{renderScreen()}
{!showSolutions && moduleIndex < selectedModules.length && (
<AbandonPopup
isOpen={showAbandonPopup}
abandonPopupTitle="Leave Exercise"
abandonPopupDescription="Are you sure you want to leave the exercise? You will lose all your progress."
abandonConfirmButtonText="Confirm"
onAbandon={() => {
reset();
}}
onCancel={() => setShowAbandonPopup(false)}
/>
)}
</>
</Layout>
)}
</>
);
} }

View File

@@ -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">

View File

@@ -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,15 +206,26 @@ const ListeningGeneration = () => {
return ( return (
<> <>
<div className="flex flex-col gap-3"> <div className="flex gap-4 w-1/2">
<label className="font-normal text-base text-mti-gray-dim">Timer</label> <div className="flex flex-col gap-3">
<Input <label className="font-normal text-base text-mti-gray-dim">Timer</label>
type="number" <Input
name="minTimer" type="number"
onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))} name="minTimer"
value={minTimer} onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))}
className="max-w-[300px]" value={minTimer}
/> className="max-w-[300px]"
/>
</div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
<Select
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
value={{value: difficulty, label: capitalize(difficulty)}}
disabled={!!part1 || !!part2 || !!part3 || !!part4}
/>
</div>
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@@ -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>

View File

@@ -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,15 +190,26 @@ const ReadingGeneration = () => {
return ( return (
<> <>
<div className="flex flex-col gap-3"> <div className="flex gap-4 w-1/2">
<label className="font-normal text-base text-mti-gray-dim">Timer</label> <div className="flex flex-col gap-3">
<Input <label className="font-normal text-base text-mti-gray-dim">Timer</label>
type="number" <Input
name="minTimer" type="number"
onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))} name="minTimer"
value={minTimer} onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))}
className="max-w-[300px]" value={minTimer}
/> className="max-w-[300px]"
/>
</div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
<Select
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
value={{value: difficulty, label: capitalize(difficulty)}}
disabled={!!part1 || !!part2 || !!part3}
/>
</div>
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@@ -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>

View File

@@ -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,15 +257,26 @@ const SpeakingGeneration = () => {
return ( return (
<> <>
<div className="flex flex-col gap-3"> <div className="flex gap-4 w-1/2">
<label className="font-normal text-base text-mti-gray-dim">Timer</label> <div className="flex flex-col gap-3">
<Input <label className="font-normal text-base text-mti-gray-dim">Timer</label>
type="number" <Input
name="minTimer" type="number"
onChange={(e) => setMinTimer(parseInt(e) < 5 ? 5 : parseInt(e))} name="minTimer"
value={minTimer} onChange={(e) => setMinTimer(parseInt(e) < 5 ? 5 : parseInt(e))}
className="max-w-[300px]" value={minTimer}
/> className="max-w-[300px]"
/>
</div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
<Select
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
value={{value: difficulty, label: capitalize(difficulty)}}
disabled={!!part1 || !!part2 || !!part3}
/>
</div>
</div> </div>
<Tab.Group> <Tab.Group>
@@ -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>

View File

@@ -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,15 +177,26 @@ const WritingGeneration = () => {
return ( return (
<> <>
<div className="flex flex-col gap-3"> <div className="flex gap-4 w-1/2">
<label className="font-normal text-base text-mti-gray-dim">Timer</label> <div className="flex flex-col gap-3">
<Input <label className="font-normal text-base text-mti-gray-dim">Timer</label>
type="number" <Input
name="minTimer" type="number"
onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))} name="minTimer"
value={minTimer} onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))}
className="max-w-[300px]" value={minTimer}
/> className="max-w-[300px]"
/>
</div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
<Select
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
value={{value: difficulty, label: capitalize(difficulty)}}
disabled={!!task1 || !!task2}
/>
</div>
</div> </div>
<Tab.Group> <Tab.Group>
@@ -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>

View File

@@ -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"

View File

@@ -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)
} }

View File

@@ -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();

View File

@@ -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);
} }

View File

@@ -1,198 +1,171 @@
// 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, import {withIronSessionApiRoute} from "iron-session/next";
collection, import {sessionOptions} from "@/lib/session";
getDocs, import {uuidv4} from "@firebase/util";
query, import {Module} from "@/interfaces";
where, import {getExams} from "@/utils/exams.be";
setDoc, import {Exam, InstructorGender, Variant} from "@/interfaces/exam";
doc, import {capitalize, flatten, uniqBy} from "lodash";
getDoc, import {User} from "@/interfaces/user";
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { uuidv4 } from "@firebase/util";
import { Module } from "@/interfaces";
import { getExams } from "@/utils/exams.be";
import { Exam, Variant } from "@/interfaces/exam";
import { capitalize, flatten, uniqBy } from "lodash";
import { User } from "@/interfaces/user";
import moment from "moment"; import moment from "moment";
import { sendEmail } from "@/email"; import {sendEmail} from "@/email";
const db = getFirestore(app); const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ ok: false }); res.status(401).json({ok: false});
return; return;
} }
if (req.method === "GET") return GET(req, res); if (req.method === "GET") return GET(req, res);
if (req.method === "POST") return POST(req, res); if (req.method === "POST") return POST(req, res);
res.status(404).json({ ok: false }); res.status(404).json({ok: false});
} }
async function GET(req: NextApiRequest, res: NextApiResponse) { async function GET(req: NextApiRequest, res: NextApiResponse) {
const q = query(collection(db, "assignments")); const q = query(collection(db, "assignments"));
const snapshot = await getDocs(q); const snapshot = await getDocs(q);
const docs = snapshot.docs.map((doc) => ({ const docs = snapshot.docs.map((doc) => ({
id: doc.id, id: doc.id,
...doc.data(), ...doc.data(),
})); }));
res.status(200).json(docs); res.status(200).json(docs);
} }
interface ExamWithUser { interface ExamWithUser {
module: Module; module: Module;
id: string; id: string;
assignee: string; assignee: string;
} }
function getRandomIndex(arr: any[]): number { function getRandomIndex(arr: any[]): number {
const randomIndex = Math.floor(Math.random() * arr.length); const randomIndex = Math.floor(Math.random() * arr.length);
return randomIndex; return randomIndex;
} }
const generateExams = async ( const generateExams = async (
generateMultiple: Boolean, generateMultiple: Boolean,
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(db, module, "true", assignee, variant, instructorGender);
const exams: Exam[] = await getExams(
db,
module,
"true",
assignee,
variant,
);
const exam = exams[getRandomIndex(exams)]; const exam = exams[getRandomIndex(exams)];
if (exam) { if (exam) {
return { module: exam.module, id: exam.id, assignee }; return {module: exam.module, id: exam.id, assignee};
} }
return null; return null;
} catch (e) { } catch (e) {
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, return exams;
) as ExamWithUser[]; }
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) {
return { module: exam.module, id: exam.id }; return {module: exam.module, id: exam.id};
} }
return null; return null;
}); });
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) {
const { const {
selectedModules, selectedModules,
assignees, assignees,
// Generate multiple true would generate an unique exam for each user // Generate multiple true would generate an unique exam for each user
// false would generate the same exam for all users // false would generate the same exam for all users
generateMultiple = false, generateMultiple = false,
variant, variant,
...body instructorGender,
} = req.body as { ...body
selectedModules: Module[]; } = req.body as {
assignees: string[]; selectedModules: Module[];
generateMultiple: Boolean; assignees: string[];
name: string; generateMultiple: Boolean;
startDate: string; name: string;
endDate: string; startDate: string;
variant?: Variant; endDate: string;
}; 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) return;
.json({ ok: false, error: "No exams found for the selected modules" }); }
return;
}
await setDoc(doc(db, "assignments", uuidv4()), { await setDoc(doc(db, "assignments", uuidv4()), {
assigner: req.session.user?.id, assigner: req.session.user?.id,
assignees, assignees,
results: [], results: [],
exams, exams,
...body, instructorGender,
}); ...body,
});
res.status(200).json({ ok: true }); res.status(200).json({ok: true});
for (const assigneeID of assignees) { for (const assigneeID of assignees) {
const assigneeSnapshot = await getDoc(doc(db, "users", assigneeID)); const assigneeSnapshot = await getDoc(doc(db, "users", assigneeID));
if (!assigneeSnapshot.exists()) continue; if (!assigneeSnapshot.exists()) continue;
const assignee = { id: assigneeID, ...assigneeSnapshot.data() } as User; const assignee = {id: assigneeID, ...assigneeSnapshot.data()} as User;
const name = body.name; const name = body.name;
const teacher = req.session.user!; const teacher = req.session.user!;
const examModulesLabel = uniqBy(exams, (x) => x.module) const examModulesLabel = uniqBy(exams, (x) => x.module)
.map((x) => capitalize(x.module)) .map((x) => capitalize(x.module))
.join(", "); .join(", ");
const startDate = moment(body.startDate).format("DD/MM/YYYY - HH:mm"); const startDate = moment(body.startDate).format("DD/MM/YYYY - HH:mm");
const endDate = moment(body.endDate).format("DD/MM/YYYY - HH:mm"); const endDate = moment(body.endDate).format("DD/MM/YYYY - HH:mm");
await sendEmail( await sendEmail(
"assignment", "assignment",
{ {
user: { name: assignee.name }, user: {name: assignee.name},
assignment: { assignment: {
name, name,
startDate, startDate,
endDate, endDate,
modules: examModulesLabel, modules: examModulesLabel,
assigner: teacher.name, assigner: teacher.name,
}, },
}, },
[assignee.email], [assignee.email],
"EnCoach - New Assignment!", "EnCoach - New Assignment!",
); );
} }
} }

View File

@@ -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}`},
}); });

View File

@@ -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);
} }

View File

@@ -1,137 +1,128 @@
// 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, import {withIronSessionApiRoute} from "iron-session/next";
getDoc, import {sessionOptions} from "@/lib/session";
doc, import {Ticket} from "@/interfaces/ticket";
deleteDoc, import {Invite} from "@/interfaces/invite";
setDoc, import {CorporateUser, Group, User} from "@/interfaces/user";
getDocs, import {v4} from "uuid";
collection, import {sendEmail} from "@/email";
where, import {updateExpiryDateOnGroup} from "@/utils/groups.be";
query,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Ticket } from "@/interfaces/ticket";
import { Invite } from "@/interfaces/invite";
import { Group, User } from "@/interfaces/user";
import { v4 } from "uuid";
import { sendEmail } from "@/email";
import { updateExpiryDateOnGroup } from "@/utils/groups.be";
const db = getFirestore(app); const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return await get(req, res); if (req.method === "GET") return await get(req, res);
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});
return; return;
} }
const { id } = req.query as { id: string }; const {id} = req.query as {id: string};
const snapshot = await getDoc(doc(db, "invites", id)); const snapshot = await getDoc(doc(db, "invites", id));
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));
if (!invitedByRef.exists()) return res.status(404).json({ ok: false }); if (!invitedByRef.exists()) return res.status(404).json({ok: false});
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) { try {
const typeGroup: Group = invitedByGroups.find( await sendEmail(
(g) => g.name === typeGroupName, "respondedInvite",
) || { {
id: v4(), corporateName: invitedBy.name,
admin: invitedBy.id, name: req.session.user.name,
name: typeGroupName, decision: "accept",
participants: [], },
disableEditing: true, [invitedBy.email],
}; `${req.session.user.name} has accepted your invite!`,
await setDoc( );
doc(db, "groups", typeGroup.id), } catch (e) {
{ console.log(e);
...typeGroup, }
participants: [
...typeGroup.participants.filter((x) => x !== req.session.user!.id),
req.session.user.id,
],
},
{ merge: true },
);
}
const invitationsGroup: Group = invitedByGroups.find( res.status(200).json({ok: true});
(g) => g.name === "Invited", } else {
) || { res.status(404).json(undefined);
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 {
await sendEmail(
"respondedInvite",
{
corporateName: invitedBy.name,
name: req.session.user.name,
decision: "accept",
},
[invitedBy.email],
`${req.session.user.name} has accepted your invite!`,
);
} catch (e) {
console.log(e);
}
res.status(200).json({ ok: true });
} else {
res.status(404).json(undefined);
}
} }

View 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});
}

View 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});
}

View File

@@ -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);
} }

View File

@@ -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),

View 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});
}

View 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,
},
};

View File

@@ -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 });

View File

@@ -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);

View File

@@ -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&apos;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" && (

View File

@@ -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)) {

View 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",
},
];

View File

@@ -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),
})); }));

View File

@@ -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%;

View File

@@ -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}) => {
question: x.prompt, const blob = await downloadBlob(x.blob);
answer: await downloadBlob(x.blob), if (!x.blob.startsWith("blob")) await axios.post("/api/storage/delete", {path: x.blob});
}));
return {
question: x.prompt,
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}],
}; };
} }

View File

@@ -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;
};

View File

@@ -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}};