Compare commits

..

4 Commits

Author SHA1 Message Date
Tiago Ribeiro
03f78ceb46 Added the same functionality to the Assignments 2024-02-09 13:23:35 +00:00
Tiago Ribeiro
872cc62fe4 - Added the ability for a student/developer to choose a gender for a speaking instructor;
- Made it so, if chosen, the user will only get speaking exams with their chosen gender;
- Added the ability for speaking exams to select a gender when generating;
2024-02-09 12:14:47 +00:00
Joao Ramos
ce7032c8a7 Added agent as a possible idsplay on the list for ticket 2024-02-08 19:49:53 +00:00
Tiago Ribeiro
71f07af2eb Added "instructorGender" key to Speaking exams 2024-02-08 14:04:52 +00:00
14 changed files with 360 additions and 239 deletions

View File

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

View File

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

View File

@@ -19,7 +19,8 @@ import {toast} from "react-toastify";
import {uuidv4} from "@firebase/util"; import {uuidv4} from "@firebase/util";
import {Assignment} from "@/interfaces/results"; import {Assignment} from "@/interfaces/results";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import {Variant} from "@/interfaces/exam"; import {InstructorGender, Variant} from "@/interfaces/exam";
import Select from "@/components/Low/Select";
interface Props { interface Props {
isCreating: boolean; isCreating: boolean;
@@ -40,6 +41,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(), assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(),
); );
const [variant, setVariant] = useState<Variant>("full"); const [variant, setVariant] = useState<Variant>("full");
const [instructorGender, setInstructorGender] = useState<InstructorGender>(assignment?.instructorGender || "varied");
// creates a new exam for each assignee or just one exam for all assignees // creates a new exam for each assignee or just one exam for all assignees
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false); const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
@@ -63,6 +65,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
selectedModules, selectedModules,
generateMultiple, generateMultiple,
variant, variant,
instructorGender,
}) })
.then(() => { .then(() => {
toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`); toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`);
@@ -226,6 +229,20 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
</div> </div>
</div> </div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor&apos;s Gender</label>
<Select
value={{value: instructorGender, label: capitalize(instructorGender)}}
onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)}
disabled={!selectedModules.includes("speaking") || !!assignment}
options={[
{value: "male", label: "Male"},
{value: "female", label: "Female"},
{value: "varied", label: "Varied"},
]}
/>
</div>
<section className="w-full flex flex-col gap-3"> <section className="w-full flex flex-col gap-3">
<span className="font-semibold">Assignees ({assignees.length} selected)</span> <span className="font-semibold">Assignees ({assignees.length} selected)</span>
<div className="flex gap-4 overflow-x-scroll scrollbar-hide"> <div className="flex gap-4 overflow-x-scroll scrollbar-hide">

View File

@@ -2,6 +2,7 @@ import {Module} from ".";
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam; export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
export type Variant = "full" | "partial"; export type Variant = "full" | "partial";
export type InstructorGender = "male" | "female" | "varied";
export interface ReadingExam { export interface ReadingExam {
parts: ReadingPart[]; parts: ReadingPart[];
@@ -82,6 +83,7 @@ export interface SpeakingExam {
minTimer: number; minTimer: number;
isDiagnostic: boolean; isDiagnostic: boolean;
variant?: Variant; variant?: Variant;
instructorGender: InstructorGender;
} }
export type Exercise = export type Exercise =

View File

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

View File

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

View File

@@ -123,6 +123,7 @@ export default function ExamPage({page}: Props) {
setSessionId(shortUID.randomUUID(8)); setSessionId(shortUID.randomUUID(8));
} }
}, [setSessionId, selectedModules, sessionId]); }, [setSessionId, selectedModules, sessionId]);
useEffect(() => { useEffect(() => {
if (user?.type === "developer") console.log(exam); if (user?.type === "developer") console.log(exam);
}, [exam, user]); }, [exam, user]);
@@ -160,7 +161,14 @@ 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, variant)); const examPromises = selectedModules.map((module) =>
getExam(
module,
avoidRepeated,
variant,
user?.type === "student" || user?.type === "developer" ? user.preferredGender : undefined,
),
);
Promise.all(examPromises).then((values) => { Promise.all(examPromises).then((values) => {
if (values.every((x) => !!x)) { if (values.every((x) => !!x)) {
setExams(values.map((x) => x!)); setExams(values.map((x) => x!));

View File

@@ -1,5 +1,7 @@
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select";
import {Exercise, InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise} from "@/interfaces/exam"; import {Exercise, InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise} from "@/interfaces/exam";
import {AVATARS} from "@/resources/speakingAvatars";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams"; import {getExamById} from "@/utils/exams";
import {playSound} from "@/utils/sound"; import {playSound} from "@/utils/sound";
@@ -7,6 +9,7 @@ import {convertCamelCaseToReadable} from "@/utils/string";
import {Tab} from "@headlessui/react"; import {Tab} from "@headlessui/react";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize, sample, uniq} from "lodash";
import moment from "moment"; import moment from "moment";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
@@ -15,6 +18,7 @@ import {toast} from "react-toastify";
import {v4} from "uuid"; import {v4} from "uuid";
const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; setPart: (part?: SpeakingPart) => void}) => { const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; setPart: (part?: SpeakingPart) => void}) => {
const [gender, setGender] = useState<"male" | "female">("male");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const generate = () => { const generate = () => {
@@ -39,17 +43,19 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
if (!part) return toast.error("Please generate the first part before generating the video!"); if (!part) return toast.error("Please generate the first part before generating the video!");
toast.info("This will take quite a while, please do not leave this page or close the tab/window."); toast.info("This will take quite a while, please do not leave this page or close the tab/window.");
const avatar = sample(AVATARS.filter((x) => x.gender === gender));
setIsLoading(true); setIsLoading(true);
const initialTime = moment(); const initialTime = moment();
axios axios
.post(`/api/exam/speaking/generate/speaking/generate_${index === 3 ? "interactive" : "speaking"}_video`, part) .post(`/api/exam/speaking/generate/speaking/generate_${index === 3 ? "interactive" : "speaking"}_video`, {...part, avatar: avatar?.id})
.then((result) => { .then((result) => {
const isError = typeof result.data === "string" || moment().diff(initialTime, "seconds") < 60; const isError = typeof result.data === "string" || moment().diff(initialTime, "seconds") < 60;
playSound(isError ? "error" : "check"); playSound(isError ? "error" : "check");
if (isError) return toast.error("Something went wrong, please try to generate the video again."); if (isError) return toast.error("Something went wrong, please try to generate the video again.");
setPart({...part, result: result.data}); setPart({...part, result: result.data, gender, avatar});
}) })
.catch((e) => { .catch((e) => {
toast.error("Something went wrong!"); toast.error("Something went wrong!");
@@ -60,6 +66,18 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
return ( return (
<Tab.Panel className="w-full bg-ielts-speaking/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4"> <Tab.Panel className="w-full bg-ielts-speaking/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Gender</label>
<Select
options={[
{value: "male", label: "Male"},
{value: "female", label: "Female"},
]}
value={{value: gender, label: capitalize(gender)}}
onChange={(value) => (value ? setGender(value.value as typeof gender) : null)}
disabled={isLoading}
/>
</div>
<div className="flex gap-4 items-end"> <div className="flex gap-4 items-end">
<button <button
onClick={generate} onClick={generate}
@@ -128,6 +146,11 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
</div> </div>
)} )}
{part.result && <span className="font-bold mt-4">Video Generated: </span>} {part.result && <span className="font-bold mt-4">Video Generated: </span>}
{part.avatar && part.gender && (
<span>
<b>Instructor:</b> {part.avatar.name} - {capitalize(part.avatar.gender)}
</span>
)}
</div> </div>
)} )}
</Tab.Panel> </Tab.Panel>
@@ -140,6 +163,8 @@ interface SpeakingPart {
questions?: string[]; questions?: string[];
topic: string; topic: string;
result?: SpeakingExercise | InteractiveSpeakingExercise; result?: SpeakingExercise | InteractiveSpeakingExercise;
gender?: "male" | "female";
avatar?: (typeof AVATARS)[number];
} }
const SpeakingGeneration = () => { const SpeakingGeneration = () => {
@@ -165,6 +190,8 @@ const SpeakingGeneration = () => {
setIsLoading(true); setIsLoading(true);
const genders = [part1?.gender, part2?.gender, part3?.gender].filter((x) => !!x);
const exam: SpeakingExam = { const exam: SpeakingExam = {
id: v4(), id: v4(),
isDiagnostic: false, isDiagnostic: false,
@@ -172,6 +199,7 @@ const SpeakingGeneration = () => {
minTimer, minTimer,
variant: minTimer >= 14 ? "full" : "partial", variant: minTimer >= 14 ? "full" : "partial",
module: "speaking", module: "speaking",
instructorGender: genders.every((x) => x === "male") ? "male" : genders.every((x) => x === "female") ? "female" : "varied",
}; };
axios axios

View File

@@ -1,22 +1,13 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase"; import {app} from "@/firebase";
import { import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc} from "firebase/firestore";
getFirestore,
collection,
getDocs,
query,
where,
setDoc,
doc,
getDoc,
} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {uuidv4} from "@firebase/util"; import {uuidv4} from "@firebase/util";
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import {getExams} from "@/utils/exams.be"; import {getExams} from "@/utils/exams.be";
import { Exam, Variant } from "@/interfaces/exam"; import {Exam, InstructorGender, Variant} from "@/interfaces/exam";
import {capitalize, flatten, uniqBy} from "lodash"; import {capitalize, flatten, uniqBy} from "lodash";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import moment from "moment"; import moment from "moment";
@@ -66,20 +57,14 @@ const generateExams = async (
selectedModules: Module[], selectedModules: Module[],
assignees: string[], assignees: string[],
variant?: Variant, variant?: Variant,
instructorGender?: InstructorGender,
): Promise<ExamWithUser[]> => { ): Promise<ExamWithUser[]> => {
if (generateMultiple) { if (generateMultiple) {
// for optimization purposes, it would be better to create a new endpoint that returned the answers for all users at once // for optimization purposes, it would be better to create a new endpoint that returned the answers for all users at once
const allExams = assignees.map(async (assignee) => { const allExams = assignees.map(async (assignee) => {
const selectedModulePromises = selectedModules.map( const selectedModulePromises = selectedModules.map(async (module: Module) => {
async (module: Module) => {
try { try {
const exams: Exam[] = await getExams( const exams: Exam[] = await getExams(db, module, "true", assignee, variant, instructorGender);
db,
module,
"true",
assignee,
variant,
);
const exam = exams[getRandomIndex(exams)]; const exam = exams[getRandomIndex(exams)];
if (exam) { if (exam) {
@@ -90,22 +75,18 @@ const generateExams = async (
console.error(e); console.error(e);
return null; return null;
} }
}, }, []);
[],
);
const newModules = await Promise.all(selectedModulePromises); const newModules = await Promise.all(selectedModulePromises);
return newModules; return newModules;
}, []); }, []);
const exams = flatten(await Promise.all(allExams)).filter( const exams = flatten(await Promise.all(allExams)).filter((x) => x !== null) as ExamWithUser[];
(x) => x !== null,
) as ExamWithUser[];
return exams; return exams;
} }
const selectedModulePromises = selectedModules.map(async (module: Module) => { const selectedModulePromises = selectedModules.map(async (module: Module) => {
const exams: Exam[] = await getExams(db, module, "false", undefined); const exams: Exam[] = await getExams(db, module, "false", undefined, variant, instructorGender);
const exam = exams[getRandomIndex(exams)]; const exam = exams[getRandomIndex(exams)];
if (exam) { if (exam) {
@@ -116,11 +97,7 @@ const generateExams = async (
const exams = await Promise.all(selectedModulePromises); const exams = await Promise.all(selectedModulePromises);
const examesFiltered = exams.filter((x) => x !== null) as ExamWithUser[]; const examesFiltered = exams.filter((x) => x !== null) as ExamWithUser[];
return flatten( return flatten(assignees.map((assignee) => examesFiltered.map((exam) => ({...exam, assignee}))));
assignees.map((assignee) =>
examesFiltered.map((exam) => ({ ...exam, assignee })),
),
);
}; };
async function POST(req: NextApiRequest, res: NextApiResponse) { async function POST(req: NextApiRequest, res: NextApiResponse) {
@@ -131,6 +108,7 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
// false would generate the same exam for all users // false would generate the same exam for all users
generateMultiple = false, generateMultiple = false,
variant, variant,
instructorGender,
...body ...body
} = req.body as { } = req.body as {
selectedModules: Module[]; selectedModules: Module[];
@@ -140,19 +118,13 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
startDate: string; startDate: string;
endDate: string; endDate: string;
variant?: Variant; variant?: Variant;
instructorGender?: InstructorGender;
}; };
const exams: ExamWithUser[] = await generateExams( const exams: ExamWithUser[] = await generateExams(generateMultiple, selectedModules, assignees, variant, instructorGender);
generateMultiple,
selectedModules,
assignees,
variant,
);
if (exams.length === 0) { if (exams.length === 0) {
res res.status(400).json({ok: false, error: "No exams found for the selected modules"});
.status(400)
.json({ ok: false, error: "No exams found for the selected modules" });
return; return;
} }
@@ -161,6 +133,7 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
assignees, assignees,
results: [], results: [],
exams, exams,
instructorGender,
...body, ...body,
}); });

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, Variant} from "@/interfaces/exam"; import {Exam, InstructorGender, 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,9 +23,14 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
return; return;
} }
const {module, avoidRepeated, variant} = req.query as {module: string; avoidRepeated: string; variant?: Variant}; const {module, avoidRepeated, variant, instructorGender} = req.query as {
module: string;
avoidRepeated: string;
variant?: Variant;
instructorGender?: InstructorGender;
};
const exams: Exam[] = await getExams(db, module, avoidRepeated, req.session.user.id, variant); const exams: Exam[] = await getExams(db, module, avoidRepeated, req.session.user.id, variant, instructorGender);
res.status(200).json(exams); res.status(200).json(exams);
} }

View File

@@ -28,6 +28,9 @@ import TimezoneSelect from "@/components/Low/TImezoneSelect";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import ModuleLevelSelector from "@/components/Medium/ModuleLevelSelector"; import ModuleLevelSelector from "@/components/Medium/ModuleLevelSelector";
import Select from "@/components/Low/Select";
import {InstructorGender} from "@/interfaces/exam";
import {capitalize} from "lodash";
export const getServerSideProps = withIronSessionSsr(({req, res}) => { export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user; const user = req.session.user;
@@ -83,6 +86,11 @@ function UserProfile({user, mutateUser}: Props) {
user.type === "corporate" ? undefined : user.demographicInformation?.employment, user.type === "corporate" ? undefined : user.demographicInformation?.employment,
); );
const [passport_id, setPassportID] = useState<string | undefined>(user.type === "student" ? user.demographicInformation?.passport_id : undefined); const [passport_id, setPassportID] = useState<string | undefined>(user.type === "student" ? user.demographicInformation?.passport_id : undefined);
const [preferredGender, setPreferredGender] = useState<InstructorGender | undefined>(
user.type === "student" || user.type === "developer" ? user.preferredGender || "varied" : undefined,
);
const [position, setPosition] = useState<string | undefined>(user.type === "corporate" ? user.demographicInformation?.position : undefined); const [position, setPosition] = useState<string | undefined>(user.type === "corporate" ? user.demographicInformation?.position : undefined);
const [corporateInformation, setCorporateInformation] = useState(user.type === "corporate" ? user.corporateInformation : undefined); const [corporateInformation, setCorporateInformation] = useState(user.type === "corporate" ? user.corporateInformation : undefined);
const [companyName, setCompanyName] = useState<string | undefined>(user.type === "agent" ? user.agentInformation?.companyName : undefined); const [companyName, setCompanyName] = useState<string | undefined>(user.type === "agent" ? user.agentInformation?.companyName : undefined);
@@ -90,6 +98,7 @@ function UserProfile({user, mutateUser}: Props) {
user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined, user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined,
); );
const [timezone, setTimezone] = useState<string>(user.demographicInformation?.timezone || moment.tz.guess()); const [timezone, setTimezone] = useState<string>(user.demographicInformation?.timezone || moment.tz.guess());
const {groups} = useGroups(); const {groups} = useGroups();
const {users} = useUsers(); const {users} = useUsers();
@@ -146,6 +155,7 @@ function UserProfile({user, mutateUser}: Props) {
newPassword, newPassword,
profilePicture, profilePicture,
desiredLevels, desiredLevels,
preferredGender,
demographicInformation: { demographicInformation: {
phone, phone,
country, country,
@@ -337,6 +347,24 @@ function UserProfile({user, mutateUser}: Props) {
</div> </div>
)} )}
{preferredGender && ["developer", "student"].includes(user.type) && (
<>
<Divider />
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor&apos;s Gender</label>
<Select
value={{value: preferredGender, label: capitalize(preferredGender)}}
onChange={(value) => (value ? setPreferredGender(value.value as InstructorGender) : null)}
options={[
{value: "male", label: "Male"},
{value: "female", label: "Female"},
{value: "varied", label: "Varied"},
]}
/>
</div>
</>
)}
<Divider /> <Divider />
{user.type === "corporate" && ( {user.type === "corporate" && (

View File

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

@@ -1,6 +1,6 @@
import {collection, getDocs, query, where, setDoc, doc, Firestore} from "firebase/firestore"; import {collection, getDocs, query, where, setDoc, doc, Firestore} from "firebase/firestore";
import {shuffle} from "lodash"; import {shuffle} from "lodash";
import {Exam, Variant} from "@/interfaces/exam"; import {Exam, InstructorGender, Variant} from "@/interfaces/exam";
import {Stat} from "@/interfaces/user"; import {Stat} from "@/interfaces/user";
export const getExams = async ( export const getExams = async (
@@ -12,22 +12,23 @@ export const getExams = async (
// by the teacher that performed the request // by the teacher that performed the request
userId: string | undefined, userId: string | undefined,
variant?: Variant, variant?: Variant,
instructorGender?: InstructorGender,
): Promise<Exam[]> => { ): Promise<Exam[]> => {
const moduleRef = collection(db, module); const moduleRef = collection(db, module);
const q = query(moduleRef, where("isDiagnostic", "==", false)); const q = query(moduleRef, where("isDiagnostic", "==", false));
const snapshot = await getDocs(q); const snapshot = await getDocs(q);
const exams: Exam[] = filterByVariant( const allExams = shuffle(
shuffle(
snapshot.docs.map((doc) => ({ snapshot.docs.map((doc) => ({
id: doc.id, id: doc.id,
...doc.data(), ...doc.data(),
module, module,
})), })),
) as Exam[], ) as Exam[];
variant,
); const variantExams: Exam[] = filterByVariant(allExams, variant);
const genderedExams: Exam[] = filterByInstructorGender(variantExams, instructorGender);
if (avoidRepeated === "true") { if (avoidRepeated === "true") {
const statsQ = query(collection(db, "stats"), where("user", "==", userId)); const statsQ = query(collection(db, "stats"), where("user", "==", userId));
@@ -37,12 +38,18 @@ export const getExams = async (
id: doc.id, id: doc.id,
...doc.data(), ...doc.data(),
})) as unknown as Stat[]; })) as unknown as Stat[];
const filteredExams = exams.filter((x) => !stats.map((s) => s.exam).includes(x.id)); const filteredExams = genderedExams.filter((x) => !stats.map((s) => s.exam).includes(x.id));
return filteredExams.length > 0 ? filteredExams : exams; return filteredExams.length > 0 ? filteredExams : genderedExams;
} }
return exams; return genderedExams;
};
const filterByInstructorGender = (exams: Exam[], instructorGender?: InstructorGender) => {
if (!instructorGender || instructorGender === "varied") return exams;
return exams.filter((e) => (e.module === "speaking" ? e.instructorGender === instructorGender : true));
}; };
const filterByVariant = (exams: Exam[], variant?: Variant) => { const filterByVariant = (exams: Exam[], variant?: Variant) => {

View File

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