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,6 +1,6 @@
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,
@@ -13,17 +13,18 @@ import {
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;
@@ -31,6 +32,7 @@ interface Props {
onFocusLayerMouseEnter?: () => void; onFocusLayerMouseEnter?: () => void;
className?: string; className?: string;
userType?: Type; userType?: Type;
userId?: string;
} }
interface NavProps { interface NavProps {
@@ -40,47 +42,44 @@ interface NavProps {
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,
path,
keyPath,
disabled = false,
isMinimized = false,
}: NavProps) => (
<Link <Link
href={!disabled ? keyPath : ""} href={!disabled ? keyPath : ""}
className={clsx( className={clsx(
"flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white", "flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white",
"transition-all duration-300 ease-in-out", "transition-all duration-300 ease-in-out relative",
disabled disabled ? "hover:bg-mti-gray-dim cursor-not-allowed" : "hover:bg-mti-purple-light cursor-pointer",
? "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] px-8 2xl:min-w-[220px]", isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]",
)} )}>
>
<Icon size={24} /> <Icon size={24} />
{!isMinimized && <span className="text-lg font-semibold">{label}</span>} {!isMinimized && <span className="text-lg font-semibold">{label}</span>}
{!!badge && badge > 0 && (
<div
className={clsx(
"bg-mti-purple-light h-5 w-5 text-xs rounded-full flex items-center justify-center text-white",
"transition ease-in-out duration-300",
isMinimized && "absolute right-0 top-0",
)}>
{badge}
</div>
)}
</Link> </Link>
); );
};
export default function Sidebar({ export default function Sidebar({path, navDisabled = false, focusMode = false, userType, onFocusLayerMouseEnter, className, userId}: Props) {
path,
navDisabled = false,
focusMode = false,
userType,
onFocusLayerMouseEnter,
className,
}: Props) {
const router = useRouter(); const router = useRouter();
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [ const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
state.isSidebarMinimized,
state.toggleSidebarMinimized, const {totalAssignedTickets} = useTicketsListener(userId);
]);
useEffect(() => console.log(totalAssignedTickets), [totalAssignedTickets]);
const logout = async () => { const logout = async () => {
axios.post("/api/logout").finally(() => { axios.post("/api/logout").finally(() => {
@@ -96,20 +95,10 @@ export default function Sidebar({
"relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8", "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", isMinimized ? "w-fit" : "-xl:w-fit w-1/6",
className, className,
)} )}>
>
<div className="-xl:hidden flex-col gap-3 xl:flex"> <div className="-xl:hidden flex-col gap-3 xl:flex">
<Nav <Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
disabled={disableNavigation} {(userType === "student" || userType === "teacher" || userType === "developer") && (
Icon={MdSpaceDashboard}
label="Dashboard"
path={path}
keyPath="/"
isMinimized={isMinimized}
/>
{(userType === "student" ||
userType === "teacher" ||
userType === "developer") && (
<> <>
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
@@ -129,25 +118,9 @@ export default function Sidebar({
/> />
</> </>
)} )}
<Nav <Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
disabled={disableNavigation} <Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
Icon={BsGraphUp} {["admin", "developer", "agent", "corporate"].includes(userType || "") && (
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 <Nav
disabled={disableNavigation} disabled={disableNavigation}
Icon={BsCurrencyDollar} Icon={BsCurrencyDollar}
@@ -157,9 +130,7 @@ export default function Sidebar({
isMinimized={isMinimized} isMinimized={isMinimized}
/> />
)} )}
{["admin", "developer", "corporate", "teacher"].includes( {["admin", "developer", "corporate", "teacher"].includes(userType || "") && (
userType || "",
) && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
Icon={BsShieldFill} Icon={BsShieldFill}
@@ -177,6 +148,7 @@ export default function Sidebar({
path={path} path={path}
keyPath="/tickets" keyPath="/tickets"
isMinimized={isMinimized} isMinimized={isMinimized}
badge={totalAssignedTickets}
/> />
)} )}
{userType === "developer" && ( {userType === "developer" && (
@@ -191,65 +163,16 @@ export default function Sidebar({
)} )}
</div> </div>
<div className="-xl:flex flex-col gap-3 xl:hidden"> <div className="-xl:flex flex-col gap-3 xl:hidden">
<Nav <Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} />
disabled={disableNavigation} <Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} />
Icon={MdSpaceDashboard} <Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
label="Dashboard" <Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
path={path} <Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
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" && ( {userType !== "student" && (
<Nav <Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized={true} />
disabled={disableNavigation}
Icon={BsShieldFill}
label="Settings"
path={path}
keyPath="/settings"
isMinimized={true}
/>
)} )}
{userType === "developer" && ( {userType === "developer" && (
<Nav <Nav disabled={disableNavigation} Icon={BsCloudFill} label="Generation" path={path} keyPath="/generation" isMinimized={true} />
disabled={disableNavigation}
Icon={BsCloudFill}
label="Generation"
path={path}
keyPath="/generation"
isMinimized={true}
/>
)} )}
</div> </div>
@@ -261,16 +184,9 @@ export default function Sidebar({
className={clsx( 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", "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 ? "w-fit" : "w-full min-w-[250px] px-8",
)} )}>
> {isMinimized ? <BsChevronBarRight size={24} /> : <BsChevronBarLeft size={24} />}
{isMinimized ? ( {!isMinimized && <span className="text-lg font-medium">Minimize</span>}
<BsChevronBarRight size={24} />
) : (
<BsChevronBarLeft size={24} />
)}
{!isMinimized && (
<span className="text-lg font-medium">Minimize</span>
)}
</div> </div>
<div <div
role="button" role="button"
@@ -279,17 +195,12 @@ export default function Sidebar({
className={clsx( 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", "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", isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
)} )}>
>
<RiLogoutBoxFill size={24} /> <RiLogoutBoxFill size={24} />
{!isMinimized && ( {!isMinimized && <span className="-xl:hidden text-lg font-medium">Log Out</span>}
<span className="-xl:hidden text-lg font-medium">Log Out</span>
)}
</div> </div>
</div> </div>
{focusMode && ( {focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
<FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
)}
</section> </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,6 +358,7 @@ function UserProfile({user, mutateUser}: Props) {
{preferredGender && ["developer", "student"].includes(user.type) && ( {preferredGender && ["developer", "student"].includes(user.type) && (
<> <>
<Divider /> <Divider />
<DoubleColumnRow>
<div className="flex flex-col gap-3 w-full"> <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> <label className="font-normal text-base text-mti-gray-dim">Speaking Instructor&apos;s Gender</label>
<Select <Select
@@ -362,6 +371,28 @@ function UserProfile({user, mutateUser}: Props) {
]} ]}
/> />
</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;
};