Merge branch 'develop'

This commit is contained in:
Tiago Ribeiro
2024-02-15 16:10:06 +00:00
15 changed files with 445 additions and 311 deletions

View File

@@ -41,7 +41,7 @@ export default function Writing({
if (inputText.length > 0 && saveTimer % 10 === 0) { if (inputText.length > 0 && saveTimer % 10 === 0) {
setUserSolutions([ setUserSolutions([
...storeUserSolutions.filter((x) => x.exercise !== id), ...storeUserSolutions.filter((x) => x.exercise !== id),
{exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type}, {exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type, module: "writing"},
]); ]);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -64,7 +64,8 @@ export default function Writing({
}, []); }, []);
useEffect(() => { useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type}); if (hasExamEnded)
onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type, module: "writing"});
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]); }, [hasExamEnded]);
@@ -160,6 +161,7 @@ export default function Writing({
solutions: [{id, solution: inputText.replaceAll(/\s{2,}/g, " ")}], solutions: [{id, solution: inputText.replaceAll(/\s{2,}/g, " ")}],
score: {correct: 1, total: 1, missing: 0}, score: {correct: 1, total: 1, missing: 0},
type, type,
module: "writing",
}) })
} }
className="max-w-[200px] self-end w-full"> className="max-w-[200px] self-end w-full">

View File

@@ -34,6 +34,7 @@ export default function Layout({user, children, className, navDisabled = false,
onFocusLayerMouseEnter={onFocusLayerMouseEnter} onFocusLayerMouseEnter={onFocusLayerMouseEnter}
className="-md:hidden" className="-md:hidden"
userType={user.type} userType={user.type}
userId={user.id}
/> />
<div <div
className={clsx( className={clsx(

View File

@@ -21,7 +21,11 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
const [timer, setTimer] = useState(minTimer * 60); const [timer, setTimer] = useState(minTimer * 60);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [warningMode, setWarningMode] = useState(false); const [warningMode, setWarningMode] = useState(false);
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded); const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
const {timeSpent} = useExamStore((state) => state);
useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]);
useEffect(() => { useEffect(() => {
if (!disableTimer) { if (!disableTimer) {

View File

@@ -0,0 +1,70 @@
import topics from "@/resources/topics";
import {useState} from "react";
import {BsArrowLeft, BsArrowRight} from "react-icons/bs";
import Button from "../Low/Button";
import Modal from "../Modal";
interface Props {
isOpen: boolean;
initialTopics: string[];
onClose: VoidFunction;
selectTopics: (topics: string[]) => void;
}
export default function TopicModal({isOpen, initialTopics, onClose, selectTopics}: Props) {
const [selectedTopics, setSelectedTopics] = useState([...initialTopics]);
return (
<Modal isOpen={isOpen} onClose={onClose} title="Preferred Topics">
<div className="flex flex-col w-full h-full gap-4 mt-4">
<div className="w-full h-full grid grid-cols-2 -md:gap-1 gap-4">
<div className="flex flex-col gap-2">
<span className="border-b border-b-neutral-400/30">Available Topics</span>
<div className=" max-h-[500px] overflow-y-scroll scrollbar-hide">
{topics
.filter((x) => !selectedTopics.includes(x))
.map((x) => (
<div key={x} className="odd:bg-mti-purple-ultralight/40 p-2 flex justify-between items-center">
<span>{x}</span>
<button
onClick={() => setSelectedTopics((prev) => [...prev, x])}
className="border border-mti-purple-light cursor-pointer p-2 rounded-lg bg-white drop-shadow transition ease-in-out duration-300 hover:bg-mti-purple hover:text-white">
<BsArrowRight />
</button>
</div>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<span className="border-b border-b-neutral-400/30">Preferred Topics ({selectedTopics.length || "All"})</span>
<div className=" max-h-[500px] overflow-y-scroll scrollbar-hide">
{selectedTopics.map((x) => (
<div key={x} className="odd:bg-mti-purple-ultralight/40 p-2 flex justify-between items-center text-right">
<button
onClick={() => setSelectedTopics((prev) => [...prev.filter((y) => y !== x)])}
className="border border-mti-purple-light cursor-pointer p-2 rounded-lg bg-white drop-shadow transition ease-in-out duration-300 hover:bg-mti-purple hover:text-white">
<BsArrowLeft />
</button>
<span>{x}</span>
</div>
))}
</div>
</div>
</div>
<div className="w-full flex gap-4 items-center justify-end">
<Button variant="outline" color="rose" className="w-full max-w-[200px]" onClick={onClose}>
Close
</Button>
<Button
className="w-full max-w-[200px]"
onClick={() => {
selectTopics(selectedTopics);
onClose();
}}>
Select
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -1,295 +1,206 @@
import clsx from "clsx"; import clsx from "clsx";
import { IconType } from "react-icons"; import {IconType} from "react-icons";
import { MdSpaceDashboard } from "react-icons/md"; import {MdSpaceDashboard} from "react-icons/md";
import { import {
BsFileEarmarkText, BsFileEarmarkText,
BsClockHistory, BsClockHistory,
BsPencil, BsPencil,
BsGraphUp, BsGraphUp,
BsChevronBarRight, BsChevronBarRight,
BsChevronBarLeft, BsChevronBarLeft,
BsShieldFill, BsShieldFill,
BsCloudFill, BsCloudFill,
BsCurrencyDollar, BsCurrencyDollar,
BsClipboardData, BsClipboardData,
} from "react-icons/bs"; } from "react-icons/bs";
import { RiLogoutBoxFill } from "react-icons/ri"; import {RiLogoutBoxFill} from "react-icons/ri";
import { SlPencil } from "react-icons/sl"; import {SlPencil} from "react-icons/sl";
import { FaAward } from "react-icons/fa"; import {FaAward} from "react-icons/fa";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import {useRouter} from "next/router";
import axios from "axios"; import axios from "axios";
import FocusLayer from "@/components/FocusLayer"; import FocusLayer from "@/components/FocusLayer";
import { preventNavigation } from "@/utils/navigation.disabled"; import {preventNavigation} from "@/utils/navigation.disabled";
import { useState } from "react"; import {useEffect, useState} from "react";
import usePreferencesStore from "@/stores/preferencesStore"; import usePreferencesStore from "@/stores/preferencesStore";
import { Type } from "@/interfaces/user"; import {Type} from "@/interfaces/user";
import useTicketsListener from "@/hooks/useTicketsListener";
interface Props { interface Props {
path: string; path: string;
navDisabled?: boolean; navDisabled?: boolean;
focusMode?: boolean; focusMode?: boolean;
onFocusLayerMouseEnter?: () => void; onFocusLayerMouseEnter?: () => void;
className?: string; className?: string;
userType?: Type; userType?: Type;
userId?: string;
} }
interface NavProps { interface NavProps {
Icon: IconType; Icon: IconType;
label: string; label: string;
path: string; path: string;
keyPath: string; keyPath: string;
disabled?: boolean; disabled?: boolean;
isMinimized?: boolean; isMinimized?: boolean;
badge?: number;
} }
const Nav = ({ const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false, badge}: NavProps) => {
Icon, return (
label, <Link
path, href={!disabled ? keyPath : ""}
keyPath, className={clsx(
disabled = false, "flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white",
isMinimized = false, "transition-all duration-300 ease-in-out relative",
}: NavProps) => ( disabled ? "hover:bg-mti-gray-dim cursor-not-allowed" : "hover:bg-mti-purple-light cursor-pointer",
<Link path === keyPath && "bg-mti-purple-light text-white",
href={!disabled ? keyPath : ""} isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]",
className={clsx( )}>
"flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white", <Icon size={24} />
"transition-all duration-300 ease-in-out", {!isMinimized && <span className="text-lg font-semibold">{label}</span>}
disabled {!!badge && badge > 0 && (
? "hover:bg-mti-gray-dim cursor-not-allowed" <div
: "hover:bg-mti-purple-light cursor-pointer", className={clsx(
path === keyPath && "bg-mti-purple-light text-white", "bg-mti-purple-light h-5 w-5 text-xs rounded-full flex items-center justify-center text-white",
isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]", "transition ease-in-out duration-300",
)} isMinimized && "absolute right-0 top-0",
> )}>
<Icon size={24} /> {badge}
{!isMinimized && <span className="text-lg font-semibold">{label}</span>} </div>
</Link> )}
); </Link>
);
};
export default function Sidebar({ export default function Sidebar({path, navDisabled = false, focusMode = false, userType, onFocusLayerMouseEnter, className, userId}: Props) {
path, const router = useRouter();
navDisabled = false,
focusMode = false,
userType,
onFocusLayerMouseEnter,
className,
}: Props) {
const router = useRouter();
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [ const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
state.isSidebarMinimized,
state.toggleSidebarMinimized,
]);
const logout = async () => { const {totalAssignedTickets} = useTicketsListener(userId);
axios.post("/api/logout").finally(() => {
setTimeout(() => router.reload(), 500);
});
};
const disableNavigation = preventNavigation(navDisabled, focusMode); useEffect(() => console.log(totalAssignedTickets), [totalAssignedTickets]);
return ( const logout = async () => {
<section axios.post("/api/logout").finally(() => {
className={clsx( setTimeout(() => router.reload(), 500);
"relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8", });
isMinimized ? "w-fit" : "-xl:w-fit w-1/6", };
className,
)}
>
<div className="-xl:hidden flex-col gap-3 xl:flex">
<Nav
disabled={disableNavigation}
Icon={MdSpaceDashboard}
label="Dashboard"
path={path}
keyPath="/"
isMinimized={isMinimized}
/>
{(userType === "student" ||
userType === "teacher" ||
userType === "developer") && (
<>
<Nav
disabled={disableNavigation}
Icon={BsFileEarmarkText}
label="Exams"
path={path}
keyPath="/exam"
isMinimized={isMinimized}
/>
<Nav
disabled={disableNavigation}
Icon={BsPencil}
label="Exercises"
path={path}
keyPath="/exercises"
isMinimized={isMinimized}
/>
</>
)}
<Nav
disabled={disableNavigation}
Icon={BsGraphUp}
label="Stats"
path={path}
keyPath="/stats"
isMinimized={isMinimized}
/>
<Nav
disabled={disableNavigation}
Icon={BsClockHistory}
label="Record"
path={path}
keyPath="/record"
isMinimized={isMinimized}
/>
{["admin", "developer", "agent", "corporate"].includes(
userType || "",
) && (
<Nav
disabled={disableNavigation}
Icon={BsCurrencyDollar}
label="Payment Record"
path={path}
keyPath="/payment-record"
isMinimized={isMinimized}
/>
)}
{["admin", "developer", "corporate", "teacher"].includes(
userType || "",
) && (
<Nav
disabled={disableNavigation}
Icon={BsShieldFill}
label="Settings"
path={path}
keyPath="/settings"
isMinimized={isMinimized}
/>
)}
{["admin", "developer", "agent"].includes(userType || "") && (
<Nav
disabled={disableNavigation}
Icon={BsClipboardData}
label="Tickets"
path={path}
keyPath="/tickets"
isMinimized={isMinimized}
/>
)}
{userType === "developer" && (
<Nav
disabled={disableNavigation}
Icon={BsCloudFill}
label="Generation"
path={path}
keyPath="/generation"
isMinimized={isMinimized}
/>
)}
</div>
<div className="-xl:flex flex-col gap-3 xl:hidden">
<Nav
disabled={disableNavigation}
Icon={MdSpaceDashboard}
label="Dashboard"
path={path}
keyPath="/"
isMinimized={true}
/>
<Nav
disabled={disableNavigation}
Icon={BsFileEarmarkText}
label="Exams"
path={path}
keyPath="/exam"
isMinimized={true}
/>
<Nav
disabled={disableNavigation}
Icon={BsPencil}
label="Exercises"
path={path}
keyPath="/exercises"
isMinimized={true}
/>
<Nav
disabled={disableNavigation}
Icon={BsGraphUp}
label="Stats"
path={path}
keyPath="/stats"
isMinimized={true}
/>
<Nav
disabled={disableNavigation}
Icon={BsClockHistory}
label="Record"
path={path}
keyPath="/record"
isMinimized={true}
/>
{userType !== "student" && (
<Nav
disabled={disableNavigation}
Icon={BsShieldFill}
label="Settings"
path={path}
keyPath="/settings"
isMinimized={true}
/>
)}
{userType === "developer" && (
<Nav
disabled={disableNavigation}
Icon={BsCloudFill}
label="Generation"
path={path}
keyPath="/generation"
isMinimized={true}
/>
)}
</div>
<div className="fixed bottom-12 flex flex-col gap-0"> const disableNavigation = preventNavigation(navDisabled, focusMode);
<div
role="button" return (
tabIndex={1} <section
onClick={toggleMinimize} className={clsx(
className={clsx( "relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8",
"hover:text-mti-rose -xl:hidden flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out", isMinimized ? "w-fit" : "-xl:w-fit w-1/6",
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8", className,
)} )}>
> <div className="-xl:hidden flex-col gap-3 xl:flex">
{isMinimized ? ( <Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
<BsChevronBarRight size={24} /> {(userType === "student" || userType === "teacher" || userType === "developer") && (
) : ( <>
<BsChevronBarLeft size={24} /> <Nav
)} disabled={disableNavigation}
{!isMinimized && ( Icon={BsFileEarmarkText}
<span className="text-lg font-medium">Minimize</span> label="Exams"
)} path={path}
</div> keyPath="/exam"
<div isMinimized={isMinimized}
role="button" />
tabIndex={1} <Nav
onClick={focusMode ? () => {} : logout} disabled={disableNavigation}
className={clsx( Icon={BsPencil}
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out", label="Exercises"
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8", path={path}
)} keyPath="/exercises"
> isMinimized={isMinimized}
<RiLogoutBoxFill size={24} /> />
{!isMinimized && ( </>
<span className="-xl:hidden text-lg font-medium">Log Out</span> )}
)} <Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
</div> <Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
</div> {["admin", "developer", "agent", "corporate"].includes(userType || "") && (
{focusMode && ( <Nav
<FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} /> disabled={disableNavigation}
)} Icon={BsCurrencyDollar}
</section> label="Payment Record"
); path={path}
keyPath="/payment-record"
isMinimized={isMinimized}
/>
)}
{["admin", "developer", "corporate", "teacher"].includes(userType || "") && (
<Nav
disabled={disableNavigation}
Icon={BsShieldFill}
label="Settings"
path={path}
keyPath="/settings"
isMinimized={isMinimized}
/>
)}
{["admin", "developer", "agent"].includes(userType || "") && (
<Nav
disabled={disableNavigation}
Icon={BsClipboardData}
label="Tickets"
path={path}
keyPath="/tickets"
isMinimized={isMinimized}
badge={totalAssignedTickets}
/>
)}
{userType === "developer" && (
<Nav
disabled={disableNavigation}
Icon={BsCloudFill}
label="Generation"
path={path}
keyPath="/generation"
isMinimized={isMinimized}
/>
)}
</div>
<div className="-xl:flex flex-col gap-3 xl:hidden">
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} />
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} />
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
{userType !== "student" && (
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized={true} />
)}
{userType === "developer" && (
<Nav disabled={disableNavigation} Icon={BsCloudFill} label="Generation" path={path} keyPath="/generation" isMinimized={true} />
)}
</div>
<div className="fixed bottom-12 flex flex-col gap-0">
<div
role="button"
tabIndex={1}
onClick={toggleMinimize}
className={clsx(
"hover:text-mti-rose -xl:hidden flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
)}>
{isMinimized ? <BsChevronBarRight size={24} /> : <BsChevronBarLeft size={24} />}
{!isMinimized && <span className="text-lg font-medium">Minimize</span>}
</div>
<div
role="button"
tabIndex={1}
onClick={focusMode ? () => {} : logout}
className={clsx(
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
)}>
<RiLogoutBoxFill size={24} />
{!isMinimized && <span className="-xl:hidden text-lg font-medium">Log Out</span>}
</div>
</div>
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
</section>
);
} }

View File

@@ -16,7 +16,7 @@ interface Props {
} }
const INSTRUCTIONS_AUDIO_SRC = const INSTRUCTIONS_AUDIO_SRC =
"https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_recordings%2Fgeneric_intro.mp3?alt=media&token=9b9cfdb8-e90d-40d1-854b-51c4378a5c4b"; "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/generic_listening_intro_v2.mp3?alt=media&token=16769f5f-1e9b-4a72-86a9-45a6f0fa9f82";
export default function Listening({exam, showSolutions = false, onFinish}: Props) { export default function Listening({exam, showSolutions = false, onFinish}: Props) {
const [questionIndex, setQuestionIndex] = useState(0); const [questionIndex, setQuestionIndex] = useState(0);

View File

@@ -1,22 +1,22 @@
import { Ticket } from "@/interfaces/ticket"; import { Ticket } from "@/interfaces/ticket";
import { Code, Group, User } from "@/interfaces/user"; import { Code, Group, User } from "@/interfaces/user";
import axios from "axios"; import axios from "axios";
import { useEffect, useState } from "react"; import { useEffect, useState, useCallback } from "react";
export default function useTickets() { export default function useTickets() {
const [tickets, setTickets] = useState<Ticket[]>([]); const [tickets, setTickets] = useState<Ticket[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false); const [isError, setIsError] = useState(false);
const getData = () => { const getData = useCallback(() => {
setIsLoading(true); setIsLoading(true);
axios axios
.get<Ticket[]>(`/api/tickets`) .get<Ticket[]>(`/api/tickets`)
.then((response) => setTickets(response.data)) .then((response) => setTickets(response.data))
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; }, []);
useEffect(getData, []); useEffect(getData, [getData]);
return { tickets, isLoading, isError, reload: getData }; return { tickets, isLoading, isError, reload: getData };
} }

View File

@@ -0,0 +1,29 @@
import React from "react";
import useTickets from "./useTickets";
const useTicketsListener = (userId?: string) => {
const { tickets, reload } = useTickets();
React.useEffect(() => {
const intervalId = setInterval(() => {
reload();
}, 60 * 1000);
return () => clearInterval(intervalId);
}, [reload]);
if (userId) {
const assignedTickets = tickets.filter(
(ticket) => ticket.assignedTo === userId && ticket.status === "submitted"
);
return {
assignedTickets,
totalAssignedTickets: assignedTickets.length,
};
}
return {};
};
export default useTicketsListener;

View File

@@ -69,7 +69,7 @@ export interface UserSolution {
export interface WritingExam { export interface WritingExam {
module: "writing"; module: "writing";
id: string; id: string;
exercises: Exercise[]; exercises: WritingExercise[];
minTimer: number; minTimer: number;
isDiagnostic: boolean; isDiagnostic: boolean;
variant?: Variant; variant?: Variant;
@@ -84,7 +84,7 @@ interface WordCounter {
export interface SpeakingExam { export interface SpeakingExam {
id: string; id: string;
module: "speaking"; module: "speaking";
exercises: Exercise[]; exercises: (SpeakingExercise | InteractiveSpeakingExercise)[];
minTimer: number; minTimer: number;
isDiagnostic: boolean; isDiagnostic: boolean;
variant?: Variant; variant?: Variant;
@@ -148,6 +148,7 @@ export interface WritingExercise {
solution: string; solution: string;
evaluation?: CommonEvaluation; evaluation?: CommonEvaluation;
}[]; }[];
topic?: string;
} }
export interface SpeakingExercise { export interface SpeakingExercise {
@@ -162,6 +163,7 @@ export interface SpeakingExercise {
solution: string; solution: string;
evaluation?: SpeakingEvaluation; evaluation?: SpeakingEvaluation;
}[]; }[];
topic?: string;
} }
export interface InteractiveSpeakingExercise { export interface InteractiveSpeakingExercise {
@@ -175,6 +177,7 @@ export interface InteractiveSpeakingExercise {
solution: {questionIndex: number; question: string; answer: string}[]; solution: {questionIndex: number; question: string; answer: string}[];
evaluation?: InteractiveSpeakingEvaluation; evaluation?: InteractiveSpeakingEvaluation;
}[]; }[];
topic?: string;
} }
export interface FillBlanksExercise { export interface FillBlanksExercise {

View File

@@ -24,6 +24,7 @@ export interface StudentUser extends BasicUser {
type: "student"; type: "student";
preferredGender?: InstructorGender; preferredGender?: InstructorGender;
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
preferredTopics?: string[];
} }
export interface TeacherUser extends BasicUser { export interface TeacherUser extends BasicUser {
@@ -52,6 +53,7 @@ export interface DeveloperUser extends BasicUser {
type: "developer"; type: "developer";
preferredGender?: InstructorGender; preferredGender?: InstructorGender;
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
preferredTopics?: string[];
} }
export interface CorporateInformation { export interface CorporateInformation {

View File

@@ -336,12 +336,13 @@ export default function ExamPage({page}: Props) {
}; };
answers.forEach((x) => { answers.forEach((x) => {
console.log({x}); const examModule =
x.module || (x.type === "writing" ? "writing" : x.type === "speaking" || x.type === "interactiveSpeaking" ? "speaking" : undefined);
scores[x.module!] = { scores[examModule!] = {
total: scores[x.module!].total + x.score.total, total: scores[examModule!].total + x.score.total,
correct: scores[x.module!].correct + x.score.correct, correct: scores[examModule!].correct + x.score.correct,
missing: scores[x.module!].missing + x.score.missing, missing: scores[examModule!].missing + x.score.missing,
}; };
}); });

View File

@@ -70,7 +70,7 @@ const PartTab = ({
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, gender, avatar}); setPart({...part, result: {...result.data, topic: part?.topic}, gender, avatar});
}) })
.catch((e) => { .catch((e) => {
toast.error("Something went wrong!"); toast.error("Something went wrong!");
@@ -79,6 +79,8 @@ const PartTab = ({
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
useEffect(() => console.log(part), [part]);
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"> <div className="flex flex-col gap-3 w-full">

View File

@@ -16,7 +16,7 @@ import {CorporateUser, EmploymentStatus, EMPLOYMENT_STATUS, Gender, User} from "
import CountrySelect from "@/components/Low/CountrySelect"; import CountrySelect from "@/components/Low/CountrySelect";
import {shouldRedirectHome} from "@/utils/navigation.disabled"; import {shouldRedirectHome} from "@/utils/navigation.disabled";
import moment from "moment"; import moment from "moment";
import {BsCamera} from "react-icons/bs"; import {BsCamera, BsQuestionCircleFill} from "react-icons/bs";
import {USER_TYPE_LABELS} from "@/resources/user"; import {USER_TYPE_LABELS} from "@/resources/user";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
@@ -31,6 +31,8 @@ import ModuleLevelSelector from "@/components/Medium/ModuleLevelSelector";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import {InstructorGender} from "@/interfaces/exam"; import {InstructorGender} from "@/interfaces/exam";
import {capitalize} from "lodash"; import {capitalize} from "lodash";
import TopicModal from "@/components/Medium/TopicModal";
import {v4} from "uuid";
export const getServerSideProps = withIronSessionSsr(({req, res}) => { export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user; const user = req.session.user;
@@ -90,6 +92,9 @@ function UserProfile({user, mutateUser}: Props) {
const [preferredGender, setPreferredGender] = useState<InstructorGender | undefined>( const [preferredGender, setPreferredGender] = useState<InstructorGender | undefined>(
user.type === "student" || user.type === "developer" ? user.preferredGender || "varied" : undefined, user.type === "student" || user.type === "developer" ? user.preferredGender || "varied" : undefined,
); );
const [preferredTopics, setPreferredTopics] = useState<string[] | undefined>(
user.type === "student" || user.type === "developer" ? user.preferredTopics : 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);
@@ -99,6 +104,8 @@ function UserProfile({user, mutateUser}: Props) {
); );
const [timezone, setTimezone] = useState<string>(user.demographicInformation?.timezone || moment.tz.guess()); const [timezone, setTimezone] = useState<string>(user.demographicInformation?.timezone || moment.tz.guess());
const [isPreferredTopicsOpen, setIsPreferredTopicsOpen] = useState(false);
const {groups} = useGroups(); const {groups} = useGroups();
const {users} = useUsers(); const {users} = useUsers();
@@ -156,6 +163,7 @@ function UserProfile({user, mutateUser}: Props) {
profilePicture, profilePicture,
desiredLevels, desiredLevels,
preferredGender, preferredGender,
preferredTopics,
demographicInformation: { demographicInformation: {
phone, phone,
country, country,
@@ -350,18 +358,41 @@ function UserProfile({user, mutateUser}: Props) {
{preferredGender && ["developer", "student"].includes(user.type) && ( {preferredGender && ["developer", "student"].includes(user.type) && (
<> <>
<Divider /> <Divider />
<div className="flex flex-col gap-3 w-full"> <DoubleColumnRow>
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor&apos;s Gender</label> <div className="flex flex-col gap-3 w-full">
<Select <label className="font-normal text-base text-mti-gray-dim">Speaking Instructor&apos;s Gender</label>
value={{value: preferredGender, label: capitalize(preferredGender)}} <Select
onChange={(value) => (value ? setPreferredGender(value.value as InstructorGender) : null)} value={{value: preferredGender, label: capitalize(preferredGender)}}
options={[ onChange={(value) => (value ? setPreferredGender(value.value as InstructorGender) : null)}
{value: "male", label: "Male"}, options={[
{value: "female", label: "Female"}, {value: "male", label: "Male"},
{value: "varied", label: "Varied"}, {value: "female", label: "Female"},
]} {value: "varied", label: "Varied"},
/> ]}
</div> />
</div>
<div className="flex flex-col gap-4 w-full">
<label className="font-normal text-base text-mti-gray-dim flex gap-2 items-center">
Preferred Topics{" "}
<span
className="tooltip"
data-tip="These topics will be considered for speaking and writing modules, aiming to include at least one exercise containing of the these in the selected exams.">
<BsQuestionCircleFill />
</span>
</label>
<Button className="w-full" variant="outline" onClick={() => setIsPreferredTopicsOpen(true)}>
Select Topics ({preferredTopics?.length || "All"} selected)
</Button>
</div>
</DoubleColumnRow>
<TopicModal
key={v4()}
isOpen={isPreferredTopicsOpen}
onClose={() => setIsPreferredTopicsOpen(false)}
selectTopics={setPreferredTopics}
initialTopics={preferredTopics || []}
/>
</> </>
)} )}

55
src/resources/topics.ts Normal file
View File

@@ -0,0 +1,55 @@
const topics = [
"Education",
"Technology",
"Environment",
"Health and Fitness",
"Globalization",
"Engineering",
"Work and Careers",
"Travel and Tourism",
"Culture and Traditions",
"Social Issues",
"Arts and Entertainment",
"Climate Change",
"Social Media",
"Sustainable Development",
"Health Care",
"Immigration",
"Artificial Intelligence",
"Consumerism",
"Online Shopping",
"Energy",
"Oil and Gas",
"Poverty and Inequality",
"Cultural Diversity",
"Democracy and Governance",
"Mental Health",
"Ethics and Morality",
"Population Growth",
"Science and Innovation",
"Poverty Alleviation",
"Cybersecurity and Privacy",
"Human Rights",
"Social Justice",
"Food and Agriculture",
"Cyberbullying and Online Safety",
"Linguistic Diversity",
"Urbanization",
"Artificial Intelligence in Education",
"Youth Empowerment",
"Disaster Management",
"Mental Health Stigma",
"Internet Censorship",
"Sustainable Fashion",
"Indigenous Rights",
"Water Scarcity",
"Social Entrepreneurship",
"Privacy in the Digital Age",
"Sustainable Transportation",
"Gender Equality",
"Automation and Job Displacement",
"Digital Divide",
"Education Inequality",
];
export default topics;

View File

@@ -1,7 +1,7 @@
import {collection, getDocs, query, where, setDoc, doc, Firestore, getDoc} from "firebase/firestore"; import {collection, getDocs, query, where, setDoc, doc, Firestore, getDoc} from "firebase/firestore";
import {shuffle} from "lodash"; import {shuffle} from "lodash";
import {Difficulty, Exam, InstructorGender, Variant} from "@/interfaces/exam"; import {Difficulty, Exam, InstructorGender, SpeakingExam, Variant, WritingExam} from "@/interfaces/exam";
import {Stat, User} from "@/interfaces/user"; import {DeveloperUser, Stat, StudentUser, User} from "@/interfaces/user";
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
export const getExams = async ( export const getExams = async (
@@ -28,9 +28,10 @@ export const getExams = async (
})), })),
) as Exam[]; ) as Exam[];
const variantExams: Exam[] = filterByVariant(allExams, variant); let exams: Exam[] = filterByVariant(allExams, variant);
const genderedExams: Exam[] = filterByInstructorGender(variantExams, instructorGender); exams = filterByInstructorGender(exams, instructorGender);
const difficultyExams: Exam[] = await filterByDifficulty(db, genderedExams, module, userId); exams = await filterByDifficulty(db, exams, module, userId);
exams = await filterByPreference(db, exams, module, userId);
if (avoidRepeated === "true") { if (avoidRepeated === "true") {
const statsQ = query(collection(db, "stats"), where("user", "==", userId)); const statsQ = query(collection(db, "stats"), where("user", "==", userId));
@@ -40,12 +41,12 @@ export const getExams = async (
id: doc.id, id: doc.id,
...doc.data(), ...doc.data(),
})) as unknown as Stat[]; })) as unknown as Stat[];
const filteredExams = difficultyExams.filter((x) => !stats.map((s) => s.exam).includes(x.id)); const filteredExams = exams.filter((x) => !stats.map((s) => s.exam).includes(x.id));
return filteredExams.length > 0 ? filteredExams : difficultyExams; return filteredExams.length > 0 ? filteredExams : exams;
} }
return difficultyExams; return exams;
}; };
const filterByInstructorGender = (exams: Exam[], instructorGender?: InstructorGender) => { const filterByInstructorGender = (exams: Exam[], instructorGender?: InstructorGender) => {
@@ -70,3 +71,25 @@ const filterByDifficulty = async (db: Firestore, exams: Exam[], module: Module,
const filteredExams = exams.filter((exam) => exam.difficulty === difficulty); const filteredExams = exams.filter((exam) => exam.difficulty === difficulty);
return filteredExams.length === 0 ? exams : filteredExams; return filteredExams.length === 0 ? exams : filteredExams;
}; };
const filterByPreference = async (db: Firestore, exams: Exam[], module: Module, userID?: string) => {
if (!["speaking", "writing"].includes(module)) return exams;
if (!userID) return exams;
const userRef = await getDoc(doc(db, "users", userID));
if (!userRef.exists()) return exams;
const user = {...userRef.data(), id: userRef.id} as StudentUser | DeveloperUser;
if (!["developer", "student"].includes(user.type)) return exams;
if (!user.preferredTopics || user.preferredTopics.length === 0) return exams;
const userTopics = user.preferredTopics;
const topicalExams = exams.filter((e) => {
const exam = e as WritingExam | SpeakingExam;
const topics = exam.exercises.map((x) => x.topic).filter((x) => !!x) as string[];
return topics.some((topic) => userTopics.map((x) => x.toLowerCase()).includes(topic.toLowerCase()));
});
return topicalExams.length > 0 ? shuffle(topicalExams) : exams;
};