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} */
const websiteUrl = process.env.NODE_ENV === 'production' ? "https://encoach.com" : "http://localhost:3000";
const nextConfig = {
reactStrictMode: true,
output: "standalone",
@@ -8,7 +9,7 @@ const nextConfig = {
source: "/api/packages",
headers: [
{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",
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 Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
import {downloadBlob} from "@/utils/evaluation";
import axios from "axios";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
@@ -14,9 +16,11 @@ const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mo
export default function InteractiveSpeaking({
id,
title,
examID,
text,
type,
prompts,
userSolutions,
updateIndex,
onNext,
onBack,
@@ -24,20 +28,109 @@ export default function InteractiveSpeaking({
const [recordingDuration, setRecordingDuration] = useState(0);
const [isRecording, setIsRecording] = useState(false);
const [mediaBlob, setMediaBlob] = useState<string>();
const [promptIndex, setPromptIndex] = useState(0);
const [answers, setAnswers] = useState<{prompt: string; blob: string}[]>([]);
const [answers, setAnswers] = useState<{prompt: string; blob: string; questionIndex: number}[]>([]);
const [isLoading, setIsLoading] = useState(false);
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const saveToStorage = async (previousURL?: string) => {
if (mediaBlob && mediaBlob.startsWith("blob")) {
const blobBuffer = await downloadBlob(mediaBlob);
const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"});
const seed = Math.random().toString().replace("0.", "");
const formData = new FormData();
formData.append("audio", audioFile, `${seed}.wav`);
formData.append("root", "speaking_recordings");
const config = {
headers: {
"Content-Type": "audio/wav",
},
};
const response = await axios.post<{path: string}>("/api/storage/insert", formData, config);
if (previousURL && !previousURL.startsWith("blob")) await axios.post("/api/storage/delete", {path: previousURL});
return response.data.path;
}
return undefined;
};
const back = async () => {
setIsLoading(true);
const answer = await saveAnswer(questionIndex);
if (questionIndex - 1 >= 0) {
setQuestionIndex(questionIndex - 1);
setIsLoading(false);
return;
}
setIsLoading(false);
onBack({
exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 1, total: 1, missing: 0},
type,
});
};
const next = async () => {
setIsLoading(true);
const answer = await saveAnswer(questionIndex);
if (questionIndex + 1 < prompts.length) {
setQuestionIndex(questionIndex + 1);
setIsLoading(false);
return;
}
setIsLoading(false);
onNext({
exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 1, total: 1, missing: 0},
type,
});
};
useEffect(() => {
if (updateIndex) updateIndex(promptIndex);
}, [promptIndex, updateIndex]);
if (userSolutions.length > 0 && answers.length === 0) {
console.log(userSolutions);
const solutions = userSolutions as unknown as typeof answers;
setAnswers(solutions);
if (!mediaBlob) setMediaBlob(solutions.find((x) => x.questionIndex === questionIndex)?.blob);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userSolutions, mediaBlob, answers]);
useEffect(() => {
console.log({answers});
}, [answers]);
useEffect(() => {
if (updateIndex) updateIndex(questionIndex);
}, [questionIndex, updateIndex]);
useEffect(() => {
if (hasExamEnded) {
const answer = {
questionIndex,
prompt: prompts[questionIndex].text,
blob: mediaBlob!,
};
onNext({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 1, total: 1, missing: 0},
type,
});
@@ -59,19 +152,38 @@ export default function InteractiveSpeaking({
}, [isRecording]);
useEffect(() => {
if (promptIndex === answers.length - 1) {
setMediaBlob(answers[promptIndex].blob);
if (questionIndex <= answers.length - 1) {
const blob = answers.find((x) => x.questionIndex === questionIndex)?.blob;
setMediaBlob(blob);
}
}, [answers, promptIndex]);
}, [answers, questionIndex]);
const saveAnswer = async (index: number) => {
const previousURL = answers.find((x) => x.questionIndex === questionIndex)?.blob;
const audioPath = await saveToStorage(previousURL);
const saveAnswer = () => {
const answer = {
prompt: prompts[promptIndex].text,
blob: mediaBlob!,
questionIndex,
prompt: prompts[questionIndex].text,
blob: audioPath ? audioPath : mediaBlob!,
};
setAnswers((prev) => [...prev, answer]);
setAnswers((prev) => [...prev.filter((x) => x.questionIndex !== index), answer]);
setMediaBlob(undefined);
setUserSolutions([
...storeUserSolutions.filter((x) => x.exercise !== id),
{
exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 1, total: 1, missing: 0},
module: "speaking",
exam: examID,
type,
},
]);
return answer;
};
return (
@@ -82,8 +194,8 @@ export default function InteractiveSpeaking({
</div>
{prompts && prompts.length > 0 && (
<div className="flex flex-col gap-4 w-full items-center">
<video key={promptIndex} autoPlay controls className="max-w-3xl rounded-xl">
<source src={prompts[promptIndex].video_url} />
<video key={questionIndex} autoPlay controls className="max-w-3xl rounded-xl">
<source src={prompts[questionIndex].video_url} />
</video>
</div>
)}
@@ -91,13 +203,13 @@ export default function InteractiveSpeaking({
<ReactMediaRecorder
audio
key={promptIndex}
key={questionIndex}
onStop={(blob) => setMediaBlob(blob)}
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => (
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
<p className="text-base font-normal">Record your answer:</p>
<div className="flex gap-8 items-center justify-center py-8">
{status === "idle" && (
{status === "idle" && !mediaBlob && (
<>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
{status === "idle" && (
@@ -176,9 +288,9 @@ export default function InteractiveSpeaking({
</div>
</>
)}
{status === "stopped" && mediaBlobUrl && (
{((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && (
<>
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
<Waveform audio={mediaBlobUrl ? mediaBlobUrl : mediaBlob!} waveColor="#FCDDEC" progressColor="#EF5DA8" />
<div className="flex gap-4 items-center">
<BsTrashFill
className="text-mti-gray-cool cursor-pointer w-5 h-5"
@@ -208,44 +320,11 @@ export default function InteractiveSpeaking({
/>
<div className="self-end flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() =>
onBack({
exercise: id,
solutions: answers,
score: {correct: 1, total: 1, missing: 0},
type,
})
}
className="max-w-[200px] self-end w-full">
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
Back
</Button>
<Button
color="purple"
disabled={!mediaBlob}
onClick={() => {
saveAnswer();
if (promptIndex + 1 < prompts.length) {
setPromptIndex((prev) => prev + 1);
return;
}
onNext({
exercise: id,
solutions: [
...answers,
{
prompt: prompts[promptIndex].text,
blob: mediaBlob!,
},
],
score: {correct: 1, total: 1, missing: 0},
type,
});
}}
className="max-w-[200px] self-end w-full">
{promptIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
<Button color="purple" disabled={!mediaBlob} isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
</Button>
</div>
</div>

View File

@@ -59,10 +59,18 @@ export default function MultipleChoice({
onBack,
}: MultipleChoiceExercise & CommonProps) {
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
const [questionIndex, setQuestionIndex] = useState(0);
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
useEffect(() => {
setUserSolutions([...storeUserSolutions.filter((x) => x.exercise !== id), {exercise: id, solutions: answers, score: calculateScore(), type}]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers]);
useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -91,16 +99,20 @@ export default function MultipleChoice({
if (questionIndex === questions.length - 1) {
onNext({exercise: id, solutions: answers, score: calculateScore(), type});
} else {
setQuestionIndex((prev) => prev + 1);
setQuestionIndex(questionIndex + 1);
}
scrollToTop();
};
const back = () => {
if (questionIndex === 0) {
onBack({exercise: id, solutions: answers, score: calculateScore(), type});
} else {
setQuestionIndex((prev) => prev - 1);
setQuestionIndex(questionIndex - 1);
}
scrollToTop();
};
return (

View File

@@ -5,28 +5,58 @@ import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill}
import dynamic from "next/dynamic";
import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
import {downloadBlob} from "@/utils/evaluation";
import axios from "axios";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
ssr: false,
});
export default function Speaking({id, title, text, video_url, type, prompts, onNext, onBack}: SpeakingExercise & CommonProps) {
export default function Speaking({id, title, text, video_url, type, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
const [recordingDuration, setRecordingDuration] = useState(0);
const [isRecording, setIsRecording] = useState(false);
const [mediaBlob, setMediaBlob] = useState<string>();
const [audioURL, setAudioURL] = useState<string>();
const [isLoading, setIsLoading] = useState(false);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
if (hasExamEnded) {
onNext({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
});
const saveToStorage = async () => {
if (mediaBlob && mediaBlob.startsWith("blob")) {
const blobBuffer = await downloadBlob(mediaBlob);
const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"});
const seed = Math.random().toString().replace("0.", "");
const formData = new FormData();
formData.append("audio", audioFile, `${seed}.wav`);
formData.append("root", "speaking_recordings");
const config = {
headers: {
"Content-Type": "audio/wav",
},
};
const response = await axios.post<{path: string}>("/api/storage/insert", formData, config);
if (audioURL) await axios.post("/api/storage/delete", {path: audioURL});
return response.data.path;
}
return undefined;
};
useEffect(() => {
if (userSolutions.length > 0) {
const {solution} = userSolutions[0] as {solution?: string};
if (solution && !mediaBlob) setMediaBlob(solution);
if (solution && !solution.startsWith("blob")) setAudioURL(solution);
}
}, [userSolutions, mediaBlob]);
useEffect(() => {
if (hasExamEnded) next();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
@@ -43,6 +73,32 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
};
}, [isRecording]);
const next = async () => {
setIsLoading(true);
const storagePath = await saveToStorage();
setIsLoading(false);
onNext({
exercise: id,
solutions: storagePath ? [{id, solution: storagePath}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
});
};
const back = async () => {
setIsLoading(true);
const storagePath = await saveToStorage();
setIsLoading(false);
onBack({
exercise: id,
solutions: storagePath ? [{id, solution: storagePath}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
});
};
return (
<div className="flex flex-col h-full w-full gap-9">
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
@@ -89,7 +145,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
<p className="text-base font-normal">Record your answer:</p>
<div className="flex gap-8 items-center justify-center py-8">
{status === "idle" && (
{status === "idle" && !mediaBlob && (
<>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
{status === "idle" && (
@@ -168,9 +224,9 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
</div>
</>
)}
{status === "stopped" && mediaBlobUrl && (
{((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && (
<>
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
<Waveform audio={mediaBlobUrl ? mediaBlobUrl : mediaBlob!} waveColor="#FCDDEC" progressColor="#EF5DA8" />
<div className="flex gap-4 items-center">
<BsTrashFill
className="text-mti-gray-cool cursor-pointer w-5 h-5"
@@ -200,32 +256,10 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
/>
<div className="self-end flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() =>
onBack({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
})
}
className="max-w-[200px] self-end w-full">
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
Back
</Button>
<Button
color="purple"
disabled={!mediaBlob}
onClick={() =>
onNext({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
})
}
className="max-w-[200px] self-end w-full">
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
Next
</Button>
</div>

View File

@@ -22,9 +22,31 @@ export default function Writing({
const [isModalOpen, setIsModalOpen] = useState(false);
const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : "");
const [isSubmitEnabled, setIsSubmitEnabled] = useState(false);
const [saveTimer, setSaveTimer] = useState(0);
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
const saveTimerInterval = setInterval(() => {
setSaveTimer((prev) => prev + 1);
}, 1000);
return () => {
clearInterval(saveTimerInterval);
};
}, []);
useEffect(() => {
if (inputText.length > 0 && saveTimer % 10 === 0) {
setUserSolutions([
...storeUserSolutions.filter((x) => x.exercise !== id),
{exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type},
]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [saveTimer]);
useEffect(() => {
if (localStorage.getItem("enable_paste")) return;

View File

@@ -22,6 +22,7 @@ import InteractiveSpeaking from "./InteractiveSpeaking";
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false});
export interface CommonProps {
examID?: string;
updateIndex?: (internalIndex: number) => void;
onNext: (userSolutions: UserSolution) => void;
onBack: (userSolutions: UserSolution) => void;
@@ -29,17 +30,18 @@ export interface CommonProps {
export const renderExercise = (
exercise: Exercise,
examID: string,
onNext: (userSolutions: UserSolution) => void,
onBack: (userSolutions: UserSolution) => void,
updateIndex?: (internalIndex: number) => void,
) => {
switch (exercise.type) {
case "fillBlanks":
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
case "trueFalse":
return <TrueFalse key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
return <TrueFalse key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
case "matchSentences":
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
case "multipleChoice":
return (
<MultipleChoice
@@ -48,19 +50,21 @@ export const renderExercise = (
updateIndex={updateIndex}
onNext={onNext}
onBack={onBack}
examID={examID}
/>
);
case "writeBlanks":
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
case "writing":
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
case "speaking":
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
case "interactiveSpeaking":
return (
<InteractiveSpeaking
key={exercise.id}
{...(exercise as InteractiveSpeakingExercise)}
examID={examID}
updateIndex={updateIndex}
onNext={onNext}
onBack={onBack}

View File

@@ -137,7 +137,7 @@ export default function TicketDisplay({ user, ticket, onClose }: Props) {
options={[
{ value: "me", label: "Assign to me" },
...users
.filter((x) => ["admin", "developer"].includes(x.type))
.filter((x) => ["admin", "developer", "agent"].includes(x.type))
.map((u) => ({
value: u.id,
label: `${u.name} - ${u.email}`,

View File

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

View File

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

View File

@@ -0,0 +1,101 @@
import {Session} from "@/hooks/useSessions";
import useExamStore from "@/stores/examStore";
import {sortByModuleName} from "@/utils/moduleUtils";
import axios from "axios";
import clsx from "clsx";
import {capitalize} from "lodash";
import moment from "moment";
import {useState} from "react";
import {BsArrowRepeat, BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
import {toast} from "react-toastify";
export default function SessionCard({
session,
reload,
loadSession,
}: {
session: Session;
reload: () => void;
loadSession: (session: Session) => Promise<void>;
}) {
const [isLoading, setIsLoading] = useState(false);
const deleteSession = async () => {
if (!confirm("Are you sure you want to delete this session?")) return;
setIsLoading(true);
await axios
.delete(`/api/sessions/${session.sessionId}`)
.then(() => {
toast.success(`Successfully delete session "${session.sessionId}"`);
})
.catch((e) => {
console.log(e);
toast.error("Something went wrong, please try again later");
})
.finally(() => {
reload();
setIsLoading(false);
});
};
return (
<div className="border-mti-gray-anti-flash flex w-64 flex-col gap-3 rounded-xl border p-4 text-black">
<span className="flex gap-1">
<b>ID:</b>
{session.sessionId}
</span>
<span className="flex gap-1">
<b>Date:</b>
{moment(session.date).format("DD/MM/YYYY - HH:mm")}
</span>
<div className="flex w-full items-center justify-between">
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-center justify-center gap-2">
{session.selectedModules.sort(sortByModuleName).map((module) => (
<div
key={module}
data-tip={capitalize(module)}
className={clsx(
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
module === "reading" && "bg-ielts-reading",
module === "listening" && "bg-ielts-listening",
module === "writing" && "bg-ielts-writing",
module === "speaking" && "bg-ielts-speaking",
module === "level" && "bg-ielts-level",
)}>
{module === "reading" && <BsBook className="h-4 w-4" />}
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
{module === "writing" && <BsPen className="h-4 w-4" />}
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
{module === "level" && <BsClipboard className="h-4 w-4" />}
</div>
))}
</div>
</div>
<div className="flex items-center gap-2 w-full">
<button
onClick={async () => await loadSession(session)}
disabled={isLoading}
className="bg-mti-green-ultralight w-full hover:bg-mti-green-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
{!isLoading && "Resume"}
{isLoading && (
<div className="flex items-center justify-center">
<BsArrowRepeat className="animate-spin text-white" size={25} />
</div>
)}
</button>
<button
onClick={deleteSession}
disabled={isLoading}
className="bg-mti-red-ultralight w-full hover:bg-mti-red-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
{!isLoading && "Delete"}
{isLoading && (
<div className="flex items-center justify-center">
<BsArrowRepeat className="animate-spin text-white" size={25} />
</div>
)}
</button>
</div>
</div>
);
}

View File

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

View File

@@ -11,7 +11,7 @@ interface Props {
export default function Modal({isOpen, title, onClose, children}: Props) {
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={onClose}>
<Dialog as="div" className="relative z-[200]" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"

View File

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

View File

@@ -19,7 +19,8 @@ import {toast} from "react-toastify";
import {uuidv4} from "@firebase/util";
import {Assignment} from "@/interfaces/results";
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 {
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(),
);
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
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
@@ -63,6 +65,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
selectedModules,
generateMultiple,
variant,
instructorGender,
})
.then(() => {
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 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">
<span className="font-semibold">Assignees ({assignees.length} selected)</span>
<div className="flex gap-4 overflow-x-scroll scrollbar-hide">

View File

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

View File

@@ -86,16 +86,19 @@ export default function StudentDashboard({user}: Props) {
icon: <BsFileEarmarkText className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
value: Object.keys(groupBySession(stats)).length,
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" />,
value: stats.length,
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" />,
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
label: "Average Score",
tooltip: "Average success rate for questions responded",
},
]}
/>

View File

@@ -163,6 +163,7 @@ export default function TeacherDashboard({user}: Props) {
onClose={() => {
setSelectedAssignment(undefined);
setIsCreatingAssignment(false);
reloadAssignments();
}}
assignment={selectedAssignment}
/>

View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
</head>
<div style="background-color: #ffffff; color: #353338;"
class="h-full min-h-screen w-full flex flex-col p-8 gap-16 text-base">
<img src="/logo_title.png" class="w-48 h-48 self-center" />
<div>
<span>Your ticket has been completed!</span>
<br/>
<span>Here is the ticket's information:</span>
<br/>
<br/>
<span><b>ID:</b> {{id}}</span><br/>
<span><b>Subject:</b> {{subject}}</span><br/>
<span><b>Reporter:</b> {{reporter.name}} - {{reporter.email}}</span><br/>
<span><b>Date:</b> {{date}}</span><br/>
<span><b>Type:</b> {{type}}</span><br/>
<span><b>Page:</b> {{reportedFrom}}</span>
<br/>
<br/>
<span><b>Description:</b> {{description}}</span><br/>
</div>
<br />
<br />
<div>
<span>Thanks, <br /> Your EnCoach team</span>
</div>
</div>
</html>

View File

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

View File

@@ -22,9 +22,9 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
const [questionIndex, setQuestionIndex] = useState(0);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [exerciseIndex, setExerciseIndex] = useState(0);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
useEffect(() => {
setCurrentQuestionIndex(0);
@@ -38,7 +38,7 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
const nextExercise = (solution?: UserSolution) => {
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
}
setQuestionIndex((prev) => prev + currentQuestionIndex);
@@ -62,7 +62,7 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
const previousExercise = (solution?: UserSolution) => {
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
}
if (exerciseIndex > 0) {
@@ -91,7 +91,7 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
!showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
showSolutions &&

View File

@@ -7,7 +7,6 @@ import AudioPlayer from "@/components/Low/AudioPlayer";
import Button from "@/components/Low/Button";
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils";
interface Props {
@@ -16,24 +15,35 @@ interface Props {
onFinish: (userSolutions: UserSolution[]) => void;
}
const INSTRUCTIONS_AUDIO_SRC =
"https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_recordings%2Fgeneric_intro.mp3?alt=media&token=9b9cfdb8-e90d-40d1-854b-51c4378a5c4b";
export default function Listening({exam, showSolutions = false, onFinish}: Props) {
const [questionIndex, setQuestionIndex] = useState(0);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [exerciseIndex, setExerciseIndex] = useState(showSolutions ? 0 : -1);
const [partIndex, setPartIndex] = useState(0);
const [timesListened, setTimesListened] = useState(0);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(
exam.parts.flatMap((x) => x.exercises).map((x) => defaultUserSolutions(x, exam)),
);
const [showBlankModal, setShowBlankModal] = useState(false);
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
const {partIndex, setPartIndex} = useExamStore((state) => state);
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
useEffect(() => {
if (showSolutions) return setExerciseIndex(-1);
}, [setExerciseIndex, showSolutions]);
// useEffect(() => {
// if (exam.variant !== "partial") setPartIndex(-1);
// }, [exam.variant, setPartIndex]);
useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) {
setExerciseIndex((prev) => prev + 1);
setExerciseIndex(exerciseIndex + 1);
}
}, [hasExamEnded, exerciseIndex]);
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
useEffect(() => {
setCurrentQuestionIndex(0);
@@ -49,18 +59,19 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
};
const nextExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
}
setQuestionIndex((prev) => prev + currentQuestionIndex);
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
setExerciseIndex((prev) => prev + 1);
setExerciseIndex(exerciseIndex + 1);
return;
}
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
setPartIndex((prev) => prev + 1);
setPartIndex(partIndex + 1);
setExerciseIndex(showSolutions ? 0 : -1);
return;
}
@@ -89,11 +100,12 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
};
const previousExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
}
setExerciseIndex((prev) => prev - 1);
setExerciseIndex(exerciseIndex - 1);
};
const getExercise = () => {
@@ -104,6 +116,17 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
};
};
const renderAudioInstructionsPlayer = () => (
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
<div className="flex flex-col w-full gap-2">
<h4 className="text-xl font-semibold">Please listen to the instructions audio attentively.</h4>
</div>
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
<AudioPlayer key={partIndex} src={INSTRUCTIONS_AUDIO_SRC} color="listening" />
</div>
</div>
);
const renderAudioPlayer = () => (
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
<div className="flex flex-col w-full gap-2">
@@ -133,38 +156,53 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
<div className="flex flex-col h-full w-full gap-8 justify-between">
<ModuleTitle
exerciseIndex={
(exam.parts
.flatMap((x) => x.exercises)
.findIndex(
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
) || 0) +
(exerciseIndex === -1 ? 0 : 1) +
questionIndex +
currentQuestionIndex
partIndex === -1
? 0
: (exam.parts
.flatMap((x) => x.exercises)
.findIndex(
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
) || 0) +
(exerciseIndex === -1 ? 0 : 1) +
questionIndex +
currentQuestionIndex
}
minTimer={exam.minTimer}
module="listening"
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
disableTimer={showSolutions}
/>
{renderAudioPlayer()}
{/* Audio Player for the Instructions */}
{partIndex === -1 && renderAudioInstructionsPlayer()}
{/* Part's audio player */}
{partIndex > -1 && renderAudioPlayer()}
{/* Exercise renderer */}
{exerciseIndex > -1 &&
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
{/* Solution renderer */}
{exerciseIndex > -1 &&
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
showSolutions &&
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
</div>
{exerciseIndex === -1 && partIndex > 0 && (
{exerciseIndex === -1 && partIndex > -1 && exam.variant !== "partial" && (
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => {
if (partIndex === 0) return setPartIndex(-1);
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
setPartIndex((prev) => prev - 1);
setPartIndex(partIndex - 1);
}}
className="max-w-[200px] w-full">
Back
@@ -175,7 +213,13 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
</Button>
</div>
)}
{exerciseIndex === -1 && partIndex === 0 && (
{partIndex === -1 && exam.variant !== "partial" && (
<Button color="purple" onClick={() => setPartIndex(0)} className="max-w-[200px] self-end w-full justify-self-end">
Start now
</Button>
)}
{exerciseIndex === -1 && partIndex === 0 && exam.variant === "partial" && (
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end">
Start now
</Button>

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) {
const [questionIndex, setQuestionIndex] = useState(0);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [exerciseIndex, setExerciseIndex] = useState(showSolutions ? 0 : -1);
const [partIndex, setPartIndex] = useState(0);
const [showTextModal, setShowTextModal] = useState(false);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(
exam.parts.flatMap((x) => x.exercises).map((x) => defaultUserSolutions(x, exam)),
);
const [showBlankModal, setShowBlankModal] = useState(false);
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
const {partIndex, setPartIndex} = useExamStore((state) => state);
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
const setStoreQuestionIndex = useExamStore((state) => state.setQuestionIndex);
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
useEffect(() => {
if (showSolutions) setExerciseIndex(-1);
}, [setExerciseIndex, showSolutions]);
useEffect(() => {
const listener = (e: KeyboardEvent) => {
@@ -113,9 +118,9 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) {
setExerciseIndex((prev) => prev + 1);
setExerciseIndex(exerciseIndex + 1);
}
}, [hasExamEnded, exerciseIndex]);
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
const confirmFinishModule = (keepGoing?: boolean) => {
if (!keepGoing) {
@@ -127,18 +132,20 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
};
const nextExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
}
setQuestionIndex((prev) => prev + currentQuestionIndex);
setStoreQuestionIndex(0);
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
setExerciseIndex((prev) => prev + 1);
setExerciseIndex(exerciseIndex + 1);
return;
}
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
setPartIndex((prev) => prev + 1);
setPartIndex(partIndex + 1);
setExerciseIndex(showSolutions ? 0 : -1);
return;
}
@@ -167,11 +174,13 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
};
const previousExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
}
setStoreQuestionIndex(0);
setExerciseIndex((prev) => prev - 1);
setExerciseIndex(exerciseIndex - 1);
};
const getExercise = () => {
@@ -206,14 +215,17 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
<>
<div className="flex flex-col h-full w-full gap-8">
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
<TextModal {...exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />
{partIndex > -1 && <TextModal {...exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />}
<ModuleTitle
minTimer={exam.minTimer}
exerciseIndex={
(exam.parts
.flatMap((x) => x.exercises)
.findIndex(
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
(x) =>
x.id ===
exam.parts[partIndex > -1 ? partIndex : 0].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]
?.id,
) || 0) +
(exerciseIndex === -1 ? 0 : 1) +
questionIndex +
@@ -225,17 +237,21 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
label={exerciseIndex === -1 ? undefined : convertCamelCaseToReadable(exam.parts[partIndex].exercises[exerciseIndex].type)}
/>
<div className={clsx("mb-20 w-full", exerciseIndex > -1 && "grid grid-cols-2 gap-4")}>
{renderText()}
{partIndex > -1 && renderText()}
{exerciseIndex > -1 &&
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
{exerciseIndex > -1 &&
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
showSolutions &&
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
</div>
{exerciseIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
{exerciseIndex > -1 && partIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
<Button
color="purple"
variant="outline"
@@ -252,7 +268,7 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
variant="outline"
onClick={() => {
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
setPartIndex((prev) => prev - 1);
setPartIndex(partIndex - 1);
}}
className="max-w-[200px] w-full">
Back

View File

@@ -4,7 +4,7 @@ import {Module} from "@/interfaces";
import clsx from "clsx";
import {User} from "@/interfaces/user";
import ProgressBar from "@/components/Low/ProgressBar";
import {BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
import {BsArrowRepeat, BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
import {totalExamsByModule} from "@/utils/stats";
import useStats from "@/hooks/useStats";
import Button from "@/components/Low/Button";
@@ -13,6 +13,10 @@ import {sortByModuleName} from "@/utils/moduleUtils";
import {capitalize} from "lodash";
import ProfileSummary from "@/components/ProfileSummary";
import {Variant} from "@/interfaces/exam";
import useSessions, {Session} from "@/hooks/useSessions";
import SessionCard from "@/components/Medium/SessionCard";
import useExamStore from "@/stores/examStore";
import moment from "moment";
interface Props {
user: User;
@@ -25,13 +29,32 @@ export default function Selection({user, page, onStart, disableSelection = false
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
const [variant, setVariant] = useState<Variant>("full");
const {stats} = useStats(user?.id);
const {sessions, isLoading, reload} = useSessions(user.id);
const state = useExamStore((state) => state);
const toggleModule = (module: Module) => {
const modules = selectedModules.filter((x) => x !== module);
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module]));
};
const loadSession = async (session: Session) => {
state.setSelectedModules(session.selectedModules);
state.setExam(session.exam);
state.setExams(session.exams);
state.setSessionId(session.sessionId);
state.setAssignment(session.assignment);
state.setExerciseIndex(session.exerciseIndex);
state.setPartIndex(session.partIndex);
state.setModuleIndex(session.moduleIndex);
state.setTimeSpent(session.timeSpent);
state.setUserSolutions(session.userSolutions);
state.setShowSolutions(false);
state.setQuestionIndex(session.questionIndex);
};
return (
<>
<div className="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>
</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
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
className={clsx(

View File

@@ -22,22 +22,26 @@ interface Props {
export default function Speaking({exam, showSolutions = false, onFinish}: Props) {
const [questionIndex, setQuestionIndex] = useState(0);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [exerciseIndex, setExerciseIndex] = useState(0);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
useEffect(() => {
setCurrentQuestionIndex(0);
}, [questionIndex]);
const nextExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
}
setQuestionIndex((prev) => prev + currentQuestionIndex);
if (exerciseIndex + 1 < exam.exercises.length) {
setExerciseIndex((prev) => prev + 1);
setExerciseIndex(exerciseIndex + 1);
return;
}
@@ -55,12 +59,13 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
};
const previousExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
}
if (exerciseIndex > 0) {
setExerciseIndex((prev) => prev - 1);
setExerciseIndex(exerciseIndex - 1);
}
};
@@ -86,7 +91,7 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
!showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
showSolutions &&

View File

@@ -19,18 +19,20 @@ interface Props {
}
export default function Writing({exam, showSolutions = false, onFinish}: Props) {
const [exerciseIndex, setExerciseIndex] = useState(0);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
const nextExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
}
if (exerciseIndex + 1 < exam.exercises.length) {
setExerciseIndex((prev) => prev + 1);
setExerciseIndex(exerciseIndex + 1);
return;
}
@@ -48,12 +50,13 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
};
const previousExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
}
if (exerciseIndex > 0) {
setExerciseIndex((prev) => prev - 1);
setExerciseIndex(exerciseIndex - 1);
}
};
@@ -78,7 +81,7 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
!showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise)}
renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
showSolutions &&

View File

@@ -37,15 +37,7 @@ const TestReportFooter = ({ userId }: Props) => (
</Text>
</View>
</View>
{userId && (
<View>
<Text style={styles.textBold}>
User ID:{" "}
<Text style={[styles.textFont, styles.textNormal]}>{userId}</Text>
</Text>
</View>
)}
<View style={{ paddingTop: 4 }}>
<View>
<Text style={styles.textBold}>Declaration</Text>
<Text style={{ paddingTop: 5 }}>
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.
</Text>
</View>
<View style={{ paddingTop: 4 }}>
<Text style={styles.textBold}>
PDF Version:{" "}
<Text style={[styles.textFont, styles.textNormal]}>
{process.env.PDF_VERSION}
</Text>
</Text>
</View>
{userId && (
<View>
<Text style={styles.textBold}>
User ID:{" "}
<Text style={[styles.textFont, styles.textNormal]}>{userId}</Text>
</Text>
</View>
)}
<View style={[styles.textColor, { paddingTop: 5 }]}>
<Text style={styles.textUnderline}>info@encoach.com</Text>
<Text>https://encoach.com</Text>

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 Variant = "full" | "partial";
export type InstructorGender = "male" | "female" | "varied";
export type Difficulty = "easy" | "medium" | "hard";
export interface ReadingExam {
parts: ReadingPart[];
@@ -11,6 +13,7 @@ export interface ReadingExam {
type: "academic" | "general";
isDiagnostic: boolean;
variant?: Variant;
difficulty?: Difficulty;
}
export interface ReadingPart {
@@ -28,6 +31,7 @@ export interface LevelExam {
minTimer: number;
isDiagnostic: boolean;
variant?: Variant;
difficulty?: Difficulty;
}
export interface ListeningExam {
@@ -37,6 +41,7 @@ export interface ListeningExam {
minTimer: number;
isDiagnostic: boolean;
variant?: Variant;
difficulty?: Difficulty;
}
export interface ListeningPart {
@@ -68,6 +73,7 @@ export interface WritingExam {
minTimer: number;
isDiagnostic: boolean;
variant?: Variant;
difficulty?: Difficulty;
}
interface WordCounter {
@@ -82,6 +88,8 @@ export interface SpeakingExam {
minTimer: number;
isDiagnostic: boolean;
variant?: Variant;
instructorGender: InstructorGender;
difficulty?: Difficulty;
}
export type Exercise =
@@ -164,7 +172,7 @@ export interface InteractiveSpeakingExercise {
prompts: {text: string; video_url: string}[];
userSolutions: {
id: string;
solution: {question: string; answer: string}[];
solution: {questionIndex: number; question: string; answer: string}[];
evaluation?: InteractiveSpeakingEvaluation;
}[];
}

View File

@@ -1,4 +1,5 @@
import {Module} from "@/interfaces";
import {InstructorGender} from "./exam";
import {Stat} from "./user";
export type UserResults = {[key in Module]: ModuleResult};
@@ -19,7 +20,8 @@ export interface Assignment {
type: "academic" | "general";
stats: Stat[];
}[];
exams: {id: string; module: Module, assignee: string}[];
exams: {id: string; module: Module; assignee: string}[];
instructorGender?: InstructorGender;
startDate: Date;
endDate: Date;
}

View File

@@ -1,4 +1,5 @@
import {Module} from ".";
import {InstructorGender} from "./exam";
export type User = StudentUser | TeacherUser | CorporateUser | AgentUser | AdminUser | DeveloperUser;
@@ -21,6 +22,7 @@ export interface BasicUser {
export interface StudentUser extends BasicUser {
type: "student";
preferredGender?: InstructorGender;
demographicInformation?: DemographicInformation;
}
@@ -48,6 +50,7 @@ export interface AdminUser extends BasicUser {
export interface DeveloperUser extends BasicUser {
type: "developer";
preferredGender?: InstructorGender;
demographicInformation?: DemographicInformation;
}

View File

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

View File

@@ -1,23 +1,30 @@
import {LevelExam, MultipleChoiceExercise} from "@/interfaces/exam";
import Select from "@/components/Low/Select";
import {Difficulty, LevelExam, MultipleChoiceExercise} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams";
import {playSound} from "@/utils/sound";
import {Tab} from "@headlessui/react";
import axios from "axios";
import clsx from "clsx";
import {capitalize, sample} from "lodash";
import {useRouter} from "next/router";
import {useState} from "react";
import {BsArrowRepeat} from "react-icons/bs";
import {toast} from "react-toastify";
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 generate = () => {
const url = new URLSearchParams();
url.append("difficulty", difficulty);
setIsLoading(true);
axios
.get(`/api/exam/level/generate/level`)
.get(`/api/exam/level/generate/level?${url.toString()}`)
.then((result) => {
playSound(typeof result.data === "string" ? "error" : "check");
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 [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<LevelExam>();
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
const router = useRouter();
@@ -163,6 +171,16 @@ const LevelGeneration = () => {
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.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1">
<Tab
@@ -178,7 +196,7 @@ const LevelGeneration = () => {
</Tab>
</Tab.List>
<Tab.Panels>
<TaskTab exam={generatedExam} setExam={setGeneratedExam} />
<TaskTab difficulty={difficulty} exam={generatedExam} setExam={setGeneratedExam} />
</Tab.Panels>
</Tab.Group>
<div className="w-full flex justify-end gap-4">

View File

@@ -1,5 +1,6 @@
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 {getExamById} from "@/utils/exams";
import {playSound} from "@/utils/sound";
@@ -7,17 +8,34 @@ import {convertCamelCaseToReadable} from "@/utils/string";
import {Tab} from "@headlessui/react";
import axios from "axios";
import clsx from "clsx";
import {capitalize, sample} from "lodash";
import {useRouter} from "next/router";
import {useEffect, useState} from "react";
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
import {toast} from "react-toastify";
const PartTab = ({part, types, index, setPart}: {part?: ListeningPart; types: string[]; index: number; setPart: (part?: ListeningPart) => void}) => {
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 [isLoading, setIsLoading] = useState(false);
const generate = () => {
const url = new URLSearchParams();
url.append("difficulty", difficulty);
if (topic) url.append("topic", topic);
if (types) types.forEach((t) => url.append("exercises", t));
@@ -115,6 +133,7 @@ const ListeningGeneration = () => {
const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<ListeningExam>();
const [types, setTypes] = useState<string[]>([]);
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
useEffect(() => {
const part1Timer = part1 ? 5 : 0;
@@ -148,7 +167,7 @@ const ListeningGeneration = () => {
setIsLoading(true);
axios
.post(`/api/exam/listening/generate/listening`, {parts, minTimer})
.post(`/api/exam/listening/generate/listening`, {parts, minTimer, difficulty})
.then((result) => {
playSound("sent");
console.log(`Generated Exam ID: ${result.data.id}`);
@@ -159,6 +178,7 @@ const ListeningGeneration = () => {
setPart2(undefined);
setPart3(undefined);
setPart4(undefined);
setDifficulty(sample(DIFFICULTIES)!);
setTypes([]);
})
.catch((error) => {
@@ -186,15 +206,26 @@ const ListeningGeneration = () => {
return (
<>
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
<Input
type="number"
name="minTimer"
onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))}
value={minTimer}
className="max-w-[300px]"
/>
<div className="flex gap-4 w-1/2">
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
<Input
type="number"
name="minTimer"
onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))}
value={minTimer}
className="max-w-[300px]"
/>
</div>
<div className="flex flex-col gap-3 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">
@@ -271,7 +302,7 @@ const ListeningGeneration = () => {
{part: part3, setPart: setPart3},
{part: part4, setPart: setPart4},
].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.Group>

View File

@@ -1,5 +1,6 @@
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 {getExamById} from "@/utils/exams";
import {playSound} from "@/utils/sound";
@@ -7,18 +8,35 @@ import {convertCamelCaseToReadable} from "@/utils/string";
import {Tab} from "@headlessui/react";
import axios from "axios";
import clsx from "clsx";
import {capitalize, sample} from "lodash";
import {useRouter} from "next/router";
import {useEffect, useState} from "react";
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
import {toast} from "react-toastify";
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 [isLoading, setIsLoading] = useState(false);
const generate = () => {
const url = new URLSearchParams();
url.append("difficulty", difficulty);
if (topic) url.append("topic", topic);
if (types) types.forEach((t) => url.append("exercises", t));
@@ -92,6 +110,7 @@ const ReadingGeneration = () => {
const [types, setTypes] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<ReadingExam>();
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
useEffect(() => {
const parts = [part1, part2, part3].filter((x) => !!x);
@@ -144,6 +163,7 @@ const ReadingGeneration = () => {
id: v4(),
type: "academic",
variant: parts.length === 3 ? "full" : "partial",
difficulty,
};
axios
@@ -157,6 +177,7 @@ const ReadingGeneration = () => {
setPart1(undefined);
setPart2(undefined);
setPart3(undefined);
setDifficulty(sample(DIFFICULTIES)!);
setMinTimer(60);
setTypes([]);
})
@@ -169,15 +190,26 @@ const ReadingGeneration = () => {
return (
<>
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
<Input
type="number"
name="minTimer"
onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))}
value={minTimer}
className="max-w-[300px]"
/>
<div className="flex gap-4 w-1/2">
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
<Input
type="number"
name="minTimer"
onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))}
value={minTimer}
className="max-w-[300px]"
/>
</div>
<div className="flex flex-col gap-3 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">
@@ -240,7 +272,7 @@ const ReadingGeneration = () => {
{part: part2, setPart: setPart2},
{part: part3, setPart: setPart3},
].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.Group>

View File

@@ -1,5 +1,7 @@
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 {getExamById} from "@/utils/exams";
import {playSound} from "@/utils/sound";
@@ -7,6 +9,7 @@ import {convertCamelCaseToReadable} from "@/utils/string";
import {Tab} from "@headlessui/react";
import axios from "axios";
import clsx from "clsx";
import {capitalize, sample, uniq} from "lodash";
import moment from "moment";
import {useRouter} from "next/router";
import {useEffect, useState} from "react";
@@ -14,15 +17,31 @@ import {BsArrowRepeat, BsCheck} from "react-icons/bs";
import {toast} from "react-toastify";
import {v4} from "uuid";
const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; setPart: (part?: SpeakingPart) => void}) => {
const 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 generate = () => {
setPart(undefined);
setIsLoading(true);
const url = new URLSearchParams();
url.append("difficulty", difficulty);
axios
.get(`/api/exam/speaking/generate/speaking_task_${index}`)
.get(`/api/exam/speaking/generate/speaking_task_${index}?${url.toString()}`)
.then((result) => {
playSound(typeof result.data === "string" ? "error" : "check");
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!");
toast.info("This will take quite a while, please do not leave this page or close the tab/window.");
const avatar = sample(AVATARS.filter((x) => x.gender === gender));
setIsLoading(true);
const initialTime = moment();
axios
.post(`/api/exam/speaking/generate/speaking/generate_${index === 3 ? "interactive" : "speaking"}_video`, part)
.post(`/api/exam/speaking/generate/speaking/generate_${index === 3 ? "interactive" : "speaking"}_video`, {...part, avatar: avatar?.id})
.then((result) => {
const isError = typeof result.data === "string" || moment().diff(initialTime, "seconds") < 60;
playSound(isError ? "error" : "check");
if (isError) return toast.error("Something went wrong, please try to generate the video again.");
setPart({...part, result: result.data});
setPart({...part, result: result.data, gender, avatar});
})
.catch((e) => {
toast.error("Something went wrong!");
@@ -60,6 +81,18 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
return (
<Tab.Panel className="w-full bg-ielts-speaking/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
<div className="flex 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">
<button
onClick={generate}
@@ -128,6 +161,11 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
</div>
)}
{part.result && <span className="font-bold mt-4">Video Generated: </span>}
{part.avatar && part.gender && (
<span>
<b>Instructor:</b> {part.avatar.name} - {capitalize(part.avatar.gender)}
</span>
)}
</div>
)}
</Tab.Panel>
@@ -140,6 +178,8 @@ interface SpeakingPart {
questions?: string[];
topic: string;
result?: SpeakingExercise | InteractiveSpeakingExercise;
gender?: "male" | "female";
avatar?: (typeof AVATARS)[number];
}
const SpeakingGeneration = () => {
@@ -149,6 +189,7 @@ const SpeakingGeneration = () => {
const [minTimer, setMinTimer] = useState(14);
const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<SpeakingExam>();
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
useEffect(() => {
const parts = [part1, part2, part3].filter((x) => !!x);
@@ -165,6 +206,8 @@ const SpeakingGeneration = () => {
setIsLoading(true);
const genders = [part1?.gender, part2?.gender, part3?.gender].filter((x) => !!x);
const exam: SpeakingExam = {
id: v4(),
isDiagnostic: false,
@@ -172,6 +215,7 @@ const SpeakingGeneration = () => {
minTimer,
variant: minTimer >= 14 ? "full" : "partial",
module: "speaking",
instructorGender: genders.every((x) => x === "male") ? "male" : genders.every((x) => x === "female") ? "female" : "varied",
};
axios
@@ -185,6 +229,7 @@ const SpeakingGeneration = () => {
setPart1(undefined);
setPart2(undefined);
setPart3(undefined);
setDifficulty(sample(DIFFICULTIES)!);
setMinTimer(14);
})
.catch((error) => {
@@ -212,15 +257,26 @@ const SpeakingGeneration = () => {
return (
<>
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
<Input
type="number"
name="minTimer"
onChange={(e) => setMinTimer(parseInt(e) < 5 ? 5 : parseInt(e))}
value={minTimer}
className="max-w-[300px]"
/>
<div className="flex gap-4 w-1/2">
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
<Input
type="number"
name="minTimer"
onChange={(e) => setMinTimer(parseInt(e) < 5 ? 5 : parseInt(e))}
value={minTimer}
className="max-w-[300px]"
/>
</div>
<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>
@@ -265,7 +321,7 @@ const SpeakingGeneration = () => {
{part: part2, setPart: setPart2},
{part: part3, setPart: setPart3},
].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.Group>

View File

@@ -1,24 +1,32 @@
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 {getExamById} from "@/utils/exams";
import {playSound} from "@/utils/sound";
import {Tab} from "@headlessui/react";
import axios from "axios";
import clsx from "clsx";
import {capitalize, sample} from "lodash";
import {useRouter} from "next/router";
import {useEffect, useState} from "react";
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
import {toast} from "react-toastify";
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 generate = () => {
setIsLoading(true);
const url = new URLSearchParams();
url.append("difficulty", difficulty);
axios
.get(`/api/exam/writing/generate/writing_task${index}_general`)
.get(`/api/exam/writing/generate/writing_task${index}_general?${url.toString()}`)
.then((result) => {
playSound(typeof result.data === "string" ? "error" : "check");
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 [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<WritingExam>();
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
useEffect(() => {
const task1Timer = task1 ? 20 : 0;
@@ -144,6 +153,7 @@ const WritingGeneration = () => {
exercises: [...(exercise1 ? [exercise1] : []), ...(exercise2 ? [exercise2] : [])],
id: v4(),
variant: exercise1 && exercise2 ? "full" : "partial",
difficulty,
};
axios
@@ -156,6 +166,7 @@ const WritingGeneration = () => {
setTask1(undefined);
setTask2(undefined);
setDifficulty(sample(DIFFICULTIES)!);
})
.catch((error) => {
console.log(error);
@@ -166,15 +177,26 @@ const WritingGeneration = () => {
return (
<>
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
<Input
type="number"
name="minTimer"
onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))}
value={minTimer}
className="max-w-[300px]"
/>
<div className="flex gap-4 w-1/2">
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
<Input
type="number"
name="minTimer"
onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))}
value={minTimer}
className="max-w-[300px]"
/>
</div>
<div className="flex flex-col gap-3 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>
@@ -207,7 +229,7 @@ const WritingGeneration = () => {
{task: task1, setTask: setTask1},
{task: task2, setTask: setTask2},
].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.Group>

View File

@@ -10,6 +10,7 @@ import { toast } from "react-toastify";
import { KeyedMutator } from "swr";
import Select from "react-select";
import moment from "moment";
import useAcceptedTerms from "@/hooks/useAcceptedTerms";
interface Props {
isLoading: boolean;
@@ -40,6 +41,7 @@ export default function RegisterCorporate({
const [companyName, setCompanyName] = useState("");
const [companyUsers, setCompanyUsers] = useState(0);
const [subscriptionDuration, setSubscriptionDuration] = useState(1);
const {acceptedTerms, renderCheckbox} = useAcceptedTerms();
const { users } = useUsers();
@@ -257,7 +259,9 @@ export default function RegisterCorporate({
/>
</div>
</div>
<div className="flex w-full flex-col items-start gap-4">
{renderCheckbox()}
</div>
<Button
className="w-full lg:mt-8"
color="purple"

View File

@@ -4,9 +4,10 @@ import Input from "@/components/Low/Input";
import { User } from "@/interfaces/user";
import { sendEmailVerification } from "@/utils/email";
import axios from "axios";
import { useEffect, useState } from "react";
import { useState } from "react";
import { toast } from "react-toastify";
import { KeyedMutator } from "swr";
import useAcceptedTerms from "@/hooks/useAcceptedTerms";
interface Props {
queryCode?: string;
@@ -35,6 +36,7 @@ export default function RegisterIndividual({
const [confirmPassword, setConfirmPassword] = useState("");
const [code, setCode] = useState(queryCode || "");
const [hasCode, setHasCode] = useState<boolean>(!!queryCode);
const {acceptedTerms, renderCheckbox} = useAcceptedTerms();
const onSuccess = () =>
toast.success(
@@ -146,7 +148,9 @@ export default function RegisterIndividual({
/>
)}
</div>
<div className="flex w-full flex-col items-start gap-4">
{renderCheckbox()}
</div>
<Button
className="w-full lg:mt-8"
color="purple"
@@ -156,6 +160,7 @@ export default function RegisterIndividual({
!name ||
!password ||
!confirmPassword ||
!acceptedTerms ||
password !== confirmPassword ||
(hasCode ? !code : false)
}

View File

@@ -10,9 +10,10 @@ import {useRouter} from "next/router";
import {useEffect} from "react";
import useExamStore from "@/stores/examStore";
import usePreferencesStore from "@/stores/preferencesStore";
import axios from "axios";
export default function App({Component, pageProps}: AppProps) {
const reset = useExamStore((state) => state.reset);
const {reset} = useExamStore((state) => state);
const setIsSidebarMinimized = usePreferencesStore((state) => state.setSidebarMinimized);
const router = useRouter();

View File

@@ -106,18 +106,20 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
results: any;
exams: {module: Module}[];
startDate: string;
pdf?: string;
pdf: {
path: string,
version: string,
},
};
if (!data) {
res.status(400).end();
return;
}
if (data.pdf) {
if (data.pdf && data.pdf.path && data.pdf.version === process.env.PDF_VERSION) {
// if it does, return the pdf url
const fileRef = ref(storage, data.pdf);
const fileRef = ref(storage, data.pdf.path);
const url = await getDownloadURL(fileRef);
res.status(200).end(url);
return;
}
@@ -387,7 +389,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
// update the stats entries with the pdf url to prevent duplication
await updateDoc(docSnap.ref, {
pdf: refName,
pdf: {
path: refName,
version: process.env.PDF_VERSION,
},
});
const url = await getDownloadURL(fileRef);
res.status(200).end(url);
@@ -419,8 +424,8 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
return;
}
if (data.pdf) {
const fileRef = ref(storage, data.pdf);
if (data.pdf?.path) {
const fileRef = ref(storage, data.pdf.path);
const url = await getDownloadURL(fileRef);
return res.redirect(url);
}

View File

@@ -1,198 +1,171 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
import {
getFirestore,
collection,
getDocs,
query,
where,
setDoc,
doc,
getDoc,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { uuidv4 } from "@firebase/util";
import { Module } from "@/interfaces";
import { getExams } from "@/utils/exams.be";
import { Exam, Variant } from "@/interfaces/exam";
import { capitalize, flatten, uniqBy } from "lodash";
import { User } from "@/interfaces/user";
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {uuidv4} from "@firebase/util";
import {Module} from "@/interfaces";
import {getExams} from "@/utils/exams.be";
import {Exam, InstructorGender, Variant} from "@/interfaces/exam";
import {capitalize, flatten, uniqBy} from "lodash";
import {User} from "@/interfaces/user";
import moment from "moment";
import { sendEmail } from "@/email";
import {sendEmail} from "@/email";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
if (req.method === "GET") return GET(req, res);
if (req.method === "POST") return POST(req, res);
if (req.method === "GET") return GET(req, res);
if (req.method === "POST") return POST(req, res);
res.status(404).json({ ok: false });
res.status(404).json({ok: false});
}
async function GET(req: NextApiRequest, res: NextApiResponse) {
const q = query(collection(db, "assignments"));
const snapshot = await getDocs(q);
const q = query(collection(db, "assignments"));
const snapshot = await getDocs(q);
const docs = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
}));
const docs = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
}));
res.status(200).json(docs);
res.status(200).json(docs);
}
interface ExamWithUser {
module: Module;
id: string;
assignee: string;
module: Module;
id: string;
assignee: string;
}
function getRandomIndex(arr: any[]): number {
const randomIndex = Math.floor(Math.random() * arr.length);
return randomIndex;
const randomIndex = Math.floor(Math.random() * arr.length);
return randomIndex;
}
const generateExams = async (
generateMultiple: Boolean,
selectedModules: Module[],
assignees: string[],
variant?: Variant,
generateMultiple: Boolean,
selectedModules: Module[],
assignees: string[],
variant?: Variant,
instructorGender?: InstructorGender,
): Promise<ExamWithUser[]> => {
if (generateMultiple) {
// for optimization purposes, it would be better to create a new endpoint that returned the answers for all users at once
const allExams = assignees.map(async (assignee) => {
const selectedModulePromises = selectedModules.map(
async (module: Module) => {
try {
const exams: Exam[] = await getExams(
db,
module,
"true",
assignee,
variant,
);
if (generateMultiple) {
// for optimization purposes, it would be better to create a new endpoint that returned the answers for all users at once
const allExams = assignees.map(async (assignee) => {
const selectedModulePromises = selectedModules.map(async (module: Module) => {
try {
const exams: Exam[] = await getExams(db, module, "true", assignee, variant, instructorGender);
const exam = exams[getRandomIndex(exams)];
if (exam) {
return { module: exam.module, id: exam.id, assignee };
}
return null;
} catch (e) {
console.error(e);
return null;
}
},
[],
);
const newModules = await Promise.all(selectedModulePromises);
const exam = exams[getRandomIndex(exams)];
if (exam) {
return {module: exam.module, id: exam.id, assignee};
}
return null;
} catch (e) {
console.error(e);
return null;
}
}, []);
const newModules = await Promise.all(selectedModulePromises);
return newModules;
}, []);
return newModules;
}, []);
const exams = flatten(await Promise.all(allExams)).filter(
(x) => x !== null,
) as ExamWithUser[];
return exams;
}
const exams = flatten(await Promise.all(allExams)).filter((x) => x !== null) as ExamWithUser[];
return exams;
}
const selectedModulePromises = selectedModules.map(async (module: Module) => {
const exams: Exam[] = await getExams(db, module, "false", undefined);
const exam = exams[getRandomIndex(exams)];
const selectedModulePromises = selectedModules.map(async (module: Module) => {
const exams: Exam[] = await getExams(db, module, "false", undefined, variant, instructorGender);
const exam = exams[getRandomIndex(exams)];
if (exam) {
return { module: exam.module, id: exam.id };
}
return null;
});
if (exam) {
return {module: exam.module, id: exam.id};
}
return null;
});
const exams = await Promise.all(selectedModulePromises);
const examesFiltered = exams.filter((x) => x !== null) as ExamWithUser[];
return flatten(
assignees.map((assignee) =>
examesFiltered.map((exam) => ({ ...exam, assignee })),
),
);
const exams = await Promise.all(selectedModulePromises);
const examesFiltered = exams.filter((x) => x !== null) as ExamWithUser[];
return flatten(assignees.map((assignee) => examesFiltered.map((exam) => ({...exam, assignee}))));
};
async function POST(req: NextApiRequest, res: NextApiResponse) {
const {
selectedModules,
assignees,
// Generate multiple true would generate an unique exam for each user
// false would generate the same exam for all users
generateMultiple = false,
variant,
...body
} = req.body as {
selectedModules: Module[];
assignees: string[];
generateMultiple: Boolean;
name: string;
startDate: string;
endDate: string;
variant?: Variant;
};
const {
selectedModules,
assignees,
// Generate multiple true would generate an unique exam for each user
// false would generate the same exam for all users
generateMultiple = false,
variant,
instructorGender,
...body
} = req.body as {
selectedModules: Module[];
assignees: string[];
generateMultiple: Boolean;
name: string;
startDate: string;
endDate: string;
variant?: Variant;
instructorGender?: InstructorGender;
};
const exams: ExamWithUser[] = await generateExams(
generateMultiple,
selectedModules,
assignees,
variant,
);
const exams: ExamWithUser[] = await generateExams(generateMultiple, selectedModules, assignees, variant, instructorGender);
if (exams.length === 0) {
res
.status(400)
.json({ ok: false, error: "No exams found for the selected modules" });
return;
}
if (exams.length === 0) {
res.status(400).json({ok: false, error: "No exams found for the selected modules"});
return;
}
await setDoc(doc(db, "assignments", uuidv4()), {
assigner: req.session.user?.id,
assignees,
results: [],
exams,
...body,
});
await setDoc(doc(db, "assignments", uuidv4()), {
assigner: req.session.user?.id,
assignees,
results: [],
exams,
instructorGender,
...body,
});
res.status(200).json({ ok: true });
res.status(200).json({ok: true});
for (const assigneeID of assignees) {
const assigneeSnapshot = await getDoc(doc(db, "users", assigneeID));
if (!assigneeSnapshot.exists()) continue;
for (const assigneeID of assignees) {
const assigneeSnapshot = await getDoc(doc(db, "users", assigneeID));
if (!assigneeSnapshot.exists()) continue;
const assignee = { id: assigneeID, ...assigneeSnapshot.data() } as User;
const name = body.name;
const teacher = req.session.user!;
const examModulesLabel = uniqBy(exams, (x) => x.module)
.map((x) => capitalize(x.module))
.join(", ");
const startDate = moment(body.startDate).format("DD/MM/YYYY - HH:mm");
const endDate = moment(body.endDate).format("DD/MM/YYYY - HH:mm");
const assignee = {id: assigneeID, ...assigneeSnapshot.data()} as User;
const name = body.name;
const teacher = req.session.user!;
const examModulesLabel = uniqBy(exams, (x) => x.module)
.map((x) => capitalize(x.module))
.join(", ");
const startDate = moment(body.startDate).format("DD/MM/YYYY - HH:mm");
const endDate = moment(body.endDate).format("DD/MM/YYYY - HH:mm");
await sendEmail(
"assignment",
{
user: { name: assignee.name },
assignment: {
name,
startDate,
endDate,
modules: examModulesLabel,
assigner: teacher.name,
},
},
[assignee.email],
"EnCoach - New Assignment!",
);
}
await sendEmail(
"assignment",
{
user: {name: assignee.name},
assignment: {
name,
startDate,
endDate,
modules: examModulesLabel,
assigner: teacher.name,
},
},
[assignee.email],
"EnCoach - New Assignment!",
);
}
}

View File

@@ -5,7 +5,7 @@ import {getFirestore, collection, getDocs, query, where} from "firebase/firestor
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {shuffle} from "lodash";
import {Exam} from "@/interfaces/exam";
import {Difficulty, Exam} from "@/interfaces/exam";
import {Stat} from "@/interfaces/user";
import {Module} from "@/interfaces";
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.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 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}`},
});

View File

@@ -4,8 +4,9 @@ import {app} from "@/firebase";
import {getFirestore, setDoc, doc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
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 {Module} from "@/interfaces";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
@@ -23,9 +24,14 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
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);
}

View File

@@ -1,137 +1,128 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
import {
getFirestore,
getDoc,
doc,
deleteDoc,
setDoc,
getDocs,
collection,
where,
query,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Ticket } from "@/interfaces/ticket";
import { Invite } from "@/interfaces/invite";
import { Group, User } from "@/interfaces/user";
import { v4 } from "uuid";
import { sendEmail } from "@/email";
import { updateExpiryDateOnGroup } from "@/utils/groups.be";
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, getDoc, doc, deleteDoc, setDoc, getDocs, collection, where, query} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {Ticket} from "@/interfaces/ticket";
import {Invite} from "@/interfaces/invite";
import {CorporateUser, Group, User} from "@/interfaces/user";
import {v4} from "uuid";
import {sendEmail} from "@/email";
import {updateExpiryDateOnGroup} from "@/utils/groups.be";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return await get(req, res);
if (req.method === "GET") return await get(req, res);
res.status(404).json(undefined);
res.status(404).json(undefined);
}
async function addToInviterGroup(user: User, invitedBy: User) {
const invitedByGroupsRef = await getDocs(query(collection(db, "groups"), where("admin", "==", invitedBy.id)));
const invitedByGroups = invitedByGroupsRef.docs.map((g) => ({
...g.data(),
id: g.id,
})) as Group[];
const typeGroupName = user.type === "student" ? "Students" : user.type === "teacher" ? "Teachers" : undefined;
if (typeGroupName) {
const typeGroup: Group = invitedByGroups.find((g) => g.name === typeGroupName) || {
id: v4(),
admin: invitedBy.id,
name: typeGroupName,
participants: [],
disableEditing: true,
};
await setDoc(
doc(db, "groups", typeGroup.id),
{
...typeGroup,
participants: [...typeGroup.participants.filter((x) => x !== user.id), user.id],
},
{merge: true},
);
}
const invitationsGroup: Group = invitedByGroups.find((g) => g.name === "Invited") || {
id: v4(),
admin: invitedBy.id,
name: "Invited",
participants: [],
disableEditing: true,
};
await setDoc(
doc(db, "groups", invitationsGroup.id),
{
...invitationsGroup,
participants: [...invitationsGroup.participants.filter((x) => x !== user.id), user.id],
},
{
merge: true,
},
);
}
async function deleteFromPreviousCorporateGroups(user: User, invitedBy: User) {
const corporatesRef = await getDocs(query(collection(db, "users"), where("type", "==", "corporate")));
const corporates = (corporatesRef.docs.map((x) => ({...x.data(), id: x.id})) as CorporateUser[]).filter((x) => x.id !== invitedBy.id);
const userGroupsRef = await getDocs(query(collection(db, "groups"), where("participants", "array-contains", user.id)));
const userGroups = userGroupsRef.docs.map((x) => ({...x.data(), id: x.id})) as Group[];
const corporateGroups = userGroups.filter((x) => corporates.map((c) => c.id).includes(x.admin));
await Promise.all(
corporateGroups.map(async (group) => {
await setDoc(doc(db, "groups", group.id), {participants: group.participants.filter((x) => x !== user.id)}, {merge: true});
}),
);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const { id } = req.query as { id: string };
const snapshot = await getDoc(doc(db, "invites", id));
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 });
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 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);
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) => ({
...g.data(),
id: g.id,
})) as Group[];
const invitedBy = {...invitedByRef.data(), id: invitedByRef.id} as User;
const typeGroupName =
req.session.user.type === "student"
? "Students"
: req.session.user.type === "teacher"
? "Teachers"
: undefined;
if (invitedBy.type === "corporate") await deleteFromPreviousCorporateGroups(req.session.user, invitedBy);
await addToInviterGroup(req.session.user, invitedBy);
if (typeGroupName) {
const typeGroup: Group = invitedByGroups.find(
(g) => g.name === typeGroupName,
) || {
id: v4(),
admin: invitedBy.id,
name: typeGroupName,
participants: [],
disableEditing: true,
};
await setDoc(
doc(db, "groups", typeGroup.id),
{
...typeGroup,
participants: [
...typeGroup.participants.filter((x) => x !== req.session.user!.id),
req.session.user.id,
],
},
{ merge: true },
);
}
try {
await sendEmail(
"respondedInvite",
{
corporateName: invitedBy.name,
name: req.session.user.name,
decision: "accept",
},
[invitedBy.email],
`${req.session.user.name} has accepted your invite!`,
);
} catch (e) {
console.log(e);
}
const invitationsGroup: Group = invitedByGroups.find(
(g) => g.name === "Invited",
) || {
id: v4(),
admin: invitedBy.id,
name: "Invited",
participants: [],
disableEditing: true,
};
await setDoc(
doc(db, "groups", invitationsGroup.id),
{
...invitationsGroup,
participants: [
...invitationsGroup.participants.filter(
(x) => x !== req.session.user!.id,
),
req.session.user.id,
],
},
{
merge: true,
},
);
try {
await sendEmail(
"respondedInvite",
{
corporateName: invitedBy.name,
name: req.session.user.name,
decision: "accept",
},
[invitedBy.email],
`${req.session.user.name} has accepted your invite!`,
);
} catch (e) {
console.log(e);
}
res.status(200).json({ ok: true });
} else {
res.status(404).json(undefined);
}
res.status(200).json({ok: true});
} else {
res.status(404).json(undefined);
}
}

View File

@@ -0,0 +1,54 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, doc, getDoc, deleteDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {Session} from "@/hooks/useSessions";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return get(req, res);
if (req.method === "DELETE") return del(req, res);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const {id} = req.query as {id: string};
const docRef = doc(db, "sessions", id);
const docSnap = await getDoc(docRef);
if (docSnap.exists()) {
res.status(200).json({
id: docSnap.id,
...docSnap.data(),
});
} else {
res.status(404).json(undefined);
}
}
async function del(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const {id} = req.query as {id: string};
const docRef = doc(db, "sessions", id);
const docSnap = await getDoc(docRef);
if (!docSnap.exists()) return res.status(404).json({ok: false});
await deleteDoc(docRef);
return res.status(200).json({ok: true});
}

View File

@@ -0,0 +1,46 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, collection, getDocs, query, where, doc, setDoc, addDoc, getDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return get(req, res);
if (req.method === "POST") return post(req, res);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const {user} = req.query as {user?: string};
const q = user ? query(collection(db, "sessions"), where("user", "==", user)) : collection(db, "sessions");
const snapshot = await getDocs(q);
res.status(200).json(
snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})),
);
}
async function post(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const session = req.body;
await setDoc(doc(db, "sessions", session.id), session, {merge: true});
res.status(200).json({ok: true});
}

View File

@@ -125,7 +125,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const stats = docsSnap.docs.map((d) => d.data());
// verify if the stats already have a pdf generated
const hasPDF = stats.find((s) => s.pdf);
const hasPDF = stats.find((s) => s.pdf?.path && s.pdf?.version === process.env.PDF_VERSION);
// find the user that generated the stats
const statIndex = stats.findIndex((s) => s.user);
@@ -138,7 +138,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
if (hasPDF) {
// 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);
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
docsSnap.docs.forEach(async (doc) => {
await updateDoc(doc.ref, {
pdf: refName,
pdf: {
path: refName,
version: process.env.PDF_VERSION,
},
});
});
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 hasPDF = stats.find((s) => s.pdf);
const hasPDF = stats.find((s) => s.pdf?.path);
if (hasPDF) {
const fileRef = ref(storage, hasPDF.pdf);
const fileRef = ref(storage, hasPDF.pdf.path);
const url = await getDownloadURL(fileRef);
return res.redirect(url);
}

View File

@@ -1,7 +1,7 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, collection, getDocs, query, where, doc, setDoc, addDoc, getDoc} from "firebase/firestore";
import {getFirestore, collection, getDocs, query, where, doc, setDoc, addDoc, getDoc, deleteDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {Stat} from "@/interfaces/user";
@@ -43,6 +43,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const stats = req.body as Stat[];
await stats.forEach(async (stat) => await setDoc(doc(db, "stats", stat.id), stat));
await stats.forEach(async (stat) => {
const sessionDoc = await getDoc(doc(db, "sessions", stat.session));
if (sessionDoc.exists()) await deleteDoc(sessionDoc.ref);
});
const groupedStatsByAssignment = groupBy(
stats.filter((x) => !!x.assignment),

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";
import { withIronSessionApiRoute } from "iron-session/next";
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);
@@ -69,12 +71,38 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
}
const { id } = req.query as { id: string };
const body = req.body as Ticket;
const snapshot = await getDoc(doc(db, "tickets", id));
const user = req.session.user;
if (user.type === "admin" || user.type === "developer") {
await setDoc(snapshot.ref, req.body, { merge: true });
return res.status(200).json({ ok: true });
const data = snapshot.data() as Ticket;
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 });

View File

@@ -20,13 +20,25 @@ const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
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) {
res.status(401).json({ ok: false });
return;
}
if (req.method === "GET") await get(req, res);
if (req.method === "POST") await post(req, res);
if (req.method === "GET") {
await get(req, res);
}
}
async function get(req: NextApiRequest, res: NextApiResponse) {
@@ -36,7 +48,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})),
}))
);
}
@@ -61,7 +73,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
description: body.description,
},
[body.reporter.email],
`Ticket ${id}: ${body.subject}`,
`Ticket ${id}: ${body.subject}`
);
} catch (e) {
console.log(e);

View File

@@ -28,6 +28,9 @@ import TimezoneSelect from "@/components/Low/TImezoneSelect";
import Modal from "@/components/Modal";
import {Module} from "@/interfaces";
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}) => {
const user = req.session.user;
@@ -83,6 +86,11 @@ function UserProfile({user, mutateUser}: Props) {
user.type === "corporate" ? undefined : user.demographicInformation?.employment,
);
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 [corporateInformation, setCorporateInformation] = useState(user.type === "corporate" ? user.corporateInformation : 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,
);
const [timezone, setTimezone] = useState<string>(user.demographicInformation?.timezone || moment.tz.guess());
const {groups} = useGroups();
const {users} = useUsers();
@@ -146,6 +155,7 @@ function UserProfile({user, mutateUser}: Props) {
newPassword,
profilePicture,
desiredLevels,
preferredGender,
demographicInformation: {
phone,
country,
@@ -337,6 +347,24 @@ function UserProfile({user, mutateUser}: Props) {
</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 />
{user.type === "corporate" && (

View File

@@ -178,7 +178,10 @@ export default function History({user}: {user: User}) {
const {timeSpent, session} = dateStats[0];
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) => {
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;
selectedModules: Module[];
assignment?: Assignment;
setHasExamEnded: (hasExamEnded: boolean) => void;
setUserSolutions: (userSolutions: UserSolution[]) => void;
timeSpent: number;
sessionId: string;
moduleIndex: number;
exam?: Exam;
partIndex: number;
exerciseIndex: number;
questionIndex: number;
}
export interface ExamFunctions {
setExams: (exams: Exam[]) => void;
setUserSolutions: (userSolutions: UserSolution[]) => void;
setShowSolutions: (showSolutions: boolean) => void;
setHasExamEnded: (hasExamEnded: boolean) => 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;
}
export const initialState = {
export const initialState: ExamState = {
exams: [],
userSolutions: [],
showSolutions: false,
selectedModules: [],
hasExamEnded: false,
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,
setUserSolutions: (userSolutions: UserSolution[]) => set(() => ({userSolutions})),
setExams: (exams: Exam[]) => set(() => ({exams})),
setShowSolutions: (showSolutions: boolean) => set(() => ({showSolutions})),
setSelectedModules: (modules: Module[]) => set(() => ({selectedModules: modules})),
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),
}));

View File

@@ -31,7 +31,15 @@
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 {
min-height: 100vh !important;
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"});
return Buffer.from(blobResponse.data, "binary");
};
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 formData = new FormData();
if (url && !url.startsWith("blob")) await axios.post("/api/storage/delete", {path: url});
formData.append("audio", audioFile, "audio.wav");
const evaluationQuestion =
@@ -64,7 +68,7 @@ const evaluateSpeakingExercise = async (exercise: SpeakingExercise, exerciseId:
const config = {
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 promiseParts = solution.solutions.map(async (x: {prompt: string; blob: string}) => ({
question: x.prompt,
answer: await downloadBlob(x.blob),
}));
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,
answer: blob,
};
});
const body = await Promise.all(promiseParts);
const formData = new FormData();
@@ -110,6 +119,7 @@ const evaluateInteractiveSpeakingExercise = async (exerciseId: string, solution:
};
const response = await axios.post("/api/evaluate/interactiveSpeaking", formData, config);
console.log({data: response.data, status: response.status});
if (response.status === 200) {
return {
@@ -119,6 +129,7 @@ const evaluateInteractiveSpeakingExercise = async (exerciseId: string, solution:
missing: 0,
total: 100,
},
module: "speaking",
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 {Exam, Variant} from "@/interfaces/exam";
import {Stat} from "@/interfaces/user";
import {Difficulty, Exam, InstructorGender, Variant} from "@/interfaces/exam";
import {Stat, User} from "@/interfaces/user";
import {Module} from "@/interfaces";
export const getExams = async (
db: Firestore,
module: string,
module: Module,
avoidRepeated: string,
// 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
// by the teacher that performed the request
userId: string | undefined,
variant?: Variant,
instructorGender?: InstructorGender,
): Promise<Exam[]> => {
const moduleRef = collection(db, module);
const q = query(moduleRef, where("isDiagnostic", "==", false));
const snapshot = await getDocs(q);
const exams: Exam[] = filterByVariant(
shuffle(
snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
module,
})),
) as Exam[],
variant,
);
const allExams = shuffle(
snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
module,
})),
) as Exam[];
const variantExams: Exam[] = filterByVariant(allExams, variant);
const genderedExams: Exam[] = filterByInstructorGender(variantExams, instructorGender);
const difficultyExams: Exam[] = await filterByDifficulty(db, genderedExams, module, userId);
if (avoidRepeated === "true") {
const statsQ = query(collection(db, "stats"), where("user", "==", userId));
@@ -37,15 +40,33 @@ export const getExams = async (
id: doc.id,
...doc.data(),
})) 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 filtered = variant && variant === "partial" ? exams.filter((x) => x.variant === "partial") : exams.filter((x) => x.variant !== "partial");
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 {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";
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();
url.append("avoidRepeated", avoidRepeated.toString());
if (variant) url.append("variant", variant);
if (module === "speaking" && instructorGender) url.append("instructorGender", instructorGender);
const examRequest = await axios<Exam[]>(`/api/exam/${module}?${url.toString()}`);
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 => {
const defaultSettings = {
exam: exam.id,
@@ -73,6 +98,9 @@ export const defaultUserSolutions = (exercise: Exercise, exam: Exam): UserSoluti
case "writeBlanks":
total = exercise.text.match(/({{\d+}})/g)?.length || 0;
return {...defaultSettings, score: {correct: 0, total, missing: total}};
case "trueFalse":
total = exercise.questions.length;
return {...defaultSettings, score: {correct: 0, total, missing: total}};
case "writing":
total = 1;
return {...defaultSettings, score: {correct: 0, total, missing: total}};