Merge branch 'develop'

This commit is contained in:
Tiago Ribeiro
2024-01-24 15:58:35 +00:00
32 changed files with 672 additions and 511 deletions

BIN
public/audio/error.mp3 Normal file

Binary file not shown.

View File

@@ -36,7 +36,7 @@ export default function Diagnostic({onFinish}: Props) {
}; };
const selectExam = () => { const selectExam = () => {
const examPromises = MODULE_ARRAY.map((module) => getExam(module, true)); const examPromises = MODULE_ARRAY.map((module) => getExam(module, true, "partial"));
Promise.all(examPromises).then((exams) => { Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) { if (exams.every((x) => !!x)) {

View File

@@ -42,7 +42,9 @@ export default function CountrySelect({value, disabled = false, onChange}: Props
displayValue={(code: string) => { displayValue={(code: string) => {
const country = countries[code as unknown as keyof TCountries]; const country = countries[code as unknown as keyof TCountries];
return `${countryCodes.findOne("countryCode" as any, code).flag} ${country.name} (+${country.phone})`; return `${countryCodes.findOne("countryCode" as any, code)?.flag || ""} ${country?.name || "N/A"} (+${
country?.phone || "N/A"
})`;
}} }}
/> />
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-8"> <Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-8">

View File

@@ -45,8 +45,9 @@ const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false}
<Link <Link
href={!disabled ? keyPath : ""} href={!disabled ? keyPath : ""}
className={clsx( className={clsx(
"p-4 rounded-full flex gap-4 items-center cursor-pointer text-gray-500 hover:bg-mti-purple-light hover:text-white", "p-4 rounded-full flex gap-4 items-center text-gray-500 hover:text-white",
"transition-all duration-300 ease-in-out", "transition-all duration-300 ease-in-out",
disabled ? "hover:bg-mti-gray-dim cursor-not-allowed" : "hover:bg-mti-purple-light cursor-pointer",
path === keyPath && "bg-mti-purple-light text-white", path === keyPath && "bg-mti-purple-light text-white",
isMinimized ? "w-fit" : "w-full min-w-[200px] 2xl:min-w-[220px] px-8", isMinimized ? "w-fit" : "w-full min-w-[200px] 2xl:min-w-[220px] px-8",
)}> )}>

View File

@@ -243,7 +243,9 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
options={CURRENCIES_OPTIONS} options={CURRENCIES_OPTIONS}
value={CURRENCIES_OPTIONS.find((c) => c.value === paymentCurrency)} value={CURRENCIES_OPTIONS.find((c) => c.value === paymentCurrency)}
onChange={(value) => setPaymentCurrency(value?.value)} onChange={(value) => setPaymentCurrency(value?.value)}
menuPortalTarget={document?.body}
styles={{ styles={{
menuPortal: (base) => ({...base, zIndex: 9999}),
control: (styles) => ({ control: (styles) => ({
...styles, ...styles,
paddingLeft: "4px", paddingLeft: "4px",
@@ -282,8 +284,10 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
value: referralAgent, value: referralAgent,
label: referralAgentLabel, label: referralAgentLabel,
}} }}
menuPortalTarget={document?.body}
onChange={(value) => setReferralAgent(value?.value)} onChange={(value) => setReferralAgent(value?.value)}
styles={{ styles={{
menuPortal: (base) => ({...base, zIndex: 9999}),
control: (styles) => ({ control: (styles) => ({
...styles, ...styles,
paddingLeft: "4px", paddingLeft: "4px",
@@ -314,7 +318,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
type="number" type="number"
defaultValue={commissionValue || 0} defaultValue={commissionValue || 0}
className="col-span-3" className="col-span-3"
disabled={disabled} disabled={disabled || loggedInUser.type === "agent"}
/> />
</> </>
) : ( ) : (
@@ -520,6 +524,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<Select <Select
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none" className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
options={USER_STATUS_OPTIONS} options={USER_STATUS_OPTIONS}
menuPortalTarget={document?.body}
value={USER_STATUS_OPTIONS.find((o) => o.value === status)} value={USER_STATUS_OPTIONS.find((o) => o.value === status)}
onChange={(value) => setStatus(value?.value as typeof user.status)} onChange={(value) => setStatus(value?.value as typeof user.status)}
styles={{ styles={{
@@ -532,6 +537,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
outline: "none", outline: "none",
}, },
}), }),
menuPortal: (base) => ({...base, zIndex: 9999}),
option: (styles, state) => ({ option: (styles, state) => ({
...styles, ...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
@@ -546,6 +552,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<Select <Select
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none" className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
options={USER_TYPE_OPTIONS} options={USER_TYPE_OPTIONS}
menuPortalTarget={document?.body}
value={USER_TYPE_OPTIONS.find((o) => o.value === type)} value={USER_TYPE_OPTIONS.find((o) => o.value === type)}
onChange={(value) => setType(value?.value as typeof user.type)} onChange={(value) => setType(value?.value as typeof user.type)}
styles={{ styles={{
@@ -558,6 +565,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
outline: "none", outline: "none",
}, },
}), }),
menuPortal: (base) => ({...base, zIndex: 9999}),
option: (styles, state) => ({ option: (styles, state) => ({
...styles, ...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
@@ -574,17 +582,17 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<div className="flex gap-4 justify-between mt-4 w-full"> <div className="flex gap-4 justify-between mt-4 w-full">
<div className="self-start flex gap-4 justify-start items-center w-full"> <div className="self-start flex gap-4 justify-start items-center w-full">
{onViewCorporate && ( {onViewCorporate && ["student", "teacher"].includes(user.type) && (
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewCorporate}> <Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewCorporate}>
View Corporate View Corporate
</Button> </Button>
)} )}
{onViewStudents && ( {onViewStudents && ["corporate", "teacher"].includes(user.type) && (
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewStudents}> <Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewStudents}>
View Students View Students
</Button> </Button>
)} )}
{onViewTeachers && ( {onViewTeachers && ["student", "corporate"].includes(user.type) && (
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewTeachers}> <Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewTeachers}>
View Teachers View Teachers
</Button> </Button>

View File

@@ -18,8 +18,8 @@ export const PERMISSIONS = {
developer: ["developer"], developer: ["developer"],
}, },
updateUser: { updateUser: {
student: ["teacher", "corporate", "developer", "admin"], student: ["developer", "admin"],
teacher: ["corporate", "developer", "admin"], teacher: ["developer", "admin"],
corporate: ["admin", "developer"], corporate: ["admin", "developer"],
admin: ["developer", "admin"], admin: ["developer", "admin"],
agent: ["developer", "admin"], agent: ["developer", "admin"],

View File

@@ -151,8 +151,9 @@ export default function TeacherDashboard({user}: Props) {
}; };
const AssignmentsPage = () => { const AssignmentsPage = () => {
const activeFilter = (a: Assignment) => moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()); const activeFilter = (a: Assignment) =>
const pastFilter = (a: Assignment) => moment(a.endDate).isBefore(moment()); moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()) && a.assignees.length > a.results.length;
const pastFilter = (a: Assignment) => moment(a.endDate).isBefore(moment()) || a.assignees.length === a.results.length;
const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment()); const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
return ( return (

View File

@@ -12,17 +12,19 @@ import {calculateAverageLevel} from "@/utils/score";
import {sortByModuleName} from "@/utils/moduleUtils"; import {sortByModuleName} from "@/utils/moduleUtils";
import {capitalize} from "lodash"; import {capitalize} from "lodash";
import ProfileSummary from "@/components/ProfileSummary"; import ProfileSummary from "@/components/ProfileSummary";
import {Variant} from "@/interfaces/exam";
interface Props { interface Props {
user: User; user: User;
page: "exercises" | "exams"; page: "exercises" | "exams";
onStart: (modules: Module[], avoidRepeated: boolean) => void; onStart: (modules: Module[], avoidRepeated: boolean, variant: Variant) => void;
disableSelection?: boolean; disableSelection?: boolean;
} }
export default function Selection({user, page, onStart, disableSelection = false}: Props) { export default function Selection({user, page, onStart, disableSelection = false}: Props) {
const [selectedModules, setSelectedModules] = useState<Module[]>([]); const [selectedModules, setSelectedModules] = useState<Module[]>([]);
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true); const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
const [variant, setVariant] = useState<Variant>("full");
const {stats} = useStats(user?.id); const {stats} = useStats(user?.id);
const toggleModule = (module: Module) => { const toggleModule = (module: Module) => {
@@ -202,9 +204,9 @@ export default function Selection({user, page, onStart, disableSelection = false
)} )}
</section> </section>
<div className="flex w-full -md:flex-col -md:gap-4 -md:justify-center md:justify-between items-center"> <div className="flex w-full -md:flex-col -md:gap-4 -md:justify-center md:justify-between items-center">
<div className="flex flex-col gap-3 items-center w-full">
<div <div
className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer tooltip w-full -md:justify-center" className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer w-full -md:justify-center"
data-tip="If possible, the platform will choose exams not yet done"
onClick={() => setAvoidRepeatedExams((prev) => !prev)}> onClick={() => setAvoidRepeatedExams((prev) => !prev)}>
<input type="checkbox" className="hidden" /> <input type="checkbox" className="hidden" />
<div <div
@@ -215,7 +217,24 @@ export default function Selection({user, page, onStart, disableSelection = false
)}> )}>
<BsCheck color="white" className="w-full h-full" /> <BsCheck color="white" className="w-full h-full" />
</div> </div>
<span>Avoid Repeated Questions</span> <span className="tooltip" data-tip="If possible, the platform will choose exams not yet done.">
Avoid Repeated Questions
</span>
</div>
<div
className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer w-full -md:justify-center"
onClick={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
<input type="checkbox" className="hidden" />
<div
className={clsx(
"w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
"transition duration-300 ease-in-out",
variant === "full" && "!bg-mti-purple-light ",
)}>
<BsCheck color="white" className="w-full h-full" />
</div>
<span>Full length exams</span>
</div>
</div> </div>
<div className="tooltip w-full" data-tip={`Your screen size is too small to do ${page}`}> <div className="tooltip w-full" data-tip={`Your screen size is too small to do ${page}`}>
<Button color="purple" className="px-12 w-full max-w-xs md:hidden" disabled> <Button color="purple" className="px-12 w-full max-w-xs md:hidden" disabled>
@@ -227,6 +246,7 @@ export default function Selection({user, page, onStart, disableSelection = false
onStart( onStart(
!disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"], !disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"],
avoidRepeatedExams, avoidRepeatedExams,
variant,
) )
} }
color="purple" color="purple"

View File

@@ -5,19 +5,12 @@ import { View, Text, Image } from "@react-pdf/renderer";
import {styles} from "../styles"; import {styles} from "../styles";
import {ModuleScore} from "@/interfaces/module.scores"; import {ModuleScore} from "@/interfaces/module.scores";
export const RadialResult = ({ export const RadialResult = ({module, score, total, png}: ModuleScore) => (
module,
score,
total,
png,
}: ModuleScore) => (
<View style={[styles.textFont, styles.radialContainer]}> <View style={[styles.textFont, styles.radialContainer]}>
<Text style={[styles.textColor, styles.textBold, { fontSize: 10 }]}> <Text style={[styles.textColor, styles.textBold, {fontSize: 10}]}>{module}</Text>
{module}
</Text>
<Image src={png} style={styles.image64}></Image> <Image src={png} style={styles.image64}></Image>
<View style={[styles.textColor, styles.radialResultContainer]}> <View style={[styles.textColor, styles.radialResultContainer]}>
<Text style={styles.textBold}>{score}</Text> <Text style={styles.textBold}>{score.toFixed(2)}</Text>
<Text style={{fontSize: 8}}>out of {total}</Text> <Text style={{fontSize: 8}}>out of {total}</Text>
</View> </View>
</View> </View>

View File

@@ -1,7 +1,7 @@
import {Module} from "."; import {Module} from ".";
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam; export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
export type Variant = "diagnostic" | "partial"; export type Variant = "full" | "diagnostic" | "partial";
export interface ReadingExam { export interface ReadingExam {
parts: ReadingPart[]; parts: ReadingPart[];

View File

@@ -4,10 +4,10 @@ export interface ModuleScore {
score: number; score: number;
total: number; total: number;
code: Module; code: Module;
module: Module | 'Overall'; module: Module | "Overall";
png?: string, png?: string;
evaluation?: string, evaluation?: string;
suggestions?: string, suggestions?: string;
} }
export interface StudentData { export interface StudentData {

View File

@@ -125,7 +125,9 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
onChange={(value) => setParticipants(value.map((x) => x.value))} onChange={(value) => setParticipants(value.map((x) => x.value))}
isMulti isMulti
isSearchable isSearchable
menuPortalTarget={document?.body}
styles={{ styles={{
menuPortal: (base) => ({...base, zIndex: 9999}),
control: (styles) => ({ control: (styles) => ({
...styles, ...styles,
backgroundColor: "white", backgroundColor: "white",

View File

@@ -71,7 +71,9 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
defaultValue={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}} defaultValue={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
onChange={(value) => setCurrency(value?.value || "EUR")} onChange={(value) => setCurrency(value?.value || "EUR")}
value={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}} value={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
menuPortalTarget={document?.body}
styles={{ styles={{
menuPortal: (base) => ({...base, zIndex: 9999}),
control: (styles) => ({ control: (styles) => ({
...styles, ...styles,
paddingLeft: "4px", paddingLeft: "4px",
@@ -105,7 +107,9 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
defaultValue={{value: "months", label: "Months"}} defaultValue={{value: "months", label: "Months"}}
onChange={(value) => setUnit((value?.value as DurationUnit) || "months")} onChange={(value) => setUnit((value?.value as DurationUnit) || "months")}
value={{value: unit, label: capitalize(unit)}} value={{value: unit, label: capitalize(unit)}}
menuPortalTarget={document?.body}
styles={{ styles={{
menuPortal: (base) => ({...base, zIndex: 9999}),
control: (styles) => ({ control: (styles) => ({
...styles, ...styles,
paddingLeft: "4px", paddingLeft: "4px",

View File

@@ -5,7 +5,7 @@ import {Module} from "@/interfaces";
import Selection from "@/exams/Selection"; import Selection from "@/exams/Selection";
import Reading from "@/exams/Reading"; import Reading from "@/exams/Reading";
import {Exam, InteractiveSpeakingExercise, SpeakingExercise, UserSolution, WritingExercise} from "@/interfaces/exam"; import {Exam, InteractiveSpeakingExercise, SpeakingExercise, UserSolution, Variant, WritingExercise} from "@/interfaces/exam";
import Listening from "@/exams/Listening"; import Listening from "@/exams/Listening";
import Writing from "@/exams/Writing"; import Writing from "@/exams/Writing";
import {ToastContainer, toast} from "react-toastify"; import {ToastContainer, toast} from "react-toastify";
@@ -38,6 +38,7 @@ export default function ExamPage({page}: Props) {
const [avoidRepeated, setAvoidRepeated] = useState(false); const [avoidRepeated, setAvoidRepeated] = useState(false);
const [timeSpent, setTimeSpent] = useState(0); const [timeSpent, setTimeSpent] = useState(0);
const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<string[]>([]); const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<string[]>([]);
const [variant, setVariant] = useState<Variant>("full");
const [exams, setExams] = useExamStore((state) => [state.exams, state.setExams]); const [exams, setExams] = useExamStore((state) => [state.exams, state.setExams]);
const [userSolutions, setUserSolutions] = useExamStore((state) => [state.userSolutions, state.setUserSolutions]); const [userSolutions, setUserSolutions] = useExamStore((state) => [state.userSolutions, state.setUserSolutions]);
@@ -84,7 +85,7 @@ export default function ExamPage({page}: Props) {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (selectedModules.length > 0 && exams.length === 0) { if (selectedModules.length > 0 && exams.length === 0) {
const examPromises = selectedModules.map((module) => getExam(module, avoidRepeated)); const examPromises = selectedModules.map((module) => getExam(module, avoidRepeated, variant));
Promise.all(examPromises).then((values) => { Promise.all(examPromises).then((values) => {
if (values.every((x) => !!x)) { if (values.every((x) => !!x)) {
setExams(values.map((x) => x!)); setExams(values.map((x) => x!));
@@ -253,10 +254,11 @@ export default function ExamPage({page}: Props) {
page={page} page={page}
user={user!} user={user!}
disableSelection={page === "exams"} disableSelection={page === "exams"}
onStart={(modules, avoid) => { onStart={(modules: Module[], avoid: boolean, variant: Variant) => {
setModuleIndex(0); setModuleIndex(0);
setAvoidRepeated(avoid); setAvoidRepeated(avoid);
setSelectedModules(modules); setSelectedModules(modules);
setVariant(variant);
}} }}
/> />
); );

View File

@@ -19,8 +19,8 @@ const TaskTab = ({exam, setExam}: {exam?: LevelExam; setExam: (exam: LevelExam)
axios axios
.get(`/api/exam/level/generate/level`) .get(`/api/exam/level/generate/level`)
.then((result) => { .then((result) => {
playSound("check"); playSound(typeof result.data === "string" ? "error" : "check");
console.log(result.data); if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
setExam(result.data); setExam(result.data);
}) })
.catch((error) => { .catch((error) => {

View File

@@ -8,8 +8,8 @@ import {Tab} from "@headlessui/react";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {useState} from "react"; import {useEffect, useState} from "react";
import {BsArrowRepeat} from "react-icons/bs"; import {BsArrowRepeat, BsCheck} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
const PartTab = ({part, types, index, setPart}: {part?: ListeningPart; types: string[]; index: number; setPart: (part?: ListeningPart) => void}) => { const PartTab = ({part, types, index, setPart}: {part?: ListeningPart; types: string[]; index: number; setPart: (part?: ListeningPart) => void}) => {
@@ -26,7 +26,8 @@ const PartTab = ({part, types, index, setPart}: {part?: ListeningPart; types: st
axios axios
.get(`/api/exam/listening/generate/listening_section_${index}${topic || types ? `?${url.toString()}` : ""}`) .get(`/api/exam/listening/generate/listening_section_${index}${topic || types ? `?${url.toString()}` : ""}`)
.then((result) => { .then((result) => {
playSound("check"); playSound(typeof result.data === "string" ? "error" : "check");
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
setPart(result.data); setPart(result.data);
}) })
.catch((error) => { .catch((error) => {
@@ -42,7 +43,7 @@ const PartTab = ({part, types, index, setPart}: {part?: ListeningPart; types: st
<Input type="text" placeholder="Grand Canyon..." name="topic" label="Topic" onChange={setTopic} roundness="xl" defaultValue={topic} /> <Input type="text" placeholder="Grand Canyon..." name="topic" label="Topic" onChange={setTopic} roundness="xl" defaultValue={topic} />
<button <button
onClick={generate} onClick={generate}
disabled={isLoading} disabled={isLoading || types.length === 0}
data-tip="The passage is currently being generated" data-tip="The passage is currently being generated"
className={clsx( className={clsx(
"bg-ielts-listening/70 border border-ielts-listening text-white w-full max-w-[200px] rounded-xl h-[70px]", "bg-ielts-listening/70 border border-ielts-listening text-white w-full max-w-[200px] rounded-xl h-[70px]",
@@ -110,10 +111,21 @@ const ListeningGeneration = () => {
const [part2, setPart2] = useState<ListeningPart>(); const [part2, setPart2] = useState<ListeningPart>();
const [part3, setPart3] = useState<ListeningPart>(); const [part3, setPart3] = useState<ListeningPart>();
const [part4, setPart4] = useState<ListeningPart>(); const [part4, setPart4] = useState<ListeningPart>();
const [minTimer, setMinTimer] = useState(30);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<ListeningExam>(); const [resultingExam, setResultingExam] = useState<ListeningExam>();
const [types, setTypes] = useState<string[]>([]); const [types, setTypes] = useState<string[]>([]);
useEffect(() => {
const part1Timer = part1 ? 5 : 0;
const part2Timer = part2 ? 8 : 0;
const part3Timer = part3 ? 8 : 0;
const part4Timer = part4 ? 9 : 0;
const sum = part1Timer + part2Timer + part3Timer + part4Timer;
setMinTimer(sum > 0 ? sum : 5);
}, [part1, part2, part3, part4]);
const availableTypes = [ const availableTypes = [
{type: "multipleChoice", label: "Multiple Choice"}, {type: "multipleChoice", label: "Multiple Choice"},
{type: "writeBlanksQuestions", label: "Write the Blanks: Questions"}, {type: "writeBlanksQuestions", label: "Write the Blanks: Questions"},
@@ -129,12 +141,14 @@ const ListeningGeneration = () => {
const toggleType = (type: string) => setTypes((prev) => (prev.includes(type) ? [...prev.filter((x) => x !== type)] : [...prev, type])); const toggleType = (type: string) => setTypes((prev) => (prev.includes(type) ? [...prev.filter((x) => x !== type)] : [...prev, type]));
const submitExam = () => { const submitExam = () => {
if (!part1 || !part2 || !part3 || !part4) return toast.error("Please generate all for sections!"); const parts = [part1, part2, part3, part4].filter((x) => !!x);
console.log({parts});
if (parts.length === 0) return toast.error("Please generate at least one section!");
setIsLoading(true); setIsLoading(true);
axios axios
.post(`/api/exam/listening/generate/listening`, {parts: [part1, part2, part3, part4]}) .post(`/api/exam/listening/generate/listening`, {parts, minTimer})
.then((result) => { .then((result) => {
playSound("sent"); playSound("sent");
console.log(`Generated Exam ID: ${result.data.id}`); console.log(`Generated Exam ID: ${result.data.id}`);
@@ -172,6 +186,17 @@ const ListeningGeneration = () => {
return ( return (
<> <>
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
<Input
type="number"
name="minTimer"
onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))}
value={minTimer}
className="max-w-[300px]"
/>
</div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Exercises</label> <label className="font-normal text-base text-mti-gray-dim">Exercises</label>
<div className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between"> <div className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
@@ -197,46 +222,46 @@ const ListeningGeneration = () => {
<Tab <Tab
className={({selected}) => className={({selected}) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70 flex gap-2 items-center justify-center",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-listening focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-listening focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-listening", selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-listening",
) )
}> }>
Section 1 Section 1 {part1 && <BsCheck />}
</Tab> </Tab>
<Tab <Tab
className={({selected}) => className={({selected}) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70 flex gap-2 items-center justify-center",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-listening focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-listening focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-listening", selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-listening",
) )
}> }>
Section 2 Section 2 {part2 && <BsCheck />}
</Tab> </Tab>
<Tab <Tab
className={({selected}) => className={({selected}) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70 flex gap-2 items-center justify-center",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-listening focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-listening focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-listening", selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-listening",
) )
}> }>
Section 3 Section 3 {part3 && <BsCheck />}
</Tab> </Tab>
<Tab <Tab
className={({selected}) => className={({selected}) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70 flex gap-2 items-center justify-center",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-listening focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-listening focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-listening", selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-listening",
) )
}> }>
Section 4 Section 4 {part4 && <BsCheck />}
</Tab> </Tab>
</Tab.List> </Tab.List>
<Tab.Panels> <Tab.Panels>
@@ -264,14 +289,14 @@ const ListeningGeneration = () => {
</button> </button>
)} )}
<button <button
disabled={!part1 || !part2 || !part3 || !part4 || isLoading} disabled={(!part1 && !part2 && !part3 && !part4) || isLoading}
data-tip="Please generate all three passages" data-tip="Please generate all three passages"
onClick={submitExam} onClick={submitExam}
className={clsx( className={clsx(
"bg-ielts-listening/70 border border-ielts-listening text-white w-full max-w-[200px] rounded-xl h-[70px] self-end", "bg-ielts-listening/70 border border-ielts-listening text-white w-full max-w-[200px] rounded-xl h-[70px] self-end",
"hover:bg-ielts-listening disabled:bg-ielts-listening/40 disabled:cursor-not-allowed", "hover:bg-ielts-listening disabled:bg-ielts-listening/40 disabled:cursor-not-allowed",
"transition ease-in-out duration-300", "transition ease-in-out duration-300",
(!part1 || !part2 || !part3 || !part4) && "tooltip", !part1 && !part2 && !part3 && !part4 && "tooltip",
)}> )}>
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">

View File

@@ -8,8 +8,8 @@ import {Tab} from "@headlessui/react";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {useState} from "react"; import {useEffect, useState} from "react";
import {BsArrowRepeat} from "react-icons/bs"; import {BsArrowRepeat, BsCheck} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import {v4} from "uuid"; import {v4} from "uuid";
@@ -27,7 +27,8 @@ const PartTab = ({part, types, index, setPart}: {part?: ReadingPart; types: stri
axios axios
.get(`/api/exam/reading/generate/reading_passage_${index}${topic || types ? `?${url.toString()}` : ""}`) .get(`/api/exam/reading/generate/reading_passage_${index}${topic || types ? `?${url.toString()}` : ""}`)
.then((result) => { .then((result) => {
playSound("check"); playSound(typeof result.data === "string" ? "error" : "check");
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
setPart(result.data); setPart(result.data);
}) })
.catch((error) => { .catch((error) => {
@@ -43,7 +44,7 @@ const PartTab = ({part, types, index, setPart}: {part?: ReadingPart; types: stri
<Input type="text" placeholder="Grand Canyon..." name="topic" label="Topic" onChange={setTopic} roundness="xl" defaultValue={topic} /> <Input type="text" placeholder="Grand Canyon..." name="topic" label="Topic" onChange={setTopic} roundness="xl" defaultValue={topic} />
<button <button
onClick={generate} onClick={generate}
disabled={isLoading} disabled={isLoading || types.length === 0}
data-tip="The passage is currently being generated" data-tip="The passage is currently being generated"
className={clsx( className={clsx(
"bg-ielts-reading/70 border border-ielts-reading text-white w-full max-w-[200px] rounded-xl h-[70px]", "bg-ielts-reading/70 border border-ielts-reading text-white w-full max-w-[200px] rounded-xl h-[70px]",
@@ -87,10 +88,16 @@ const ReadingGeneration = () => {
const [part1, setPart1] = useState<ReadingPart>(); const [part1, setPart1] = useState<ReadingPart>();
const [part2, setPart2] = useState<ReadingPart>(); const [part2, setPart2] = useState<ReadingPart>();
const [part3, setPart3] = useState<ReadingPart>(); const [part3, setPart3] = useState<ReadingPart>();
const [minTimer, setMinTimer] = useState(60);
const [types, setTypes] = useState<string[]>([]); const [types, setTypes] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<ReadingExam>(); const [resultingExam, setResultingExam] = useState<ReadingExam>();
useEffect(() => {
const parts = [part1, part2, part3].filter((x) => !!x);
setMinTimer(parts.length === 0 ? 60 : parts.length * 20);
}, [part1, part2, part3]);
const router = useRouter(); const router = useRouter();
const setExams = useExamStore((state) => state.setExams); const setExams = useExamStore((state) => state.setExams);
@@ -122,19 +129,21 @@ const ReadingGeneration = () => {
}; };
const submitExam = () => { const submitExam = () => {
if (!part1 || !part2 || !part3) { const parts = [part1, part2, part3].filter((x) => !!x) as ReadingPart[];
toast.error("Please generate all three passages before submitting"); if (parts.length === 0) {
toast.error("Please generate at least one passage before submitting");
return; return;
} }
setIsLoading(true); setIsLoading(true);
const exam: ReadingExam = { const exam: ReadingExam = {
parts: [part1, part2, part3], parts,
isDiagnostic: false, isDiagnostic: false,
minTimer: 60, minTimer,
module: "reading", module: "reading",
id: v4(), id: v4(),
type: "academic", type: "academic",
variant: parts.length === 3 ? "full" : "partial",
}; };
axios axios
@@ -148,6 +157,7 @@ const ReadingGeneration = () => {
setPart1(undefined); setPart1(undefined);
setPart2(undefined); setPart2(undefined);
setPart3(undefined); setPart3(undefined);
setMinTimer(60);
setTypes([]); setTypes([]);
}) })
.catch((error) => { .catch((error) => {
@@ -159,6 +169,17 @@ const ReadingGeneration = () => {
return ( return (
<> <>
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
<Input
type="number"
name="minTimer"
onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))}
value={minTimer}
className="max-w-[300px]"
/>
</div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Exercises</label> <label className="font-normal text-base text-mti-gray-dim">Exercises</label>
<div className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between"> <div className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
@@ -182,35 +203,35 @@ const ReadingGeneration = () => {
<Tab <Tab
className={({selected}) => className={({selected}) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-reading/70", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-reading/70 flex gap-2 items-center justify-center",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-reading focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-reading focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-reading", selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-reading",
) )
}> }>
Passage 1 Passage 1 {part1 && <BsCheck />}
</Tab> </Tab>
<Tab <Tab
className={({selected}) => className={({selected}) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-reading/70", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-reading/70 flex gap-2 items-center justify-center",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-reading focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-reading focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-reading", selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-reading",
) )
}> }>
Passage 2 Passage 2 {part2 && <BsCheck />}
</Tab> </Tab>
<Tab <Tab
className={({selected}) => className={({selected}) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-reading/70", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-reading/70 flex gap-2 items-center justify-center",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-reading focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-reading focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-reading", selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-reading",
) )
}> }>
Passage 3 Passage 3 {part3 && <BsCheck />}
</Tab> </Tab>
</Tab.List> </Tab.List>
<Tab.Panels> <Tab.Panels>
@@ -237,14 +258,14 @@ const ReadingGeneration = () => {
</button> </button>
)} )}
<button <button
disabled={!part1 || !part2 || !part3 || isLoading} disabled={(!part1 && !part2 && !part3) || isLoading}
data-tip="Please generate all three passages" data-tip="Please generate all three passages"
onClick={submitExam} onClick={submitExam}
className={clsx( className={clsx(
"bg-ielts-reading/70 border border-ielts-reading text-white w-full max-w-[200px] rounded-xl h-[70px] self-end", "bg-ielts-reading/70 border border-ielts-reading text-white w-full max-w-[200px] rounded-xl h-[70px] self-end",
"hover:bg-ielts-reading disabled:bg-ielts-reading/40 disabled:cursor-not-allowed", "hover:bg-ielts-reading disabled:bg-ielts-reading/40 disabled:cursor-not-allowed",
"transition ease-in-out duration-300", "transition ease-in-out duration-300",
(!part1 || !part2 || !part3) && "tooltip", !part1 && !part2 && !part3 && "tooltip",
)}> )}>
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">

View File

@@ -1,5 +1,5 @@
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import {Exercise, SpeakingExam} from "@/interfaces/exam"; import {Exercise, InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams"; import {getExamById} from "@/utils/exams";
import {playSound} from "@/utils/sound"; import {playSound} from "@/utils/sound";
@@ -7,10 +7,12 @@ import {convertCamelCaseToReadable} from "@/utils/string";
import {Tab} from "@headlessui/react"; import {Tab} from "@headlessui/react";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import moment from "moment";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {useState} from "react"; import {useEffect, useState} from "react";
import {BsArrowRepeat} from "react-icons/bs"; import {BsArrowRepeat, BsCheck} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import {v4} from "uuid";
const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; setPart: (part?: SpeakingPart) => void}) => { const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; setPart: (part?: SpeakingPart) => void}) => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -18,10 +20,12 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
const generate = () => { const generate = () => {
setPart(undefined); setPart(undefined);
setIsLoading(true); setIsLoading(true);
axios axios
.get(`/api/exam/speaking/generate/speaking_task_${index}`) .get(`/api/exam/speaking/generate/speaking_task_${index}`)
.then((result) => { .then((result) => {
playSound("check"); playSound(typeof result.data === "string" ? "error" : "check");
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
setPart(result.data); setPart(result.data);
}) })
.catch((error) => { .catch((error) => {
@@ -31,6 +35,29 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
const generateVideo = () => {
if (!part) return toast.error("Please generate the first part before generating the video!");
toast.info("This will take quite a while, please do not leave this page or close the tab/window.");
setIsLoading(true);
const initialTime = moment();
axios
.post(`/api/exam/speaking/generate/speaking/generate_${index === 3 ? "interactive" : "speaking"}_video`, part)
.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});
})
.catch((e) => {
toast.error("Something went wrong!");
console.log(e);
})
.finally(() => setIsLoading(false));
};
return ( return (
<Tab.Panel className="w-full bg-ielts-speaking/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4"> <Tab.Panel className="w-full bg-ielts-speaking/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
<div className="flex gap-4 items-end"> <div className="flex gap-4 items-end">
@@ -52,6 +79,24 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
"Generate" "Generate"
)} )}
</button> </button>
<button
onClick={generateVideo}
disabled={isLoading || !part}
data-tip="The passage is currently being generated"
className={clsx(
"bg-ielts-speaking/70 border border-ielts-speaking text-white w-full rounded-xl h-[70px]",
"hover:bg-ielts-speaking disabled:bg-ielts-speaking/40 disabled:cursor-not-allowed",
"transition ease-in-out duration-300",
isLoading && "tooltip",
)}>
{isLoading ? (
<div className="flex items-center justify-center">
<BsArrowRepeat className="text-white animate-spin" size={25} />
</div>
) : (
"Generate Video"
)}
</button>
</div> </div>
{isLoading && ( {isLoading && (
<div className="w-fit h-fit mt-12 self-center animate-pulse flex flex-col gap-8 items-center"> <div className="w-fit h-fit mt-12 self-center animate-pulse flex flex-col gap-8 items-center">
@@ -59,7 +104,7 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
<span className={clsx("font-bold text-2xl text-ielts-speaking")}>Generating...</span> <span className={clsx("font-bold text-2xl text-ielts-speaking")}>Generating...</span>
</div> </div>
)} )}
{part && ( {part && !isLoading && (
<div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide h-96"> <div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide h-96">
<h3 className="text-xl font-semibold">{part.topic}</h3> <h3 className="text-xl font-semibold">{part.topic}</h3>
{part.question && <span className="w-full">{part.question}</span>} {part.question && <span className="w-full">{part.question}</span>}
@@ -82,6 +127,7 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
))} ))}
</div> </div>
)} )}
{part.result && <span className="font-bold mt-4">Video Generated: </span>}
</div> </div>
)} )}
</Tab.Panel> </Tab.Panel>
@@ -93,27 +139,43 @@ interface SpeakingPart {
question?: string; question?: string;
questions?: string[]; questions?: string[];
topic: string; topic: string;
result?: SpeakingExercise | InteractiveSpeakingExercise;
} }
const SpeakingGeneration = () => { const SpeakingGeneration = () => {
const [part1, setPart1] = useState<SpeakingPart>(); const [part1, setPart1] = useState<SpeakingPart>();
const [part2, setPart2] = useState<SpeakingPart>(); const [part2, setPart2] = useState<SpeakingPart>();
const [part3, setPart3] = useState<SpeakingPart>(); const [part3, setPart3] = useState<SpeakingPart>();
const [minTimer, setMinTimer] = useState(14);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<SpeakingExam>(); const [resultingExam, setResultingExam] = useState<SpeakingExam>();
useEffect(() => {
const parts = [part1, part2, part3].filter((x) => !!x);
setMinTimer(parts.length === 0 ? 5 : parts.length * 5);
}, [part1, part2, part3]);
const router = useRouter(); const router = useRouter();
const setExams = useExamStore((state) => state.setExams); const setExams = useExamStore((state) => state.setExams);
const setSelectedModules = useExamStore((state) => state.setSelectedModules); const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const submitExam = () => { const submitExam = () => {
if (!part1 || !part2 || !part3) return toast.error("Please generate all for tasks!"); if (!part1?.result && !part2?.result && !part3?.result) return toast.error("Please generate at least one task!");
setIsLoading(true); setIsLoading(true);
const exam: SpeakingExam = {
id: v4(),
isDiagnostic: false,
exercises: [part1?.result, part2?.result, part3?.result].filter((x) => !!x) as (SpeakingExercise | InteractiveSpeakingExercise)[],
minTimer,
variant: minTimer >= 14 ? "full" : "partial",
module: "speaking",
};
axios axios
.post(`/api/exam/speaking/generate/speaking`, {exercises: [part1, part2, part3]}) .post(`/api/exam/speaking`, exam)
.then((result) => { .then((result) => {
playSound("sent"); playSound("sent");
console.log(`Generated Exam ID: ${result.data.id}`); console.log(`Generated Exam ID: ${result.data.id}`);
@@ -123,10 +185,11 @@ const SpeakingGeneration = () => {
setPart1(undefined); setPart1(undefined);
setPart2(undefined); setPart2(undefined);
setPart3(undefined); setPart3(undefined);
setMinTimer(14);
}) })
.catch((error) => { .catch((error) => {
console.log(error); console.log(error);
toast.error("Something went wrong!"); toast.error("Something went wrong while generating, please try again later.");
}) })
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
@@ -149,40 +212,51 @@ const SpeakingGeneration = () => {
return ( return (
<> <>
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
<Input
type="number"
name="minTimer"
onChange={(e) => setMinTimer(parseInt(e) < 5 ? 5 : parseInt(e))}
value={minTimer}
className="max-w-[300px]"
/>
</div>
<Tab.Group> <Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1"> <Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
<Tab <Tab
className={({selected}) => className={({selected}) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/70", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/70 flex gap-2 items-center justify-center",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking", selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
) )
}> }>
Task 1 Exercise 1 {part1 && part1.result && <BsCheck />}
</Tab> </Tab>
<Tab <Tab
className={({selected}) => className={({selected}) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/70", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/70 flex gap-2 items-center justify-center",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking", selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
) )
}> }>
Task 2 Exercise 2 {part2 && part2.result && <BsCheck />}
</Tab> </Tab>
<Tab <Tab
className={({selected}) => className={({selected}) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/70", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/70 flex gap-2 items-center justify-center",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking", selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
) )
}> }>
Task 3 Interactive {part3 && part3.result && <BsCheck />}
</Tab> </Tab>
</Tab.List> </Tab.List>
<Tab.Panels> <Tab.Panels>
@@ -209,14 +283,14 @@ const SpeakingGeneration = () => {
</button> </button>
)} )}
<button <button
disabled={!part1 || !part2 || !part3 || isLoading} disabled={(!part1?.result && !part2?.result && !part3?.result) || isLoading}
data-tip="Please generate all three passages" data-tip="Please generate all three passages"
onClick={submitExam} onClick={submitExam}
className={clsx( className={clsx(
"bg-ielts-speaking/70 border border-ielts-speaking text-white w-full max-w-[200px] rounded-xl h-[70px] self-end", "bg-ielts-speaking/70 border border-ielts-speaking text-white w-full max-w-[200px] rounded-xl h-[70px] self-end",
"hover:bg-ielts-speaking disabled:bg-ielts-speaking/40 disabled:cursor-not-allowed", "hover:bg-ielts-speaking disabled:bg-ielts-speaking/40 disabled:cursor-not-allowed",
"transition ease-in-out duration-300", "transition ease-in-out duration-300",
(!part1 || !part2 || !part3) && "tooltip", !part1 && !part2 && !part3 && "tooltip",
)}> )}>
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">

View File

@@ -1,5 +1,5 @@
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import {WritingExam} from "@/interfaces/exam"; import {WritingExam, WritingExercise} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams"; import {getExamById} from "@/utils/exams";
import {playSound} from "@/utils/sound"; import {playSound} from "@/utils/sound";
@@ -7,8 +7,8 @@ import {Tab} from "@headlessui/react";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {useState} from "react"; import {useEffect, useState} from "react";
import {BsArrowRepeat} from "react-icons/bs"; import {BsArrowRepeat, BsCheck} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import {v4} from "uuid"; import {v4} from "uuid";
@@ -20,7 +20,8 @@ const TaskTab = ({task, index, setTask}: {task?: string; index: number; setTask:
axios axios
.get(`/api/exam/writing/generate/writing_task${index}_general`) .get(`/api/exam/writing/generate/writing_task${index}_general`)
.then((result) => { .then((result) => {
playSound("check"); playSound(typeof result.data === "string" ? "error" : "check");
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
setTask(result.data.question); setTask(result.data.question);
}) })
.catch((error) => { .catch((error) => {
@@ -68,9 +69,16 @@ const TaskTab = ({task, index, setTask}: {task?: string; index: number; setTask:
const WritingGeneration = () => { const WritingGeneration = () => {
const [task1, setTask1] = useState<string>(); const [task1, setTask1] = useState<string>();
const [task2, setTask2] = useState<string>(); const [task2, setTask2] = useState<string>();
const [minTimer, setMinTimer] = useState(60);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<WritingExam>(); const [resultingExam, setResultingExam] = useState<WritingExam>();
useEffect(() => {
const task1Timer = task1 ? 20 : 0;
const task2Timer = task2 ? 40 : 0;
setMinTimer(task1Timer > 0 || task2Timer > 0 ? task1Timer + task2Timer : 20);
}, [task1, task2]);
const router = useRouter(); const router = useRouter();
const setExams = useExamStore((state) => state.setExams); const setExams = useExamStore((state) => state.setExams);
@@ -93,18 +101,13 @@ const WritingGeneration = () => {
}; };
const submitExam = () => { const submitExam = () => {
if (!task1 || !task2) { if (!task1 && !task2) {
toast.error("Please generate all tasks before submitting"); toast.error("Please generate a task before submitting");
return; return;
} }
setIsLoading(true); const exercise1 = task1
const exam: WritingExam = { ? ({
isDiagnostic: false,
minTimer: 60,
module: "writing",
exercises: [
{
id: v4(), id: v4(),
type: "writing", type: "writing",
prefix: `You should spend about 20 minutes on this task.`, prefix: `You should spend about 20 minutes on this task.`,
@@ -115,8 +118,11 @@ const WritingGeneration = () => {
limit: 150, limit: 150,
type: "min", type: "min",
}, },
}, } as WritingExercise)
{ : undefined;
const exercise2 = task2
? ({
id: v4(), id: v4(),
type: "writing", type: "writing",
prefix: `You should spend about 40 minutes on this task.`, prefix: `You should spend about 40 minutes on this task.`,
@@ -127,9 +133,17 @@ const WritingGeneration = () => {
limit: 250, limit: 250,
type: "min", type: "min",
}, },
}, } as WritingExercise)
], : undefined;
setIsLoading(true);
const exam: WritingExam = {
isDiagnostic: false,
minTimer,
module: "writing",
exercises: [...(exercise1 ? [exercise1] : []), ...(exercise2 ? [exercise2] : [])],
id: v4(), id: v4(),
variant: exercise1 && exercise2 ? "full" : "partial",
}; };
axios axios
@@ -152,29 +166,40 @@ const WritingGeneration = () => {
return ( return (
<> <>
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
<Input
type="number"
name="minTimer"
onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))}
value={minTimer}
className="max-w-[300px]"
/>
</div>
<Tab.Group> <Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1"> <Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1">
<Tab <Tab
className={({selected}) => className={({selected}) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/70", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/70 flex gap-2 items-center justify-center",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing", selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
) )
}> }>
Task 1 Task 1 {task1 && <BsCheck />}
</Tab> </Tab>
<Tab <Tab
className={({selected}) => className={({selected}) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/70", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/70 flex gap-2 items-center justify-center",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing", selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
) )
}> }>
Task 2 Task 2 {task2 && <BsCheck />}
</Tab> </Tab>
</Tab.List> </Tab.List>
<Tab.Panels> <Tab.Panels>
@@ -200,14 +225,14 @@ const WritingGeneration = () => {
</button> </button>
)} )}
<button <button
disabled={!task1 || !task2 || isLoading} disabled={(!task1 && !task2) || isLoading}
data-tip="Please generate all three passages" data-tip="Please generate all three passages"
onClick={submitExam} onClick={submitExam}
className={clsx( className={clsx(
"bg-ielts-writing/70 border border-ielts-writing text-white w-full max-w-[200px] rounded-xl h-[70px] self-end", "bg-ielts-writing/70 border border-ielts-writing text-white w-full max-w-[200px] rounded-xl h-[70px] self-end",
"hover:bg-ielts-writing disabled:bg-ielts-writing/40 disabled:cursor-not-allowed", "hover:bg-ielts-writing disabled:bg-ielts-writing/40 disabled:cursor-not-allowed",
"transition ease-in-out duration-300", "transition ease-in-out duration-300",
(!task1 || !task2) && "tooltip", !task1 && !task2 && "tooltip",
)}> )}>
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">

View File

@@ -39,8 +39,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) return res.status(401).json({ok: false}); if (!req.session.user) return res.status(401).json({ok: false});
if (req.session.user.type !== "developer") return res.status(403).json({ok: false}); if (req.session.user.type !== "developer") return res.status(403).json({ok: false});
const {endpoint, topic, exercises} = req.query as {module: Module; endpoint: string; topic?: string; exercises?: string[]}; const {endpoint, topic, exercises} = req.query as {module: Module; endpoint: string[]; topic?: string; exercises?: string[]};
const url = `${process.env.BACKEND_URL}/${endpoint}`; const url = `${process.env.BACKEND_URL}/${endpoint.join("/")}`;
const result = await axios.post( const result = await axios.post(
`${url}${topic && exercises ? `?topic=${topic.toLowerCase()}&exercises=${exercises.join("&exercises=")}` : ""}`, `${url}${topic && exercises ? `?topic=${topic.toLowerCase()}&exercises=${exercises.join("&exercises=")}` : ""}`,

View File

@@ -4,7 +4,7 @@ import {app} from "@/firebase";
import {getFirestore, setDoc, doc} from "firebase/firestore"; import {getFirestore, setDoc, doc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {Exam} from "@/interfaces/exam"; import {Exam, Variant} from "@/interfaces/exam";
import {getExams} from "@/utils/exams.be"; import {getExams} from "@/utils/exams.be";
const db = getFirestore(app); const db = getFirestore(app);
@@ -23,12 +23,9 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
return; return;
} }
const { const {module, avoidRepeated, variant} = req.query as {module: string; avoidRepeated: string; variant?: Variant};
module,
avoidRepeated,
} = req.query as {module: string; avoidRepeated: string};
const exams: Exam[] = await getExams(db, module, avoidRepeated, req.session.user.id); const exams: Exam[] = await getExams(db, module, avoidRepeated, req.session.user.id, variant);
res.status(200).json(exams); res.status(200).json(exams);
} }

View File

@@ -74,6 +74,10 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
if (user.type === "admin" || user.type === "developer") { if (user.type === "admin" || user.type === "developer") {
await setDoc(snapshot.ref, req.body, {merge: true}); await setDoc(snapshot.ref, req.body, {merge: true});
if (req.body.isPaid) {
const corporateID = req.body.corporate;
await setDoc(doc(db, "users", corporateID), {status: "active"}, {merge: true});
}
return res.status(200).json({ok: true}); return res.status(200).json({ok: true});
} }

View File

@@ -119,6 +119,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const updatedDoc = (await getDoc(doc(db, "payments", paymentId))).data() as Payment; const updatedDoc = (await getDoc(doc(db, "payments", paymentId))).data() as Payment;
if (updatedDoc.commissionTransfer && updatedDoc.corporateTransfer) { if (updatedDoc.commissionTransfer && updatedDoc.corporateTransfer) {
await setDoc(doc(db, "payments", paymentId), {isPaid: true}, {merge: true}); await setDoc(doc(db, "payments", paymentId), {isPaid: true}, {merge: true});
await setDoc(doc(db, "users", updatedDoc.corporate), {status: "active"}, {merge: true});
} }
res.status(200).json({ref}); res.status(200).json({ref});
} catch (error) { } catch (error) {

View File

@@ -1,15 +1,6 @@
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app, storage} from "@/firebase"; import {app, storage} from "@/firebase";
import { import {getFirestore, doc, getDoc, updateDoc, getDocs, query, collection, where} from "firebase/firestore";
getFirestore,
doc,
getDoc,
updateDoc,
getDocs,
query,
collection,
where,
} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import ReactPDF from "@react-pdf/renderer"; import ReactPDF from "@react-pdf/renderer";
@@ -23,11 +14,7 @@ import { LevelExamDetails } from "@/exams/pdf/details/level.exam";
import {calculateBandScore} from "@/utils/score"; import {calculateBandScore} from "@/utils/score";
import axios from "axios"; import axios from "axios";
import {moduleLabels} from "@/utils/moduleUtils"; import {moduleLabels} from "@/utils/moduleUtils";
import { import {generateQRCode, getRadialProgressPNG, streamToBuffer} from "@/utils/pdf";
generateQRCode,
getRadialProgressPNG,
streamToBuffer,
} from "@/utils/pdf";
import moment from "moment-timezone"; import moment from "moment-timezone";
const db = getFirestore(app); const db = getFirestore(app);
@@ -102,16 +89,14 @@ const getSkillsFeedback = async (sections: SkillsFeedbackRequest[]) => {
headers: { headers: {
Authorization: `Bearer ${process.env.BACKEND_JWT}`, Authorization: `Bearer ${process.env.BACKEND_JWT}`,
}, },
} },
); );
return backendRequest.data?.sections; return backendRequest.data?.sections;
}; };
// perform the request with several retries if needed // perform the request with several retries if needed
const handleSkillsFeedbackRequest = async ( const handleSkillsFeedbackRequest = async (sections: SkillsFeedbackRequest[]): Promise<SkillsFeedbackResponse[] | null> => {
sections: SkillsFeedbackRequest[]
): Promise<SkillsFeedbackResponse[] | null> => {
let i = 0; let i = 0;
try { try {
const data = await getSkillsFeedback(sections); const data = await getSkillsFeedback(sections);
@@ -131,13 +116,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
if (req.session.user) { if (req.session.user) {
const {id} = req.query as {id: string}; const {id} = req.query as {id: string};
// fetch stats entries for this particular user with the requested exam session // fetch stats entries for this particular user with the requested exam session
const docsSnap = await getDocs( const docsSnap = await getDocs(query(collection(db, "stats"), where("session", "==", id)));
query(
collection(db, "stats"),
where("session", "==", id),
where("user", "==", req.session.user.id)
)
);
if (docsSnap.empty) { if (docsSnap.empty) {
res.status(400).end(); res.status(400).end();
@@ -166,9 +145,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const user = docUser.data() as User; const user = docUser.data() as User;
// generate the QR code for the report // generate the QR code for the report
const qrcode = await generateQRCode( const qrcode = await generateQRCode((req.headers.origin || "") + req.url);
(req.headers.origin || "") + req.url
);
if (!qrcode) { if (!qrcode) {
res.status(500).json({ok: false}); res.status(500).json({ok: false});
@@ -178,8 +155,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
// stats may contain multiple exams of the same type so we need to aggregate them // stats may contain multiple exams of the same type so we need to aggregate them
const results = ( const results = (
stats.reduce((accm: ModuleScore[], {module, score}) => { stats.reduce((accm: ModuleScore[], {module, score}) => {
const fixedModuleStr = const fixedModuleStr = module[0].toUpperCase() + module.substring(1);
module[0].toUpperCase() + module.substring(1);
if (accm.find((e: ModuleScore) => e.module === fixedModuleStr)) { if (accm.find((e: ModuleScore) => e.module === fixedModuleStr)) {
return accm.map((e: ModuleScore) => { return accm.map((e: ModuleScore) => {
if (e.module === fixedModuleStr) { if (e.module === fixedModuleStr) {
@@ -207,12 +183,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
).map((moduleScore) => { ).map((moduleScore) => {
const {score, total} = moduleScore; const {score, total} = moduleScore;
// with all the scores aggreated we can calculate the band score for each module // with all the scores aggreated we can calculate the band score for each module
const bandScore = calculateBandScore( const bandScore = calculateBandScore(score, total, moduleScore.code as Module, user.focus);
score,
total,
moduleScore.code as Module,
user.focus
);
return { return {
...moduleScore, ...moduleScore,
@@ -228,7 +199,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
code, code,
name: moduleLabels[code], name: moduleLabels[code],
grade: bandScore, grade: bandScore,
})) })),
)) as SkillsFeedbackResponse[]; )) as SkillsFeedbackResponse[];
if (!skillsFeedback) { if (!skillsFeedback) {
@@ -238,9 +209,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
// assign the feedback to the results // assign the feedback to the results
const finalResults = results.map((result) => { const finalResults = results.map((result) => {
const feedback = skillsFeedback.find( const feedback = skillsFeedback.find((f: SkillsFeedbackResponse) => f.code === result.code);
(f: SkillsFeedbackResponse) => f.code === result.code
);
if (feedback) { if (feedback) {
return { return {
@@ -254,14 +223,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
}); });
// calculate the overall score out of all the aggregated results // calculate the overall score out of all the aggregated results
const overallScore = results.reduce( const overallScore = results.reduce((accm, {score}) => accm + score, 0);
(accm, { score }) => accm + score, const overallTotal = results.reduce((accm, {total}) => accm + total, 0);
0
);
const overallTotal = results.reduce(
(accm, { total }) => accm + total,
0
);
const overallResult = overallScore / overallTotal; const overallResult = overallScore / overallTotal;
const overallPNG = getRadialProgressPNG("laranja", overallScore, overallTotal); const overallPNG = getRadialProgressPNG("laranja", overallScore, overallTotal);
@@ -278,22 +241,14 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const [stat] = stats; const [stat] = stats;
// generate the performance summary based on the overall result // generate the performance summary based on the overall result
const performanceSummary = getPerformanceSummary( const performanceSummary = getPerformanceSummary(stat.module, overallResult);
stat.module,
overallResult
);
// level exams have a different report structure than the skill exams // level exams have a different report structure than the skill exams
const getCustomData = () => { const getCustomData = () => {
if (stat.module === "level") { if (stat.module === "level") {
return { return {
title: "ENGLISH LEVEL TEST RESULT REPORT ", title: "ENGLISH LEVEL TEST RESULT REPORT ",
details: ( details: <LevelExamDetails detail={overallDetail} title="Level as per CEFR Levels" />,
<LevelExamDetails
detail={overallDetail}
title="Level as per CEFR Levels"
/>
),
}; };
} }
@@ -309,7 +264,9 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const pdfStream = await ReactPDF.renderToStream( const pdfStream = await ReactPDF.renderToStream(
<TestReport <TestReport
title={title} title={title}
date={moment(stat.date).tz(user.demographicInformation?.timezone || 'UTC').format('ll HH:mm:ss')} date={moment(stat.date)
.tz(user.demographicInformation?.timezone || "UTC")
.format("ll HH:mm:ss")}
name={user.name} name={user.name}
email={user.email} email={user.email}
id={user.id} id={user.id}
@@ -322,7 +279,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
summaryPNG={overallPNG} summaryPNG={overallPNG}
summaryScore={`${(overallResult * 100).toFixed(0)}%`} summaryScore={`${(overallResult * 100).toFixed(0)}%`}
passportId={demographicInformation?.passport_id || ""} passportId={demographicInformation?.passport_id || ""}
/> />,
); );
// generate the file ref for storage // generate the file ref for storage
@@ -361,9 +318,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query as {id: string}; const {id} = req.query as {id: string};
const docsSnap = await getDocs( const docsSnap = await getDocs(query(collection(db, "stats"), where("session", "==", id)));
query(collection(db, "stats"), where("session", "==", id))
);
if (docsSnap.empty) { if (docsSnap.empty) {
res.status(404).end(); res.status(404).end();

View File

@@ -111,11 +111,13 @@ const PaymentCreator = ({onClose, reload, showComission = false}: {onClose: () =
options={(users.filter((u) => u.type === "corporate") as CorporateUser[]).map((user) => ({ options={(users.filter((u) => u.type === "corporate") as CorporateUser[]).map((user) => ({
value: user.id, value: user.id,
meta: user, meta: user,
label: `${user.corporateInformation.companyInformation.name || user.name} - ${user.email}`, label: `${user.corporateInformation?.companyInformation?.name || user.name} - ${user.email}`,
}))} }))}
defaultValue={{value: "undefined", label: "Select an account"}} defaultValue={{value: "undefined", label: "Select an account"}}
onChange={(value) => setCorporate((value as any)?.meta ?? undefined)} onChange={(value) => setCorporate((value as any)?.meta ?? undefined)}
menuPortalTarget={document?.body}
styles={{ styles={{
menuPortal: (base) => ({...base, zIndex: 9999}),
control: (styles) => ({ control: (styles) => ({
...styles, ...styles,
paddingLeft: "4px", paddingLeft: "4px",
@@ -144,7 +146,9 @@ const PaymentCreator = ({onClose, reload, showComission = false}: {onClose: () =
defaultValue={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}} defaultValue={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
onChange={() => {}} onChange={() => {}}
value={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}} value={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
menuPortalTarget={document?.body}
styles={{ styles={{
menuPortal: (base) => ({...base, zIndex: 9999}),
control: (styles) => ({ control: (styles) => ({
...styles, ...styles,
paddingLeft: "4px", paddingLeft: "4px",
@@ -824,7 +828,9 @@ export default function PaymentRecord() {
} }
isDisabled={user.type === "corporate"} isDisabled={user.type === "corporate"}
onChange={(value) => setCorporate((value as any)?.meta ?? undefined)} onChange={(value) => setCorporate((value as any)?.meta ?? undefined)}
menuPortalTarget={document?.body}
styles={{ styles={{
menuPortal: (base) => ({...base, zIndex: 9999}),
control: (styles) => ({ control: (styles) => ({
...styles, ...styles,
paddingLeft: "4px", paddingLeft: "4px",
@@ -859,7 +865,9 @@ export default function PaymentRecord() {
}))} }))}
value={agent ? {value: agent?.id, label: `${agent.name} - ${agent.email}`} : undefined} value={agent ? {value: agent?.id, label: `${agent.name} - ${agent.email}`} : undefined}
onChange={(value) => setAgent(value !== null ? (value as any).meta : undefined)} onChange={(value) => setAgent(value !== null ? (value as any).meta : undefined)}
menuPortalTarget={document?.body}
styles={{ styles={{
menuPortal: (base) => ({...base, zIndex: 9999}),
control: (styles) => ({ control: (styles) => ({
...styles, ...styles,
paddingLeft: "4px", paddingLeft: "4px",
@@ -892,7 +900,9 @@ export default function PaymentRecord() {
if (value) return setPaid(value.value); if (value) return setPaid(value.value);
setPaid(null); setPaid(null);
}} }}
menuPortalTarget={document?.body}
styles={{ styles={{
menuPortal: (base) => ({...base, zIndex: 9999}),
control: (styles) => ({ control: (styles) => ({
...styles, ...styles,
paddingLeft: "4px", paddingLeft: "4px",
@@ -947,7 +957,9 @@ export default function PaymentRecord() {
if (value) return setCommissionTransfer(value.value); if (value) return setCommissionTransfer(value.value);
setCommissionTransfer(null); setCommissionTransfer(null);
}} }}
menuPortalTarget={document?.body}
styles={{ styles={{
menuPortal: (base) => ({...base, zIndex: 9999}),
control: (styles) => ({ control: (styles) => ({
...styles, ...styles,
paddingLeft: "4px", paddingLeft: "4px",
@@ -979,7 +991,9 @@ export default function PaymentRecord() {
if (value) return setCorporateTransfer(value.value); if (value) return setCorporateTransfer(value.value);
setCorporateTransfer(null); setCorporateTransfer(null);
}} }}
menuPortalTarget={document?.body}
styles={{ styles={{
menuPortal: (base) => ({...base, zIndex: 9999}),
control: (styles) => ({ control: (styles) => ({
...styles, ...styles,
paddingLeft: "4px", paddingLeft: "4px",

View File

@@ -306,7 +306,9 @@ export default function History({user}: {user: User}) {
options={users.map((x) => ({value: x.id, label: `${x.name} - ${x.email}`}))} options={users.map((x) => ({value: x.id, label: `${x.name} - ${x.email}`}))}
defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}} defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}}
onChange={(value) => setStatsUserId(value?.value)} onChange={(value) => setStatsUserId(value?.value)}
menuPortalTarget={document?.body}
styles={{ styles={{
menuPortal: (base) => ({...base, zIndex: 9999}),
option: (styles, state) => ({ option: (styles, state) => ({
...styles, ...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
@@ -322,7 +324,9 @@ export default function History({user}: {user: User}) {
.map((x) => ({value: x.id, label: `${x.name} - ${x.email}`}))} .map((x) => ({value: x.id, label: `${x.name} - ${x.email}`}))}
defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}} defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}}
onChange={(value) => setStatsUserId(value?.value)} onChange={(value) => setStatsUserId(value?.value)}
menuPortalTarget={document?.body}
styles={{ styles={{
menuPortal: (base) => ({...base, zIndex: 9999}),
option: (styles, state) => ({ option: (styles, state) => ({
...styles, ...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",

View File

@@ -178,7 +178,7 @@ export default function Stats() {
}, },
{ {
icon: <BsStar className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />, icon: <BsStar className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: `${stats.length > 0 ? averageScore(userStats) : 0}%`, value: `${userStats.length > 0 ? averageScore(userStats) : 0}%`,
label: "Average Score", label: "Average Score",
}, },
]} ]}
@@ -193,7 +193,9 @@ export default function Stats() {
options={users.map((x) => ({value: x.id, label: `${x.name} - ${x.email}`}))} options={users.map((x) => ({value: x.id, label: `${x.name} - ${x.email}`}))}
defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}} defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}}
onChange={(value) => setStatsUserId(value?.value)} onChange={(value) => setStatsUserId(value?.value)}
menuPortalTarget={document?.body}
styles={{ styles={{
menuPortal: (base) => ({...base, zIndex: 9999}),
option: (styles, state) => ({ option: (styles, state) => ({
...styles, ...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
@@ -210,7 +212,9 @@ export default function Stats() {
.map((x) => ({value: x.id, label: `${x.name} - ${x.email}`}))} .map((x) => ({value: x.id, label: `${x.name} - ${x.email}`}))}
defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}} defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}}
onChange={(value) => setStatsUserId(value?.value)} onChange={(value) => setStatsUserId(value?.value)}
menuPortalTarget={document?.body}
styles={{ styles={{
menuPortal: (base) => ({...base, zIndex: 9999}),
option: (styles, state) => ({ option: (styles, state) => ({
...styles, ...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",

View File

@@ -1,14 +1,6 @@
import { import {collection, getDocs, query, where, setDoc, doc, Firestore} from "firebase/firestore";
collection,
getDocs,
query,
where,
setDoc,
doc,
Firestore,
} from "firebase/firestore";
import {shuffle} from "lodash"; import {shuffle} from "lodash";
import { Exam } from "@/interfaces/exam"; import {Exam, Variant} from "@/interfaces/exam";
import {Stat} from "@/interfaces/user"; import {Stat} from "@/interfaces/user";
export const getExams = async ( export const getExams = async (
@@ -18,20 +10,24 @@ export const getExams = async (
// added userId as due to assignments being set from the teacher to the student // added userId as due to assignments being set from the teacher to the student
// we need to make sure we are serving exams not executed by the user and not // we need to make sure we are serving exams not executed by the user and not
// by the teacher that performed the request // by the teacher that performed the request
userId: string | undefined userId: string | undefined,
variant?: Variant,
): Promise<Exam[]> => { ): Promise<Exam[]> => {
const moduleRef = collection(db, module); const moduleRef = collection(db, module);
const q = query(moduleRef, where("isDiagnostic", "==", false)); const q = query(moduleRef, where("isDiagnostic", "==", false));
const snapshot = await getDocs(q); const snapshot = await getDocs(q);
const exams: Exam[] = shuffle( const exams: Exam[] = filterByVariant(
shuffle(
snapshot.docs.map((doc) => ({ snapshot.docs.map((doc) => ({
id: doc.id, id: doc.id,
...doc.data(), ...doc.data(),
module, module,
})) })),
) as Exam[]; ) as Exam[],
variant,
);
if (avoidRepeated === "true") { if (avoidRepeated === "true") {
const statsQ = query(collection(db, "stats"), where("user", "==", userId)); const statsQ = query(collection(db, "stats"), where("user", "==", userId));
@@ -41,12 +37,15 @@ export const getExams = async (
id: doc.id, id: doc.id,
...doc.data(), ...doc.data(),
})) as unknown as Stat[]; })) as unknown as Stat[];
const filteredExams = exams.filter( const filteredExams = exams.filter((x) => !stats.map((s) => s.exam).includes(x.id));
(x) => !stats.map((s) => s.exam).includes(x.id)
);
return filteredExams.length > 0 ? filteredExams : exams; return filteredExams.length > 0 ? filteredExams : exams;
} }
return exams; return exams;
}; };
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;
};

View File

@@ -1,9 +1,13 @@
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import {Exam, ReadingExam, ListeningExam, WritingExam, SpeakingExam, Exercise, UserSolution, LevelExam} from "@/interfaces/exam"; import {Exam, ReadingExam, ListeningExam, WritingExam, SpeakingExam, Exercise, UserSolution, LevelExam, Variant} from "@/interfaces/exam";
import axios from "axios"; import axios from "axios";
export const getExam = async (module: Module, avoidRepeated: boolean): Promise<Exam | undefined> => { export const getExam = async (module: Module, avoidRepeated: boolean, variant?: Variant): Promise<Exam | undefined> => {
const examRequest = await axios<Exam[]>(`/api/exam/${module}?avoidRepeated=${avoidRepeated}`); const url = new URLSearchParams();
url.append("avoidRepeated", avoidRepeated.toString());
if (variant) url.append("variant", variant);
const examRequest = await axios<Exam[]>(`/api/exam/${module}?${url.toString()}`);
if (examRequest.status !== 200) { if (examRequest.status !== 200) {
return undefined; return undefined;
} }

View File

@@ -2,8 +2,8 @@ import moment from "moment";
export function dateSorter(a: any, b: any, direction: "asc" | "desc", key: string) { export function dateSorter(a: any, b: any, direction: "asc" | "desc", key: string) {
if (!a[key] && !b[key]) return 0; if (!a[key] && !b[key]) return 0;
if (a[key] && !b[key]) return direction === "asc" ? -1 : 1; if (a[key] && !b[key]) return direction === "asc" ? 1 : -1;
if (!a[key] && b[key]) return direction === "asc" ? 1 : -1; if (!a[key] && b[key]) return direction === "asc" ? -1 : 1;
if (moment(a[key]).isAfter(b[key])) return direction === "asc" ? 1 : -1; if (moment(a[key]).isAfter(b[key])) return direction === "asc" ? 1 : -1;
if (moment(b[key]).isAfter(a[key])) return direction === "asc" ? -1 : 1; if (moment(b[key]).isAfter(a[key])) return direction === "asc" ? -1 : 1;
return 0; return 0;

View File

@@ -1,6 +1,6 @@
import {Howl, Howler} from "howler"; import {Howl, Howler} from "howler";
export type Sound = "check" | "sent"; export type Sound = "check" | "sent" | "error";
export const playSound = (path: Sound) => { export const playSound = (path: Sound) => {
const sound = new Howl({ const sound = new Howl({
src: [`audio/${path}.mp3`], src: [`audio/${path}.mp3`],