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,8 +1,8 @@
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";
@@ -14,15 +14,14 @@ interface Props {
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",
@@ -54,10 +53,7 @@ export default function TicketSubmission({ user, page, onClose }: Props) {
axios axios
.post(`/api/tickets`, ticket) .post(`/api/tickets`, ticket)
.then(() => { .then(() => {
toast.success( toast.success(`Your ticket has been submitted! You will be contacted by e-mail for further discussion.`, {toastId: "submitted"});
`Your ticket has been submitted! You will be contacted by e-mail for further discussion.`,
{ toastId: "submitted" },
);
onClose(); onClose();
}) })
.catch((e) => { .catch((e) => {
@@ -71,37 +67,20 @@ export default function TicketSubmission({ user, page, onClose }: Props) {
return ( return (
<form className="flex flex-col gap-4 pt-8"> <form className="flex flex-col gap-4 pt-8">
<Input <Input label="Subject" type="text" name="subject" placeholder="Subject..." onChange={(e) => setSubject(e)} />
label="Subject"
type="text"
name="subject"
placeholder="Subject..."
onChange={(e) => setSubject(e)}
/>
<div className="-md:flex-col flex w-full items-center gap-4"> <div className="-md:flex-col flex w-full items-center gap-4">
<div className="flex w-full flex-col gap-3"> <div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal"> <label className="text-mti-gray-dim text-base font-normal">Type</label>
Type
</label>
<Select <Select
options={Object.keys(TicketTypeLabel).map((x) => ({ options={Object.keys(TicketTypeLabel).map((x) => ({
value: x, value: x,
label: TicketTypeLabel[x as keyof typeof TicketTypeLabel], label: TicketTypeLabel[x as keyof typeof TicketTypeLabel],
}))} }))}
onChange={(value) => onChange={(value) => setType((value?.value as TicketType) ?? undefined)}
setType((value?.value as TicketType) ?? undefined)
}
placeholder="Type..." placeholder="Type..."
/> />
</div> </div>
<Input <Input label="Reporter" type="text" name="reporter" onChange={() => null} value={`${user.name} - ${user.email}`} disabled />
label="Reporter"
type="text"
name="reporter"
onChange={() => null}
value={`${user.name} - ${user.email}`}
disabled
/>
</div> </div>
<textarea <textarea
className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8" className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8"
@@ -110,22 +89,10 @@ export default function TicketSubmission({ user, page, onClose }: Props) {
spellCheck spellCheck
/> />
<div className="mt-2 flex w-full items-center justify-end gap-4"> <div className="mt-2 flex w-full items-center justify-end gap-4">
<Button <Button type="button" color="red" className="w-full max-w-[200px]" variant="outline" onClick={onClose} isLoading={isLoading}>
type="button"
color="red"
className="w-full max-w-[200px]"
variant="outline"
onClick={onClose}
isLoading={isLoading}
>
Cancel Cancel
</Button> </Button>
<Button <Button type="button" className="w-full max-w-[200px]" isLoading={isLoading} onClick={submit}>
type="button"
className="w-full max-w-[200px]"
isLoading={isLoading}
onClick={submit}
>
Submit Submit
</Button> </Button>
</div> </div>

View File

@@ -1,5 +1,5 @@
import clsx from "clsx"; import clsx from "clsx";
import { ComponentProps } from "react"; import {ComponentProps, useEffect, useState} from "react";
import ReactSelect from "react-select"; import ReactSelect from "react-select";
interface Option { interface Option {
@@ -18,30 +18,27 @@ interface Props {
isClearable?: boolean; isClearable?: boolean;
} }
export default function Select({ export default function Select({value, defaultValue, options, placeholder, disabled, onChange, isClearable}: Props) {
value, const [target, setTarget] = useState<HTMLElement>();
defaultValue,
options, useEffect(() => {
placeholder, if (document) setTarget(document.body);
disabled, }, []);
onChange,
isClearable,
}: Props) {
return ( return (
<ReactSelect <ReactSelect
className={clsx( className={clsx(
"placeholder:text-mti-gray-cool border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none", "placeholder:text-mti-gray-cool border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none",
disabled && disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
"!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
)} )}
options={options} options={options}
value={value} value={value}
onChange={onChange} onChange={onChange}
placeholder={placeholder} placeholder={placeholder}
menuPortalTarget={document?.body} menuPortalTarget={target}
defaultValue={defaultValue} defaultValue={defaultValue}
styles={{ styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }), menuPortal: (base) => ({...base, zIndex: 9999}),
control: (styles) => ({ control: (styles) => ({
...styles, ...styles,
paddingLeft: "4px", paddingLeft: "4px",
@@ -53,11 +50,7 @@ export default function Select({
}), }),
option: (styles, state) => ({ option: (styles, state) => ({
...styles, ...styles,
backgroundColor: state.isFocused backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
? "#D5D9F0"
: state.isSelected
? "#7872BF"
: "white",
color: state.isFocused ? "black" : styles.color, color: state.isFocused ? "black" : styles.color,
}), }),
}} }}

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,21 +1,22 @@
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 () => {
@@ -34,8 +35,7 @@ export default function MobileMenu({ isOpen, onClose, path, user }: Props) {
enterTo="opacity-100" enterTo="opacity-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0">
>
<div className="fixed inset-0 bg-black bg-opacity-25" /> <div className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child> </Transition.Child>
@@ -48,146 +48,105 @@ export default function MobileMenu({ isOpen, onClose, path, user }: Props) {
enterTo="opacity-100 scale-100" enterTo="opacity-100 scale-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95">
>
<Dialog.Panel className="flex h-screen w-full transform flex-col gap-8 overflow-hidden bg-white text-left align-middle text-black shadow-xl transition-all"> <Dialog.Panel className="flex h-screen w-full transform flex-col gap-8 overflow-hidden bg-white text-left align-middle text-black shadow-xl transition-all">
<Dialog.Title <Dialog.Title as="header" className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden">
as="header" <Link href={disableNavigation ? "" : "/"}>
className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden" <Image src="/logo_title.png" alt="EnCoach logo" width={69} height={69} />
>
<Link href="/">
<Image
src="/logo_title.png"
alt="EnCoach logo"
width={69}
height={69}
/>
</Link> </Link>
<div <div className="cursor-pointer" onClick={onClose} tabIndex={0}>
className="cursor-pointer" <BsXLg className="text-mti-purple-light text-2xl" onClick={onClose} />
onClick={onClose}
tabIndex={0}
>
<BsXLg
className="text-mti-purple-light text-2xl"
onClick={onClose}
/>
</div> </div>
</Dialog.Title> </Dialog.Title>
<div className="flex h-full flex-col gap-6 px-8 text-lg"> <div className="flex h-full flex-col gap-6 px-8 text-lg">
<Link <Link
href="/" href={disableNavigation ? "" : "/"}
className={clsx( className={clsx(
"w-fit transition duration-300 ease-in-out", "w-fit transition duration-300 ease-in-out",
path === "/" && path === "/" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", )}>
)}
>
Dashboard Dashboard
</Link> </Link>
{(user.type === "student" || {(user.type === "student" || user.type === "teacher" || user.type === "developer") && (
user.type === "teacher" ||
user.type === "developer") && (
<> <>
<Link <Link
href="/exam" href={disableNavigation ? "" : "/exam"}
className={clsx( className={clsx(
"w-fit transition duration-300 ease-in-out", "w-fit transition duration-300 ease-in-out",
path === "/exam" && path === "/exam" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", )}>
)}
>
Exams Exams
</Link> </Link>
<Link <Link
href="/exercises" href={disableNavigation ? "" : "/exercises"}
className={clsx( className={clsx(
"w-fit transition duration-300 ease-in-out", "w-fit transition duration-300 ease-in-out",
path === "/exercises" && path === "/exercises" &&
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
)} )}>
>
Exercises Exercises
</Link> </Link>
</> </>
)} )}
<Link <Link
href="/stats" href={disableNavigation ? "" : "/stats"}
className={clsx( className={clsx(
"w-fit transition duration-300 ease-in-out", "w-fit transition duration-300 ease-in-out",
path === "/stats" && path === "/stats" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", )}>
)}
>
Stats Stats
</Link> </Link>
<Link <Link
href="/record" href={disableNavigation ? "" : "/record"}
className={clsx( className={clsx(
"w-fit transition duration-300 ease-in-out", "w-fit transition duration-300 ease-in-out",
path === "/record" && path === "/record" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", )}>
)}
>
Record Record
</Link> </Link>
{["admin", "developer", "agent", "corporate"].includes( {["admin", "developer", "agent", "corporate"].includes(user.type) && (
user.type,
) && (
<Link <Link
href="/payment-record" href={disableNavigation ? "" : "/payment-record"}
className={clsx( className={clsx(
"w-fit transition duration-300 ease-in-out", "w-fit transition duration-300 ease-in-out",
path === "/payment-record" && path === "/payment-record" &&
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
)} )}>
>
Payment Record Payment Record
</Link> </Link>
)} )}
{["admin", "developer", "corporate", "teacher"].includes( {["admin", "developer", "corporate", "teacher"].includes(user.type) && (
user.type,
) && (
<Link <Link
href="/settings" href={disableNavigation ? "" : "/settings"}
className={clsx( className={clsx(
"w-fit transition duration-300 ease-in-out", "w-fit transition duration-300 ease-in-out",
path === "/settings" && path === "/settings" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", )}>
)}
>
Settings Settings
</Link> </Link>
)} )}
{["admin", "developer", "agent"].includes(user.type) && ( {["admin", "developer", "agent"].includes(user.type) && (
<Link <Link
href="/tickets" href={disableNavigation ? "" : "/tickets"}
className={clsx( className={clsx(
"w-fit transition duration-300 ease-in-out", "w-fit transition duration-300 ease-in-out",
path === "/tickets" && path === "/tickets" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", )}>
)}
>
Tickets Tickets
</Link> </Link>
)} )}
<Link <Link
href="/profile" href={disableNavigation ? "" : "/profile"}
className={clsx( className={clsx(
"w-fit transition duration-300 ease-in-out", "w-fit transition duration-300 ease-in-out",
path === "/profile" && path === "/profile" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", )}>
)}
>
Profile Profile
</Link> </Link>
<span <span
className={clsx( className={clsx("w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out")}
"w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out", onClick={logout}>
)}
onClick={logout}
>
Logout Logout
</span> </span>
</div> </div>

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,17 +1,17 @@
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";
@@ -26,13 +26,7 @@ interface Props {
} }
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
export default function Navbar({ export default function Navbar({user, path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
user,
path,
navDisabled = false,
focusMode = false,
onFocusLayerMouseEnter,
}: Props) {
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false);
const [disablePaymentPage, setDisablePaymentPage] = useState(true); const [disablePaymentPage, setDisablePaymentPage] = useState(true);
const [isTicketOpen, setIsTicketOpen] = useState(false); const [isTicketOpen, setIsTicketOpen] = useState(false);
@@ -43,12 +37,9 @@ export default function Navbar({
const momentDate = moment(date); const momentDate = moment(date);
const today = moment(new Date()); const today = moment(new Date());
if (today.add(1, "days").isAfter(momentDate)) if (today.add(1, "days").isAfter(momentDate)) return "!bg-mti-red-ultralight border-mti-red-light";
return "!bg-mti-red-ultralight border-mti-red-light"; if (today.add(3, "days").isAfter(momentDate)) return "!bg-mti-rose-ultralight border-mti-rose-light";
if (today.add(3, "days").isAfter(momentDate)) if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light";
return "!bg-mti-rose-ultralight border-mti-rose-light";
if (today.add(7, "days").isAfter(momentDate))
return "!bg-mti-orange-ultralight border-mti-orange-light";
}; };
const showExpirationDate = () => { const showExpirationDate = () => {
@@ -61,40 +52,21 @@ export default function Navbar({
}; };
useEffect(() => { useEffect(() => {
if (user.type !== "student" && user.type !== "teacher") if (user.type !== "student" && user.type !== "teacher") return setDisablePaymentPage(false);
setDisablePaymentPage(false); isUserFromCorporate(user.id).then((result) => setDisablePaymentPage(result));
isUserFromCorporate(user.id).then((result) =>
setDisablePaymentPage(result),
);
}, [user]); }, [user]);
return ( return (
<> <>
<Modal <Modal isOpen={isTicketOpen} onClose={() => setIsTicketOpen(false)} title="Submit a ticket">
isOpen={isTicketOpen} <TicketSubmission user={user} page={window.location.href} onClose={() => setIsTicketOpen(false)} />
onClose={() => setIsTicketOpen(false)}
title="Submit a ticket"
>
<TicketSubmission
user={user}
page={window.location.href}
onClose={() => setIsTicketOpen(false)}
/>
</Modal> </Modal>
{user && ( {user && (
<MobileMenu <MobileMenu disableNavigation={disableNavigation} path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />
path={path}
isOpen={isMenuOpen}
onClose={() => setIsMenuOpen(false)}
user={user}
/>
)} )}
<header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4"> <header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4">
<Link <Link href={disableNavigation ? "" : "/"} className=" flex items-center gap-8 md:px-8">
href={disableNavigation ? "" : "/"}
className=" flex items-center gap-8 md:px-8"
>
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" /> <img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" />
<h1 className="-md:hidden w-1/6 text-2xl font-bold">EnCoach</h1> <h1 className="-md:hidden w-1/6 text-2xl font-bold">EnCoach</h1>
</Link> </Link>
@@ -106,8 +78,7 @@ export default function Navbar({
"hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white", "hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white",
)} )}
data-tip="Submit a help/feedback ticket" data-tip="Submit a help/feedback ticket"
onClick={() => setIsTicketOpen(true)} onClick={() => setIsTicketOpen(true)}>
>
<BsQuestionCircleFill /> <BsQuestionCircleFill />
</button> </button>
@@ -122,39 +93,23 @@ export default function Navbar({
? "bg-mti-green-ultralight border-mti-green-light" ? "bg-mti-green-ultralight border-mti-green-light"
: expirationDateColor(user.subscriptionExpirationDate), : expirationDateColor(user.subscriptionExpirationDate),
"border-mti-gray-platinum bg-white", "border-mti-gray-platinum bg-white",
)} )}>
>
{!user.subscriptionExpirationDate && "Unlimited"} {!user.subscriptionExpirationDate && "Unlimited"}
{user.subscriptionExpirationDate && {user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
</Link> </Link>
)} )}
<Link <Link href={disableNavigation ? "" : "/profile"} className="-md:hidden flex items-center justify-end gap-6">
href={disableNavigation ? "" : "/profile"} <img src={user.profilePicture} alt={user.name} className="h-10 w-10 rounded-full object-cover" />
className="-md:hidden flex items-center justify-end gap-6"
>
<img
src={user.profilePicture}
alt={user.name}
className="h-10 w-10 rounded-full object-cover"
/>
<span className="-md:hidden text-right"> <span className="-md:hidden text-right">
{user.type === "corporate" {user.type === "corporate" ? `${user.corporateInformation?.companyInformation.name} |` : ""} {user.name} |{" "}
? `${user.corporateInformation?.companyInformation.name} |` {USER_TYPE_LABELS[user.type]}
: ""}{" "}
{user.name} | {USER_TYPE_LABELS[user.type]}
</span> </span>
</Link> </Link>
<div <div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}>
className="cursor-pointer md:hidden"
onClick={() => setIsMenuOpen(true)}
>
<BsList className="text-mti-purple-light h-8 w-8" /> <BsList className="text-mti-purple-light h-8 w-8" />
</div> </div>
</div> </div>
{focusMode && ( {focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
<FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
)}
</header> </header>
</> </>
); );

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,25 +1,22 @@
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;
@@ -27,8 +24,8 @@ interface Props {
onClose: () => void; onClose: () => void;
} }
export default function AssignmentView({ isOpen, assignment, onClose }: Props) { export default function AssignmentView({isOpen, assignment, onClose}: Props) {
const { users } = useUsers(); const {users} = useUsers();
const router = useRouter(); const router = useRouter();
const setExams = useExamStore((state) => state.setExams); const setExams = useExamStore((state) => state.setExams);
@@ -36,6 +33,16 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
const setUserSolutions = useExamStore((state) => state.setUserSolutions); const setUserSolutions = useExamStore((state) => state.setUserSolutions);
const setSelectedModules = useExamStore((state) => state.setSelectedModules); const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const deleteAssignment = async () => {
if (!confirm("Are you sure you want to delete this assignment?")) return;
axios
.delete(`/api/assignments/${assignment?.id}`)
.then(() => toast.success(`Successfully deleted the assignment "${assignment?.name}".`))
.catch(() => toast.error("Something went wrong, please try again later."))
.finally(onClose);
};
const formatTimestamp = (timestamp: string) => { const formatTimestamp = (timestamp: string) => {
const date = moment(parseInt(timestamp)); const date = moment(parseInt(timestamp));
const formatter = "YYYY/MM/DD - HH:mm"; const formatter = "YYYY/MM/DD - HH:mm";
@@ -49,28 +56,17 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
const resultModuleBandScores = assignment.results.map((r) => { const resultModuleBandScores = assignment.results.map((r) => {
const moduleStats = r.stats.filter((s) => s.module === module); const moduleStats = r.stats.filter((s) => s.module === module);
const correct = moduleStats.reduce( const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
(acc, curr) => acc + curr.score.correct, const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
0,
);
const total = moduleStats.reduce(
(acc, curr) => acc + curr.score.total,
0,
);
return calculateBandScore(correct, total, module, r.type); return calculateBandScore(correct, total, module, r.type);
}); });
return resultModuleBandScores.length === 0 return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length;
? -1
: resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
assignment.results.length;
}; };
const aggregateScoresByModule = ( const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => {
stats: Stat[],
): { module: Module; total: number; missing: number; correct: number }[] => {
const scores: { const scores: {
[key in Module]: { total: number; missing: number; correct: number }; [key in Module]: {total: number; missing: number; correct: number};
} = { } = {
reading: { reading: {
total: 0, total: 0,
@@ -109,25 +105,13 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
return Object.keys(scores) return Object.keys(scores)
.filter((x) => scores[x as Module].total > 0) .filter((x) => scores[x as Module].total > 0)
.map((x) => ({ module: x as Module, ...scores[x as Module] })); .map((x) => ({module: x as Module, ...scores[x as Module]}));
}; };
const customContent = ( const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => {
stats: Stat[], const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
user: string, const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
focus: "academic" | "general", const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
) => {
const correct = stats.reduce(
(accumulator, current) => accumulator + current.score.correct,
0,
);
const total = stats.reduce(
(accumulator, current) => accumulator + current.score.total,
0,
);
const aggregatedScores = aggregateScoresByModule(stats).filter(
(x) => x.total > 0,
);
const aggregatedLevels = aggregatedScores.map((x) => ({ const aggregatedLevels = aggregatedScores.map((x) => ({
module: x.module, module: x.module,
@@ -137,9 +121,7 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
const timeSpent = stats[0].timeSpent; const timeSpent = stats[0].timeSpent;
const selectExam = () => { const selectExam = () => {
const examPromises = uniqBy(stats, "exam").map((stat) => const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam));
getExamById(stat.module, stat.exam),
);
Promise.all(examPromises).then((exams) => { Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) { if (exams.every((x) => !!x)) {
@@ -161,15 +143,11 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
<> <>
<div className="-md:items-center flex w-full justify-between 2xl:items-center"> <div className="-md:items-center flex w-full justify-between 2xl:items-center">
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2"> <div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
<span className="font-medium"> <span className="font-medium">{formatTimestamp(stats[0].date.toString())}</span>
{formatTimestamp(stats[0].date.toString())}
</span>
{timeSpent && ( {timeSpent && (
<> <>
<span className="md:hidden 2xl:flex"> </span> <span className="md:hidden 2xl:flex"> </span>
<span className="text-sm"> <span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span>
{Math.floor(timeSpent / 60)} minutes
</span>
</> </>
)} )}
</div> </div>
@@ -178,21 +156,15 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
correct / total >= 0.7 && "text-mti-purple", correct / total >= 0.7 && "text-mti-purple",
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red", correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
correct / total < 0.3 && "text-mti-rose", correct / total < 0.3 && "text-mti-rose",
)} )}>
>
Level{" "} Level{" "}
{( {(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
aggregatedLevels.reduce(
(accumulator, current) => accumulator + current.level,
0,
) / aggregatedLevels.length
).toFixed(1)}
</span> </span>
</div> </div>
<div className="flex w-full flex-col gap-1"> <div className="flex w-full flex-col gap-1">
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2"> <div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
{aggregatedLevels.map(({ module, level }) => ( {aggregatedLevels.map(({module, level}) => (
<div <div
key={module} key={module}
className={clsx( className={clsx(
@@ -202,8 +174,7 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
module === "writing" && "bg-ielts-writing", module === "writing" && "bg-ielts-writing",
module === "speaking" && "bg-ielts-speaking", module === "speaking" && "bg-ielts-speaking",
module === "level" && "bg-ielts-level", module === "level" && "bg-ielts-level",
)} )}>
>
{module === "reading" && <BsBook className="h-4 w-4" />} {module === "reading" && <BsBook className="h-4 w-4" />}
{module === "listening" && <BsHeadphones className="h-4 w-4" />} {module === "listening" && <BsHeadphones className="h-4 w-4" />}
{module === "writing" && <BsPen className="h-4 w-4" />} {module === "writing" && <BsPen className="h-4 w-4" />}
@@ -230,14 +201,11 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
className={clsx( className={clsx(
"border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out", "border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
correct / total >= 0.7 && "hover:border-mti-purple", correct / total >= 0.7 && "hover:border-mti-purple",
correct / total >= 0.3 && correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
correct / total < 0.7 &&
"hover:border-mti-red",
correct / total < 0.3 && "hover:border-mti-rose", correct / total < 0.3 && "hover:border-mti-rose",
)} )}
onClick={selectExam} onClick={selectExam}
role="button" role="button">
>
{content} {content}
</div> </div>
<div <div
@@ -245,14 +213,11 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
className={clsx( className={clsx(
"border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden", "border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden",
correct / total >= 0.7 && "hover:border-mti-purple", correct / total >= 0.7 && "hover:border-mti-purple",
correct / total >= 0.3 && correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
correct / total < 0.7 &&
"hover:border-mti-red",
correct / total < 0.3 && "hover:border-mti-rose", correct / total < 0.3 && "hover:border-mti-rose",
)} )}
data-tip="Your screen size is too small to view previous exams." data-tip="Your screen size is too small to view previous exams."
role="button" role="button">
>
{content} {content}
</div> </div>
</div> </div>
@@ -267,27 +232,14 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`} label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
className="h-6" className="h-6"
textClassName={ textClassName={
(assignment?.results.length || 0) / (assignment?.results.length || 0) / (assignment?.assignees.length || 1) < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"
(assignment?.assignees.length || 1) <
0.5
? "!text-mti-gray-dim font-light"
: "text-white"
}
percentage={
((assignment?.results.length || 0) /
(assignment?.assignees.length || 1)) *
100
} }
percentage={((assignment?.results.length || 0) / (assignment?.assignees.length || 1)) * 100}
/> />
<div className="flex items-start gap-8"> <div className="flex items-start gap-8">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span> <span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span>
Start Date:{" "} <span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span>
{moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}
</span>
<span>
End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}
</span>
</div> </div>
<span> <span>
Assignees:{" "} Assignees:{" "}
@@ -301,7 +253,7 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
<span className="text-xl font-bold">Average Scores</span> <span className="text-xl font-bold">Average Scores</span>
<div className="-md:mt-2 flex w-full items-center gap-4"> <div className="-md:mt-2 flex w-full items-center gap-4">
{assignment && {assignment &&
uniqBy(assignment.exams, (x) => x.module).map(({ module }) => ( uniqBy(assignment.exams, (x) => x.module).map(({module}) => (
<div <div
data-tip={capitalize(module)} data-tip={capitalize(module)}
key={module} key={module}
@@ -312,19 +264,14 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
module === "writing" && "bg-ielts-writing", module === "writing" && "bg-ielts-writing",
module === "speaking" && "bg-ielts-speaking", module === "speaking" && "bg-ielts-speaking",
module === "level" && "bg-ielts-level", module === "level" && "bg-ielts-level",
)} )}>
>
{module === "reading" && <BsBook className="h-4 w-4" />} {module === "reading" && <BsBook className="h-4 w-4" />}
{module === "listening" && ( {module === "listening" && <BsHeadphones className="h-4 w-4" />}
<BsHeadphones className="h-4 w-4" />
)}
{module === "writing" && <BsPen className="h-4 w-4" />} {module === "writing" && <BsPen className="h-4 w-4" />}
{module === "speaking" && <BsMegaphone className="h-4 w-4" />} {module === "speaking" && <BsMegaphone className="h-4 w-4" />}
{module === "level" && <BsClipboard className="h-4 w-4" />} {module === "level" && <BsClipboard className="h-4 w-4" />}
{calculateAverageModuleScore(module) > -1 && ( {calculateAverageModuleScore(module) > -1 && (
<span className="text-sm"> <span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
{calculateAverageModuleScore(module).toFixed(1)}
</span>
)} )}
</div> </div>
))} ))}
@@ -332,22 +279,28 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className="text-xl font-bold"> <span className="text-xl font-bold">
Results ({assignment?.results.length}/{assignment?.assignees.length} Results ({assignment?.results.length}/{assignment?.assignees.length})
)
</span> </span>
<div> <div>
{assignment && assignment?.results.length > 0 && ( {assignment && assignment?.results.length > 0 && (
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6"> <div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
{assignment.results.map((r) => {assignment.results.map((r) => customContent(r.stats, r.user, r.type))}
customContent(r.stats, r.user, r.type),
)}
</div> </div>
)} )}
{assignment && assignment?.results.length === 0 && ( {assignment && assignment?.results.length === 0 && <span className="ml-1 font-semibold">No results yet...</span>}
<span className="ml-1 font-semibold">No results yet...</span>
)}
</div> </div>
</div> </div>
<div className="flex gap-4 w-full items-center justify-end">
{assignment && (assignment.results.length === assignment.assignees.length || moment().isAfter(moment(assignment.endDate))) && (
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={deleteAssignment}>
Delete
</Button>
)}
<Button onClick={onClose} className="w-full max-w-[200px]">
Close
</Button>
</div>
</div> </div>
</Modal> </Modal>
); );

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,26 +1,17 @@
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;
@@ -37,28 +28,15 @@ interface Props {
onViewResults: () => void; onViewResults: () => void;
} }
export default function Finish({ export default function Finish({user, scores, modules, isLoading, onViewResults}: Props) {
user,
scores,
modules,
isLoading,
onViewResults,
}: Props) {
const [selectedModule, setSelectedModule] = useState(modules[0]); const [selectedModule, setSelectedModule] = useState(modules[0]);
const [selectedScore, setSelectedScore] = useState<Score>( const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!);
scores.find((x) => x.module === modules[0])!,
);
const exams = useExamStore((state) => state.exams); const exams = useExamStore((state) => state.exams);
useEffect( useEffect(() => setSelectedScore(scores.find((x) => x.module === selectedModule)!), [scores, selectedModule]);
() => setSelectedScore(scores.find((x) => x.module === selectedModule)!),
[scores, selectedModule],
);
useEffect(() => console.log(scores), [scores]);
const moduleColors: { [key in Module]: { progress: string; inner: string } } = const moduleColors: {[key in Module]: {progress: string; inner: string}} = {
{
reading: { reading: {
progress: "text-ielts-reading", progress: "text-ielts-reading",
inner: "bg-ielts-reading-light", inner: "bg-ielts-reading-light",
@@ -90,19 +68,14 @@ export default function Finish({
return exam.exercises.length; return exam.exercises.length;
}; };
const bandScore: number = calculateBandScore( const bandScore: number = calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus);
selectedScore.correct,
selectedScore.total,
selectedModule,
user.focus,
);
const showLevel = (level: number) => { const showLevel = (level: number) => {
if (selectedModule === "level") { if (selectedModule === "level") {
const [levelStr, grade] = getLevelScore(level); const [levelStr, grade] = getLevelScore(level);
return ( return (
<div className="flex flex-col items-center justify-center gap-1"> <div className="flex flex-col items-center justify-center gap-1">
<span className="text-xl font-bold">{grade}</span> <span className="text-xl font-bold">{levelStr}</span>
</div> </div>
); );
} }
@@ -126,11 +99,8 @@ export default function Finish({
onClick={() => setSelectedModule("reading")} onClick={() => setSelectedModule("reading")}
className={clsx( className={clsx(
"hover:bg-ielts-reading flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg", "hover:bg-ielts-reading flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
selectedModule === "reading" selectedModule === "reading" ? "bg-ielts-reading text-white" : "bg-mti-gray-smoke text-ielts-reading",
? "bg-ielts-reading text-white" )}>
: "bg-mti-gray-smoke text-ielts-reading",
)}
>
<BsBook className="h-6 w-6" /> <BsBook className="h-6 w-6" />
<span className="font-semibold">Reading</span> <span className="font-semibold">Reading</span>
</div> </div>
@@ -140,11 +110,8 @@ export default function Finish({
onClick={() => setSelectedModule("listening")} onClick={() => setSelectedModule("listening")}
className={clsx( className={clsx(
"hover:bg-ielts-listening flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg", "hover:bg-ielts-listening flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
selectedModule === "listening" selectedModule === "listening" ? "bg-ielts-listening text-white" : "bg-mti-gray-smoke text-ielts-listening",
? "bg-ielts-listening text-white" )}>
: "bg-mti-gray-smoke text-ielts-listening",
)}
>
<BsHeadphones className="h-6 w-6" /> <BsHeadphones className="h-6 w-6" />
<span className="font-semibold">Listening</span> <span className="font-semibold">Listening</span>
</div> </div>
@@ -154,11 +121,8 @@ export default function Finish({
onClick={() => setSelectedModule("writing")} onClick={() => setSelectedModule("writing")}
className={clsx( className={clsx(
"hover:bg-ielts-writing flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg", "hover:bg-ielts-writing flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
selectedModule === "writing" selectedModule === "writing" ? "bg-ielts-writing text-white" : "bg-mti-gray-smoke text-ielts-writing",
? "bg-ielts-writing text-white" )}>
: "bg-mti-gray-smoke text-ielts-writing",
)}
>
<BsPen className="h-6 w-6" /> <BsPen className="h-6 w-6" />
<span className="font-semibold">Writing</span> <span className="font-semibold">Writing</span>
</div> </div>
@@ -168,11 +132,8 @@ export default function Finish({
onClick={() => setSelectedModule("speaking")} onClick={() => setSelectedModule("speaking")}
className={clsx( className={clsx(
"hover:bg-ielts-speaking flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg", "hover:bg-ielts-speaking flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
selectedModule === "speaking" selectedModule === "speaking" ? "bg-ielts-speaking text-white" : "bg-mti-gray-smoke text-ielts-speaking",
? "bg-ielts-speaking text-white" )}>
: "bg-mti-gray-smoke text-ielts-speaking",
)}
>
<BsMegaphone className="h-6 w-6" /> <BsMegaphone className="h-6 w-6" />
<span className="font-semibold">Speaking</span> <span className="font-semibold">Speaking</span>
</div> </div>
@@ -182,11 +143,8 @@ export default function Finish({
onClick={() => setSelectedModule("level")} onClick={() => setSelectedModule("level")}
className={clsx( className={clsx(
"hover:bg-ielts-level flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg", "hover:bg-ielts-level flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
selectedModule === "level" selectedModule === "level" ? "bg-ielts-level text-white" : "bg-mti-gray-smoke text-ielts-level",
? "bg-ielts-level text-white" )}>
: "bg-mti-gray-smoke text-ielts-level",
)}
>
<BsClipboard className="h-6 w-6" /> <BsClipboard className="h-6 w-6" />
<span className="font-semibold">Level</span> <span className="font-semibold">Level</span>
</div> </div>
@@ -194,18 +152,8 @@ export default function Finish({
</div> </div>
{isLoading && ( {isLoading && (
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12"> <div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
<span <span className={clsx("loading loading-infinity w-32", moduleColors[selectedModule].progress)} />
className={clsx( <span className={clsx("text-center text-2xl font-bold", moduleColors[selectedModule].progress)}>
"loading loading-infinity w-32",
moduleColors[selectedModule].progress,
)}
/>
<span
className={clsx(
"text-center text-2xl font-bold",
moduleColors[selectedModule].progress,
)}
>
Evaluating your answers, please be patient... Evaluating your answers, please be patient...
<br /> <br />
You can also check it later on your records page! You can also check it later on your records page!
@@ -214,30 +162,22 @@ export default function Finish({
)} )}
{!isLoading && ( {!isLoading && (
<div className="mb-20 mt-32 flex w-full items-center justify-between gap-9"> <div className="mb-20 mt-32 flex w-full items-center justify-between gap-9">
<span className="max-w-3xl"> <span className="max-w-3xl">{moduleResultText(selectedModule, bandScore)}</span>
{moduleResultText(selectedModule, bandScore)}
</span>
<div className="flex gap-9 px-16"> <div className="flex gap-9 px-16">
<div <div
className={clsx( className={clsx("radial-progress overflow-hidden", moduleColors[selectedModule].progress)}
"radial-progress overflow-hidden",
moduleColors[selectedModule].progress,
)}
style={ style={
{ {
"--value": "--value": (selectedScore.correct / selectedScore.total) * 100,
(selectedScore.correct / selectedScore.total) * 100,
"--thickness": "12px", "--thickness": "12px",
"--size": "13rem", "--size": "13rem",
} as any } as any
} }>
>
<div <div
className={clsx( className={clsx(
"flex h-48 w-48 flex-col items-center justify-center rounded-full", "flex h-48 w-48 flex-col items-center justify-center rounded-full",
moduleColors[selectedModule].inner, moduleColors[selectedModule].inner,
)} )}>
>
<span className="text-xl">Level</span> <span className="text-xl">Level</span>
{showLevel(bandScore)} {showLevel(bandScore)}
</div> </div>
@@ -247,12 +187,7 @@ export default function Finish({
<div className="bg-mti-red-light mt-1 h-3 w-3 rounded-full" /> <div className="bg-mti-red-light mt-1 h-3 w-3 rounded-full" />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-mti-red-light"> <span className="text-mti-red-light">
{( {(((selectedScore.total - selectedScore.missing) / selectedScore.total) * 100).toFixed(0)}%
((selectedScore.total - selectedScore.missing) /
selectedScore.total) *
100
).toFixed(0)}
%
</span> </span>
<span className="text-lg">Completion</span> <span className="text-lg">Completion</span>
</div> </div>
@@ -260,9 +195,7 @@ export default function Finish({
<div className="flex gap-2"> <div className="flex gap-2">
<div className="bg-mti-purple-light mt-1 h-3 w-3 rounded-full" /> <div className="bg-mti-purple-light mt-1 h-3 w-3 rounded-full" />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-mti-purple-light"> <span className="text-mti-purple-light">{selectedScore.correct.toString().padStart(2, "0")}</span>
{selectedScore.correct.toString().padStart(2, "0")}
</span>
<span className="text-lg">Correct</span> <span className="text-lg">Correct</span>
</div> </div>
</div> </div>
@@ -270,9 +203,7 @@ export default function Finish({
<div className="bg-mti-rose-light mt-1 h-3 w-3 rounded-full" /> <div className="bg-mti-rose-light mt-1 h-3 w-3 rounded-full" />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-mti-rose-light"> <span className="text-mti-rose-light">
{(selectedScore.total - selectedScore.correct) {(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")}
.toString()
.padStart(2, "0")}
</span> </span>
<span className="text-lg">Wrong</span> <span className="text-lg">Wrong</span>
</div> </div>
@@ -289,8 +220,7 @@ export default function Finish({
<div className="flex w-fit cursor-pointer flex-col items-center gap-1"> <div className="flex w-fit cursor-pointer flex-col items-center gap-1">
<button <button
onClick={() => window.location.reload()} onClick={() => window.location.reload()}
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out" className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
>
<BsArrowCounterclockwise className="h-7 w-7 text-white" /> <BsArrowCounterclockwise className="h-7 w-7 text-white" />
</button> </button>
<span>Play Again</span> <span>Play Again</span>
@@ -298,8 +228,7 @@ export default function Finish({
<div className="flex w-fit cursor-pointer flex-col items-center gap-1"> <div className="flex w-fit cursor-pointer flex-col items-center gap-1">
<button <button
onClick={onViewResults} onClick={onViewResults}
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out" className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
>
<BsEyeFill className="h-7 w-7 text-white" /> <BsEyeFill className="h-7 w-7 text-white" />
</button> </button>
<span>Review Answers</span> <span>Review Answers</span>

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,7 +156,9 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
<div className="flex flex-col h-full w-full gap-8 justify-between"> <div className="flex flex-col h-full w-full gap-8 justify-between">
<ModuleTitle <ModuleTitle
exerciseIndex={ exerciseIndex={
(exam.parts partIndex === -1
? 0
: (exam.parts
.flatMap((x) => x.exercises) .flatMap((x) => x.exercises)
.findIndex( .findIndex(
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id, (x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
@@ -147,24 +172,37 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))} totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
disableTimer={showSolutions} disableTimer={showSolutions}
/> />
{renderAudioPlayer()} {/* Audio Player for the Instructions */}
{partIndex === -1 && renderAudioInstructionsPlayer()}
{/* Part's audio player */}
{partIndex > -1 && renderAudioPlayer()}
{/* Exercise renderer */}
{exerciseIndex > -1 && {exerciseIndex > -1 &&
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length && exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions && !showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)} renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
{/* Solution renderer */}
{exerciseIndex > -1 && {exerciseIndex > -1 &&
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length && exerciseIndex < exam.parts[partIndex].exercises.length &&
showSolutions && showSolutions &&
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)} renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
</div> </div>
{exerciseIndex === -1 && partIndex > 0 && (
{exerciseIndex === -1 && partIndex > -1 && exam.variant !== "partial" && (
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8"> <div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button <Button
color="purple" color="purple"
variant="outline" variant="outline"
onClick={() => { onClick={() => {
if (partIndex === 0) return setPartIndex(-1);
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1); setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
setPartIndex((prev) => prev - 1); setPartIndex(partIndex - 1);
}} }}
className="max-w-[200px] w-full"> className="max-w-[200px] w-full">
Back Back
@@ -175,7 +213,13 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
</Button> </Button>
</div> </div>
)} )}
{exerciseIndex === -1 && partIndex === 0 && (
{partIndex === -1 && exam.variant !== "partial" && (
<Button color="purple" onClick={() => setPartIndex(0)} className="max-w-[200px] self-end w-full justify-self-end">
Start now
</Button>
)}
{exerciseIndex === -1 && partIndex === 0 && exam.variant === "partial" && (
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end"> <Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end">
Start now Start now
</Button> </Button>

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,65 +12,123 @@ 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 [moduleIndex, setModuleIndex] = useState(0);
const [sessionId, setSessionId] = useState("");
const [exam, setExam] = useState<Exam>();
const [isEvaluationLoading, setIsEvaluationLoading] = useState(false);
const [showAbandonPopup, setShowAbandonPopup] = useState(false);
const [avoidRepeated, setAvoidRepeated] = useState(false);
const [timeSpent, setTimeSpent] = useState(0);
const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<
string[]
>([]);
const [variant, setVariant] = useState<Variant>("full"); const [variant, setVariant] = useState<Variant>("full");
const [avoidRepeated, setAvoidRepeated] = useState(false);
const [hasBeenUploaded, setHasBeenUploaded] = useState(false);
const [showAbandonPopup, setShowAbandonPopup] = useState(false);
const [isEvaluationLoading, setIsEvaluationLoading] = useState(false);
const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<string[]>([]);
const [timeSpent, setTimeSpent] = useState(0);
const [exams, setExams] = useExamStore((state) => [ const resetStore = useExamStore((state) => state.reset);
state.exams,
state.setExams,
]);
const [userSolutions, setUserSolutions] = useExamStore((state) => [
state.userSolutions,
state.setUserSolutions,
]);
const [showSolutions, setShowSolutions] = useExamStore((state) => [
state.showSolutions,
state.setShowSolutions,
]);
const [selectedModules, setSelectedModules] = useExamStore((state) => [
state.selectedModules,
state.setSelectedModules,
]);
const assignment = useExamStore((state) => state.assignment); const assignment = useExamStore((state) => state.assignment);
const initialTimeSpent = useExamStore((state) => state.timeSpent);
const { user } = useUser({ redirectTo: "/login" }); const {exam, setExam} = useExamStore((state) => state);
const {exams, setExams} = useExamStore((state) => state);
const {sessionId, setSessionId} = useExamStore((state) => state);
const {partIndex, setPartIndex} = useExamStore((state) => state);
const {moduleIndex, setModuleIndex} = useExamStore((state) => state);
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
const {showSolutions, setShowSolutions} = useExamStore((state) => state);
const {selectedModules, setSelectedModules} = useExamStore((state) => state);
const {user} = useUser({redirectTo: "/login"});
const router = useRouter(); const router = useRouter();
useEffect(() => setSessionId(uuidv4()), []); const reset = () => {
resetStore();
setVariant("full");
setAvoidRepeated(false);
setHasBeenUploaded(false);
setShowAbandonPopup(false);
setIsEvaluationLoading(false);
setStatsAwaitingEvaluation([]);
setTimeSpent(0);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
const saveSession = async () => {
console.log("Saving your session...");
await axios.post("/api/sessions", {
id: sessionId,
sessionId,
date: new Date().toISOString(),
userSolutions,
moduleIndex,
selectedModules,
assignment,
timeSpent,
exams,
exam,
partIndex,
exerciseIndex,
questionIndex,
user: user?.id,
});
};
useEffect(() => setTimeSpent((prev) => prev + initialTimeSpent), [initialTimeSpent]);
useEffect(() => {
if (userSolutions.length === 0 && exams.length > 0) {
const defaultSolutions = exams.map(defaultExamUserSolutions).flat();
setUserSolutions(defaultSolutions);
}
}, [exams, setUserSolutions, userSolutions]);
useEffect(() => {
if (
sessionId.length > 0 &&
userSolutions.length > 0 &&
selectedModules.length > 0 &&
exams.length > 0 &&
!!exam &&
timeSpent > 0 &&
!showSolutions &&
moduleIndex < selectedModules.length
)
saveSession();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exam, exams, moduleIndex, selectedModules, sessionId, userSolutions, user, exerciseIndex, partIndex, questionIndex]);
useEffect(() => {
if (timeSpent % 20 === 0 && timeSpent > 0 && moduleIndex < selectedModules.length && !showSolutions) saveSession();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeSpent]);
useEffect(() => {
if (selectedModules.length > 0 && sessionId.length === 0) {
const shortUID = new ShortUniqueId();
setSessionId(shortUID.randomUUID(8));
}
}, [setSessionId, selectedModules, sessionId]);
useEffect(() => { useEffect(() => {
if (user?.type === "developer") console.log(exam); if (user?.type === "developer") console.log(exam);
}, [exam, user]); }, [exam, user]);
useEffect(() => { useEffect(() => {
selectedModules.length > 0 && timeSpent === 0 && !showSolutions;
if (selectedModules.length > 0 && timeSpent === 0 && !showSolutions) { if (selectedModules.length > 0 && timeSpent === 0 && !showSolutions) {
const timerInterval = setInterval(() => { const timerInterval = setInterval(() => {
setTimeSpent((prev) => prev + 1); setTimeSpent((prev) => prev + 1);
@@ -85,16 +143,15 @@ export default function ExamPage({ page }: Props) {
useEffect(() => { useEffect(() => {
if (showSolutions) setModuleIndex(-1); if (showSolutions) setModuleIndex(-1);
}, [showSolutions]); }, [setModuleIndex, showSolutions]);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if ( if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) {
selectedModules.length > 0 &&
exams.length > 0 &&
moduleIndex < selectedModules.length
) {
const nextExam = exams[moduleIndex]; const nextExam = exams[moduleIndex];
if (partIndex === -1 && nextExam.module !== "listening") setPartIndex(0);
if (exerciseIndex === -1 && !["reading", "listening"].includes(nextExam.module)) setExerciseIndex(0);
setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined); setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined);
} }
})(); })();
@@ -105,7 +162,12 @@ export default function ExamPage({ page }: Props) {
(async () => { (async () => {
if (selectedModules.length > 0 && exams.length === 0) { if (selectedModules.length > 0 && exams.length === 0) {
const examPromises = selectedModules.map((module) => const examPromises = selectedModules.map((module) =>
getExam(module, avoidRepeated, variant), getExam(
module,
avoidRepeated,
variant,
user?.type === "student" || user?.type === "developer" ? user.preferredGender : undefined,
),
); );
Promise.all(examPromises).then((values) => { Promise.all(examPromises).then((values) => {
if (values.every((x) => !!x)) { if (values.every((x) => !!x)) {
@@ -121,27 +183,21 @@ export default function ExamPage({ page }: Props) {
}, [selectedModules, setExams, exams]); }, [selectedModules, setExams, exams]);
useEffect(() => { useEffect(() => {
if ( if (selectedModules.length > 0 && exams.length !== 0 && moduleIndex >= selectedModules.length && !hasBeenUploaded && !showSolutions) {
selectedModules.length > 0 &&
exams.length !== 0 &&
moduleIndex >= selectedModules.length &&
!hasBeenUploaded &&
!showSolutions
) {
const newStats: Stat[] = userSolutions.map((solution) => ({ const newStats: Stat[] = userSolutions.map((solution) => ({
...solution, ...solution,
id: solution.id || uuidv4(), id: solution.id || uuidv4(),
timeSpent, timeSpent,
session: sessionId, session: sessionId,
exam: solution.exam!, exam: exam!.id,
module: solution.module!, module: exam!.module,
user: user?.id || "", user: user?.id || "",
date: new Date().getTime(), date: new Date().getTime(),
...(assignment ? { assignment: assignment.id } : {}), ...(assignment ? {assignment: assignment.id} : {}),
})); }));
axios axios
.post<{ ok: boolean }>("/api/stats", newStats) .post<{ok: boolean}>("/api/stats", newStats)
.then((response) => setHasBeenUploaded(response.data.ok)) .then((response) => setHasBeenUploaded(response.data.ok))
.catch(() => setHasBeenUploaded(false)); .catch(() => setHasBeenUploaded(false));
} }
@@ -161,12 +217,8 @@ export default function ExamPage({ page }: Props) {
const checkIfStatsHaveBeenEvaluated = (ids: string[]) => { const checkIfStatsHaveBeenEvaluated = (ids: string[]) => {
setTimeout(async () => { setTimeout(async () => {
const awaitedStats = await Promise.all( const awaitedStats = await Promise.all(ids.map(async (id) => (await axios.get<Stat>(`/api/stats/${id}`)).data));
ids.map(async (id) => (await axios.get<Stat>(`/api/stats/${id}`)).data), const solutionsEvaluated = awaitedStats.every((stat) => stat.solutions.every((x) => x.evaluation !== null));
);
const solutionsEvaluated = awaitedStats.every((stat) =>
stat.solutions.every((x) => x.evaluation !== null),
);
if (solutionsEvaluated) { if (solutionsEvaluated) {
const statsUserSolutions: UserSolution[] = awaitedStats.map((stat) => ({ const statsUserSolutions: UserSolution[] = awaitedStats.map((stat) => ({
id: stat.id, id: stat.id,
@@ -179,16 +231,12 @@ export default function ExamPage({ page }: Props) {
})); }));
const updatedUserSolutions = userSolutions.map((x) => { const updatedUserSolutions = userSolutions.map((x) => {
const respectiveSolution = statsUserSolutions.find( const respectiveSolution = statsUserSolutions.find((y) => y.exercise === x.exercise);
(y) => y.exercise === x.exercise,
);
return respectiveSolution ? respectiveSolution : x; return respectiveSolution ? respectiveSolution : x;
}); });
setUserSolutions(updatedUserSolutions); setUserSolutions(updatedUserSolutions);
return setStatsAwaitingEvaluation((prev) => return setStatsAwaitingEvaluation((prev) => prev.filter((x) => !ids.includes(x)));
prev.filter((x) => !ids.includes(x)),
);
} }
return checkIfStatsHaveBeenEvaluated(ids); return checkIfStatsHaveBeenEvaluated(ids);
@@ -201,22 +249,20 @@ export default function ExamPage({ page }: Props) {
Object.assign(p, { Object.assign(p, {
exercises: p.exercises.map((x) => exercises: p.exercises.map((x) =>
Object.assign(x, { Object.assign(x, {
userSolutions: userSolutions.find((y) => x.id === y.exercise) userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions,
?.solutions,
}), }),
), ),
}), }),
); );
return Object.assign(exam, { parts }); return Object.assign(exam, {parts});
} }
const exercises = exam.exercises.map((x) => const exercises = exam.exercises.map((x) =>
Object.assign(x, { Object.assign(x, {
userSolutions: userSolutions.find((y) => x.id === y.exercise) userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions,
?.solutions,
}), }),
); );
return Object.assign(exam, { exercises }); return Object.assign(exam, {exercises});
}; };
const onFinish = (solutions: UserSolution[]) => { const onFinish = (solutions: UserSolution[]) => {
@@ -225,12 +271,7 @@ export default function ExamPage({ page }: Props) {
if (exam && !solutionExams.includes(exam.id)) return; if (exam && !solutionExams.includes(exam.id)) return;
if ( if (exam && (exam.module === "writing" || exam.module === "speaking") && solutions.length > 0 && !showSolutions) {
exam &&
(exam.module === "writing" || exam.module === "speaking") &&
solutions.length > 0 &&
!showSolutions
) {
setHasBeenUploaded(true); setHasBeenUploaded(true);
setIsEvaluationLoading(true); setIsEvaluationLoading(true);
@@ -238,32 +279,15 @@ export default function ExamPage({ page }: Props) {
exam.exercises.map(async (exercise) => { exam.exercises.map(async (exercise) => {
const evaluationID = uuidv4(); const evaluationID = uuidv4();
if (exercise.type === "writing") if (exercise.type === "writing")
return await evaluateWritingAnswer( return await evaluateWritingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!, evaluationID);
exercise,
solutions.find((x) => x.exercise === exercise.id)!,
evaluationID,
);
if ( if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking")
exercise.type === "interactiveSpeaking" || return await evaluateSpeakingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!, evaluationID);
exercise.type === "speaking"
)
return await evaluateSpeakingAnswer(
exercise,
solutions.find((x) => x.exercise === exercise.id)!,
evaluationID,
);
}), }),
) )
.then((responses) => { .then((responses) => {
setStatsAwaitingEvaluation((prev) => [ setStatsAwaitingEvaluation((prev) => [...prev, ...responses.filter((x) => !!x).map((r) => (r as any).id)]);
...prev, setUserSolutions([...userSolutions, ...responses.filter((x) => !!x)] as any);
...responses.filter((x) => !!x).map((r) => (r as any).id),
]);
setUserSolutions([
...userSolutions,
...responses.filter((x) => !!x),
] as any);
}) })
.finally(() => { .finally(() => {
setHasBeenUploaded(false); setHasBeenUploaded(false);
@@ -272,18 +296,17 @@ export default function ExamPage({ page }: Props) {
axios.get("/api/stats/update"); axios.get("/api/stats/update");
setUserSolutions([ setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...solutions]);
...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), setModuleIndex(moduleIndex + 1);
...solutions,
]); setPartIndex(-1);
setModuleIndex((prev) => prev + 1); setExerciseIndex(-1);
setQuestionIndex(0);
}; };
const aggregateScoresByModule = ( const aggregateScoresByModule = (answers: UserSolution[]): {module: Module; total: number; missing: number; correct: number}[] => {
answers: UserSolution[],
): { module: Module; total: number; missing: number; correct: number }[] => {
const scores: { const scores: {
[key in Module]: { total: number; missing: number; correct: number }; [key in Module]: {total: number; missing: number; correct: number};
} = { } = {
reading: { reading: {
total: 0, total: 0,
@@ -313,6 +336,8 @@ export default function ExamPage({ page }: Props) {
}; };
answers.forEach((x) => { answers.forEach((x) => {
console.log({x});
scores[x.module!] = { scores[x.module!] = {
total: scores[x.module!].total + x.score.total, total: scores[x.module!].total + x.score.total,
correct: scores[x.module!].correct + x.score.correct, correct: scores[x.module!].correct + x.score.correct,
@@ -322,7 +347,7 @@ export default function ExamPage({ page }: Props) {
return Object.keys(scores) return Object.keys(scores)
.filter((x) => scores[x as Module].total > 0) .filter((x) => scores[x as Module].total > 0)
.map((x) => ({ module: x as Module, ...scores[x as Module] })); .map((x) => ({module: x as Module, ...scores[x as Module]}));
}; };
const renderScreen = () => { const renderScreen = () => {
@@ -351,6 +376,8 @@ export default function ExamPage({ page }: Props) {
onViewResults={() => { onViewResults={() => {
setShowSolutions(true); setShowSolutions(true);
setModuleIndex(0); setModuleIndex(0);
setExerciseIndex(["reading", "listening"].includes(exams[0].module) ? -1 : 0);
setPartIndex(exams[0].module === "listening" ? -1 : 0);
setExam(exams[0]); setExam(exams[0]);
}} }}
scores={aggregateScoresByModule(userSolutions)} scores={aggregateScoresByModule(userSolutions)}
@@ -359,49 +386,23 @@ export default function ExamPage({ page }: Props) {
} }
if (exam && exam.module === "reading") { if (exam && exam.module === "reading") {
return ( return <Reading exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
<Reading
exam={exam}
onFinish={onFinish}
showSolutions={showSolutions}
/>
);
} }
if (exam && exam.module === "listening") { if (exam && exam.module === "listening") {
return ( return <Listening exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
<Listening
exam={exam}
onFinish={onFinish}
showSolutions={showSolutions}
/>
);
} }
if (exam && exam.module === "writing") { if (exam && exam.module === "writing") {
return ( return <Writing exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
<Writing
exam={exam}
onFinish={onFinish}
showSolutions={showSolutions}
/>
);
} }
if (exam && exam.module === "speaking") { if (exam && exam.module === "speaking") {
return ( return <Speaking exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
<Speaking
exam={exam}
onFinish={onFinish}
showSolutions={showSolutions}
/>
);
} }
if (exam && exam.module === "level") { if (exam && exam.module === "level") {
return ( return <Level exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
<Level exam={exam} onFinish={onFinish} showSolutions={showSolutions} />
);
} }
return <>Loading...</>; return <>Loading...</>;
@@ -414,13 +415,8 @@ export default function ExamPage({ page }: Props) {
<Layout <Layout
user={user} user={user}
className="justify-between" className="justify-between"
focusMode={ focusMode={selectedModules.length !== 0 && !showSolutions && moduleIndex < selectedModules.length}
selectedModules.length !== 0 && onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}>
!showSolutions &&
moduleIndex < selectedModules.length
}
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}
>
<> <>
{renderScreen()} {renderScreen()}
{!showSolutions && moduleIndex < selectedModules.length && ( {!showSolutions && moduleIndex < selectedModules.length && (
@@ -429,7 +425,9 @@ export default function ExamPage({ page }: Props) {
abandonPopupTitle="Leave Exercise" abandonPopupTitle="Leave Exercise"
abandonPopupDescription="Are you sure you want to leave the exercise? You will lose all your progress." abandonPopupDescription="Are you sure you want to leave the exercise? You will lose all your progress."
abandonConfirmButtonText="Confirm" abandonConfirmButtonText="Confirm"
onAbandon={() => router.reload()} onAbandon={() => {
reset();
}}
onCancel={() => setShowAbandonPopup(false)} onCancel={() => setShowAbandonPopup(false)}
/> />
)} )}

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,6 +206,7 @@ const ListeningGeneration = () => {
return ( return (
<> <>
<div className="flex gap-4 w-1/2">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label> <label className="font-normal text-base text-mti-gray-dim">Timer</label>
<Input <Input
@@ -196,6 +217,16 @@ const ListeningGeneration = () => {
className="max-w-[300px]" className="max-w-[300px]"
/> />
</div> </div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
<Select
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
value={{value: difficulty, label: capitalize(difficulty)}}
disabled={!!part1 || !!part2 || !!part3 || !!part4}
/>
</div>
</div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Exercises</label> <label className="font-normal text-base text-mti-gray-dim">Exercises</label>
@@ -271,7 +302,7 @@ const ListeningGeneration = () => {
{part: part3, setPart: setPart3}, {part: part3, setPart: setPart3},
{part: part4, setPart: setPart4}, {part: part4, setPart: setPart4},
].map(({part, setPart}, index) => ( ].map(({part, setPart}, index) => (
<PartTab part={part} types={types} index={index + 1} key={index} setPart={setPart} /> <PartTab part={part} difficulty={difficulty} types={types} index={index + 1} key={index} setPart={setPart} />
))} ))}
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>

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,6 +190,7 @@ const ReadingGeneration = () => {
return ( return (
<> <>
<div className="flex gap-4 w-1/2">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label> <label className="font-normal text-base text-mti-gray-dim">Timer</label>
<Input <Input
@@ -179,6 +201,16 @@ const ReadingGeneration = () => {
className="max-w-[300px]" className="max-w-[300px]"
/> />
</div> </div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
<Select
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
value={{value: difficulty, label: capitalize(difficulty)}}
disabled={!!part1 || !!part2 || !!part3}
/>
</div>
</div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Exercises</label> <label className="font-normal text-base text-mti-gray-dim">Exercises</label>
@@ -240,7 +272,7 @@ const ReadingGeneration = () => {
{part: part2, setPart: setPart2}, {part: part2, setPart: setPart2},
{part: part3, setPart: setPart3}, {part: part3, setPart: setPart3},
].map(({part, setPart}, index) => ( ].map(({part, setPart}, index) => (
<PartTab part={part} types={types} index={index + 1} key={index} setPart={setPart} /> <PartTab part={part} types={types} difficulty={difficulty} index={index + 1} key={index} setPart={setPart} />
))} ))}
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>

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,6 +257,7 @@ const SpeakingGeneration = () => {
return ( return (
<> <>
<div className="flex gap-4 w-1/2">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label> <label className="font-normal text-base text-mti-gray-dim">Timer</label>
<Input <Input
@@ -222,6 +268,16 @@ const SpeakingGeneration = () => {
className="max-w-[300px]" className="max-w-[300px]"
/> />
</div> </div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
<Select
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
value={{value: difficulty, label: capitalize(difficulty)}}
disabled={!!part1 || !!part2 || !!part3}
/>
</div>
</div>
<Tab.Group> <Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1"> <Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
@@ -265,7 +321,7 @@ const SpeakingGeneration = () => {
{part: part2, setPart: setPart2}, {part: part2, setPart: setPart2},
{part: part3, setPart: setPart3}, {part: part3, setPart: setPart3},
].map(({part, setPart}, index) => ( ].map(({part, setPart}, index) => (
<PartTab part={part} index={index + 1} key={index} setPart={setPart} /> <PartTab difficulty={difficulty} part={part} index={index + 1} key={index} setPart={setPart} />
))} ))}
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>

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,6 +177,7 @@ const WritingGeneration = () => {
return ( return (
<> <>
<div className="flex gap-4 w-1/2">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label> <label className="font-normal text-base text-mti-gray-dim">Timer</label>
<Input <Input
@@ -176,6 +188,16 @@ const WritingGeneration = () => {
className="max-w-[300px]" className="max-w-[300px]"
/> />
</div> </div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
<Select
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
value={{value: difficulty, label: capitalize(difficulty)}}
disabled={!!task1 || !!task2}
/>
</div>
</div>
<Tab.Group> <Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1"> <Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1">
@@ -207,7 +229,7 @@ const WritingGeneration = () => {
{task: task1, setTask: setTask1}, {task: task1, setTask: setTask1},
{task: task2, setTask: setTask2}, {task: task2, setTask: setTask2},
].map(({task, setTask}, index) => ( ].map(({task, setTask}, index) => (
<TaskTab task={task} index={index + 1} key={index} setTask={setTask} /> <TaskTab difficulty={difficulty} task={task} index={index + 1} key={index} setTask={setTask} />
))} ))}
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>

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,26 +1,17 @@
// 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);
@@ -28,14 +19,14 @@ 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) {
@@ -66,61 +57,47 @@ const generateExams = async (
selectedModules: Module[], selectedModules: Module[],
assignees: string[], assignees: string[],
variant?: Variant, variant?: Variant,
instructorGender?: InstructorGender,
): Promise<ExamWithUser[]> => { ): Promise<ExamWithUser[]> => {
if (generateMultiple) { if (generateMultiple) {
// for optimization purposes, it would be better to create a new endpoint that returned the answers for all users at once // for optimization purposes, it would be better to create a new endpoint that returned the answers for all users at once
const allExams = assignees.map(async (assignee) => { const allExams = assignees.map(async (assignee) => {
const selectedModulePromises = selectedModules.map( const selectedModulePromises = selectedModules.map(async (module: Module) => {
async (module: Module) => {
try { try {
const exams: Exam[] = await getExams( const exams: Exam[] = await getExams(db, module, "true", assignee, variant, instructorGender);
db,
module,
"true",
assignee,
variant,
);
const exam = exams[getRandomIndex(exams)]; const exam = exams[getRandomIndex(exams)];
if (exam) { if (exam) {
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,
) as ExamWithUser[];
return exams; return exams;
} }
const selectedModulePromises = selectedModules.map(async (module: Module) => { const selectedModulePromises = selectedModules.map(async (module: Module) => {
const exams: Exam[] = await getExams(db, module, "false", undefined); const exams: Exam[] = await getExams(db, module, "false", undefined, variant, instructorGender);
const exam = exams[getRandomIndex(exams)]; const exam = exams[getRandomIndex(exams)];
if (exam) { if (exam) {
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) {
@@ -131,6 +108,7 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
// false would generate the same exam for all users // false would generate the same exam for all users
generateMultiple = false, generateMultiple = false,
variant, variant,
instructorGender,
...body ...body
} = req.body as { } = req.body as {
selectedModules: Module[]; selectedModules: Module[];
@@ -140,19 +118,13 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
startDate: string; startDate: string;
endDate: string; endDate: string;
variant?: Variant; variant?: Variant;
instructorGender?: InstructorGender;
}; };
const exams: ExamWithUser[] = await generateExams( const exams: ExamWithUser[] = await generateExams(generateMultiple, selectedModules, assignees, variant, instructorGender);
generateMultiple,
selectedModules,
assignees,
variant,
);
if (exams.length === 0) { if (exams.length === 0) {
res res.status(400).json({ok: false, error: "No exams found for the selected modules"});
.status(400)
.json({ ok: false, error: "No exams found for the selected modules" });
return; return;
} }
@@ -161,16 +133,17 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
assignees, assignees,
results: [], results: [],
exams, exams,
instructorGender,
...body, ...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)
@@ -182,7 +155,7 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
await sendEmail( await sendEmail(
"assignment", "assignment",
{ {
user: { name: assignee.name }, user: {name: assignee.name},
assignment: { assignment: {
name, name,
startDate, startDate,

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,25 +1,15 @@
// 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);
@@ -31,46 +21,17 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
res.status(404).json(undefined); res.status(404).json(undefined);
} }
async function get(req: NextApiRequest, res: NextApiResponse) { async function addToInviterGroup(user: User, invitedBy: User) {
if (!req.session.user) { const invitedByGroupsRef = await getDocs(query(collection(db, "groups"), where("admin", "==", invitedBy.id)));
res.status(401).json({ ok: false });
return;
}
const { id } = req.query as { id: string };
const snapshot = await getDoc(doc(db, "invites", id));
if (snapshot.exists()) {
const invite = { ...snapshot.data(), id: snapshot.id } as Invite;
if (invite.to !== req.session.user.id)
return res.status(403).json({ ok: false });
await deleteDoc(snapshot.ref);
const invitedByRef = await getDoc(doc(db, "users", invite.from));
if (!invitedByRef.exists()) return res.status(404).json({ ok: false });
await updateExpiryDateOnGroup(invite.to, invite.from);
const invitedBy = { ...invitedByRef.data(), id: invitedByRef.id } as User;
const invitedByGroupsRef = await getDocs(
query(collection(db, "groups"), where("admin", "==", invitedBy.id)),
);
const invitedByGroups = invitedByGroupsRef.docs.map((g) => ({ const invitedByGroups = invitedByGroupsRef.docs.map((g) => ({
...g.data(), ...g.data(),
id: g.id, id: g.id,
})) as Group[]; })) as Group[];
const typeGroupName = const typeGroupName = user.type === "student" ? "Students" : user.type === "teacher" ? "Teachers" : undefined;
req.session.user.type === "student"
? "Students"
: req.session.user.type === "teacher"
? "Teachers"
: undefined;
if (typeGroupName) { if (typeGroupName) {
const typeGroup: Group = invitedByGroups.find( const typeGroup: Group = invitedByGroups.find((g) => g.name === typeGroupName) || {
(g) => g.name === typeGroupName,
) || {
id: v4(), id: v4(),
admin: invitedBy.id, admin: invitedBy.id,
name: typeGroupName, name: typeGroupName,
@@ -81,18 +42,13 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
doc(db, "groups", typeGroup.id), doc(db, "groups", typeGroup.id),
{ {
...typeGroup, ...typeGroup,
participants: [ participants: [...typeGroup.participants.filter((x) => x !== user.id), user.id],
...typeGroup.participants.filter((x) => x !== req.session.user!.id),
req.session.user.id,
],
}, },
{ merge: true }, {merge: true},
); );
} }
const invitationsGroup: Group = invitedByGroups.find( const invitationsGroup: Group = invitedByGroups.find((g) => g.name === "Invited") || {
(g) => g.name === "Invited",
) || {
id: v4(), id: v4(),
admin: invitedBy.id, admin: invitedBy.id,
name: "Invited", name: "Invited",
@@ -103,17 +59,52 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
doc(db, "groups", invitationsGroup.id), doc(db, "groups", invitationsGroup.id),
{ {
...invitationsGroup, ...invitationsGroup,
participants: [ participants: [...invitationsGroup.participants.filter((x) => x !== user.id), user.id],
...invitationsGroup.participants.filter(
(x) => x !== req.session.user!.id,
),
req.session.user.id,
],
}, },
{ {
merge: true, merge: true,
}, },
); );
}
async function deleteFromPreviousCorporateGroups(user: User, invitedBy: User) {
const corporatesRef = await getDocs(query(collection(db, "users"), where("type", "==", "corporate")));
const corporates = (corporatesRef.docs.map((x) => ({...x.data(), id: x.id})) as CorporateUser[]).filter((x) => x.id !== invitedBy.id);
const userGroupsRef = await getDocs(query(collection(db, "groups"), where("participants", "array-contains", user.id)));
const userGroups = userGroupsRef.docs.map((x) => ({...x.data(), id: x.id})) as Group[];
const corporateGroups = userGroups.filter((x) => corporates.map((c) => c.id).includes(x.admin));
await Promise.all(
corporateGroups.map(async (group) => {
await setDoc(doc(db, "groups", group.id), {participants: group.participants.filter((x) => x !== user.id)}, {merge: true});
}),
);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const {id} = req.query as {id: string};
const snapshot = await getDoc(doc(db, "invites", id));
if (snapshot.exists()) {
const invite = {...snapshot.data(), id: snapshot.id} as Invite;
if (invite.to !== req.session.user.id) return res.status(403).json({ok: false});
await deleteDoc(snapshot.ref);
const invitedByRef = await getDoc(doc(db, "users", invite.from));
if (!invitedByRef.exists()) return res.status(404).json({ok: false});
await updateExpiryDateOnGroup(invite.to, invite.from);
const invitedBy = {...invitedByRef.data(), id: invitedByRef.id} as User;
if (invitedBy.type === "corporate") await deleteFromPreviousCorporateGroups(req.session.user, invitedBy);
await addToInviterGroup(req.session.user, invitedBy);
try { try {
await sendEmail( await sendEmail(
@@ -130,7 +121,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
console.log(e); console.log(e);
} }
res.status(200).json({ ok: true }); res.status(200).json({ok: true});
} else { } else {
res.status(404).json(undefined); 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}) => {
const blob = await downloadBlob(x.blob);
if (!x.blob.startsWith("blob")) await axios.post("/api/storage/delete", {path: x.blob});
return {
question: x.prompt, question: x.prompt,
answer: await downloadBlob(x.blob), answer: blob,
})); };
});
const body = await Promise.all(promiseParts); const body = await Promise.all(promiseParts);
const formData = new FormData(); const formData = new FormData();
@@ -110,6 +119,7 @@ const evaluateInteractiveSpeakingExercise = async (exerciseId: string, solution:
}; };
const response = await axios.post("/api/evaluate/interactiveSpeaking", formData, config); const response = await axios.post("/api/evaluate/interactiveSpeaking", formData, config);
console.log({data: response.data, status: response.status});
if (response.status === 200) { if (response.status === 200) {
return { return {
@@ -119,6 +129,7 @@ const evaluateInteractiveSpeakingExercise = async (exerciseId: string, solution:
missing: 0, missing: 0,
total: 100, total: 100,
}, },
module: "speaking",
solutions: [{id: exerciseId, solution: response.data ? response.data.answer : null, evaluation: response.data}], solutions: [{id: exerciseId, solution: response.data ? response.data.answer : null, evaluation: response.data}],
}; };
} }

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