Merge branch 'feature/user-choose-topics' into develop
This commit is contained in:
70
src/components/Medium/TopicModal.tsx
Normal file
70
src/components/Medium/TopicModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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'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'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
55
src/resources/topics.ts
Normal 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;
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user