Compare commits

...

66 Commits

Author SHA1 Message Date
Joao Ramos
2a27fbd02f Merge branch 'develop' into feature-paypal-simple 2024-03-05 14:45:51 +00:00
Joao Ramos
a86ed9f76c Added RAAS api and tracking
Improvements on paypal UI
2024-03-05 14:37:02 +00:00
Tiago Ribeiro
20b52d049d Updated the default to be 100 2024-03-04 00:14:04 +00:00
Tiago Ribeiro
165e33b188 Updated an error with the session 2024-03-03 12:01:05 +00:00
João Ramos
04cbcbc4cb Merged in feature-homepage-contacts-languages (pull request #43)
Feature homepage contacts languages

Approved-by: Tiago Ribeiro
2024-02-29 23:54:04 +00:00
Tiago Ribeiro
2feb9223c1 Merged develop into feature-homepage-contacts-languages 2024-02-29 23:53:42 +00:00
Joao Ramos
02d2d07f6c Paypal tab is now only displayed to admin or devs 2024-02-29 21:33:47 +00:00
Joao Ramos
ecd66d61f2 Updated exam abandon message 2024-02-29 21:33:07 +00:00
Joao Ramos
424b72efaf Uppercased the country code to prevent API errors 2024-02-29 18:35:30 +00:00
Joao Ramos
79e51d6294 Added support for the homepage languages 2024-02-29 18:26:41 +00:00
Tiago Ribeiro
773480875f Extremely wrong approach, but want to test 2024-02-28 22:44:18 +00:00
Joao Ramos
96d1b85f56 Removed HeaderGroup types on CSV export 2024-02-28 22:34:58 +00:00
Joao Ramos
cf20920fd8 Updated possible HeaderGroup types on CSV export 2024-02-28 22:29:07 +00:00
Joao Ramos
4114971244 Prevented date from wrapping 2024-02-28 20:01:43 +00:00
João Ramos
bee20388d9 Merged in feature-paypal-payments (pull request #40)
Added integration for paypal payments
2024-02-27 14:20:35 +00:00
João Ramos
bd97529658 Merged in feature-tickets-with-admin (pull request #42)
Added corporate display to Tickets table

Approved-by: Tiago Ribeiro
2024-02-27 14:17:43 +00:00
Tiago Ribeiro
d3c24d738c Merged develop into feature-tickets-with-admin 2024-02-27 14:17:28 +00:00
João Ramos
eac43a160d Merged in tickets-source (pull request #41)
Added Approach to accept ticket source

Approved-by: Tiago Ribeiro
2024-02-27 14:17:06 +00:00
Joao Ramos
24c3f506c6 Added corporate display to Tickets table 2024-02-26 23:27:10 +00:00
Joao Ramos
3e13ed5830 Added Approach to accept ticket source 2024-02-26 19:09:42 +00:00
Joao Ramos
9b5ff70037 Added search text to Paypal payments
Updated CSV Downlaod
Reordered the UI of the page
2024-02-26 18:46:21 +00:00
Joao Ramos
d7f1a4f6b2 Added integration for paypal payments 2024-02-25 16:59:21 +00:00
Tiago Ribeiro
b663e5c706 Updated the labels for the level 2024-02-24 22:35:13 +00:00
Tiago Ribeiro
efb99b31f2 Merge branch 'develop' 2024-02-23 15:20:10 +00:00
Tiago Ribeiro
03882d2a7e Improved some visual things requested by the client 2024-02-23 15:18:45 +00:00
Tiago Ribeiro
a3087567ea Merge branch 'develop' 2024-02-23 15:07:42 +00:00
João Ramos
e37afd5bbc Merged in feature-homepage-users (pull request #39)
Added API endpoints for agents load for the homepage

Approved-by: Alvaro Doria
2024-02-19 14:35:29 +00:00
João Ramos
cf5e827ca7 Merged in feature-archive-assignments (pull request #38)
Added approach to archive past assignments

Approved-by: Alvaro Doria
2024-02-19 14:35:20 +00:00
João Ramos
bfa9d039e2 Merged in bug-user-level-progress (pull request #37)
Bug user level progress

Approved-by: Alvaro Doria
2024-02-19 14:35:08 +00:00
Joao Ramos
62b915fbc1 Added API endpoints for agents load for the homepage 2024-02-18 18:04:54 +00:00
Joao Ramos
cdfafb3eea Added approach to archive past assignments 2024-02-18 11:46:08 +00:00
Joao Ramos
29cae5c3d2 Stats for Level exam are now being properly calculated 2024-02-18 11:09:25 +00:00
Joao Ramos
04f97b62c3 Filtered out level from students history display 2024-02-17 14:22:46 +00:00
Joao Ramos
52d309e7f4 Fixed NaN display on level progress 2024-02-17 10:43:55 +00:00
Tiago Ribeiro
dbf5b17f64 Merge branch 'develop' 2024-02-15 16:10:06 +00:00
Tiago Ribeiro
703fb0df5f Updated the generic Listening intro 2024-02-15 14:07:30 +00:00
Tiago Ribeiro
be4d2de76f Updated the module title to keep in mind the previous time spent 2024-02-14 11:31:35 +00:00
Tiago Ribeiro
44c61c2e5d Fixed a bug with the module evaluation, no idea why it was happening 2024-02-14 11:19:55 +00:00
João Ramos
764064bc28 Merged in feature-ticket-badge (pull request #36)
Added a badge with the amount of pending tickets assigned to the user

Approved-by: Tiago Ribeiro
2024-02-14 00:06:27 +00:00
Tiago Ribeiro
d87de9fea9 Updated the styling a little bit 2024-02-14 00:05:08 +00:00
Joao Ramos
b63ba3f316 Added a badge with the amount of pending tickets assigned to the user 2024-02-13 17:29:55 +00:00
Tiago Ribeiro
64b1d9266e Updated the Speaking Generation to save the topic as well 2024-02-13 16:26:56 +00:00
Tiago Ribeiro
b7cd1fb141 Merge branch 'feature/user-choose-topics' into develop 2024-02-13 16:07:12 +00:00
Tiago Ribeiro
30cb2f460c Changed the label on the Save button 2024-02-13 16:05:13 +00:00
Tiago Ribeiro
6a38b7a32e Updated the exam selection to get exams related to the user's topic preference 2024-02-13 16:04:46 +00:00
Joao Ramos
2a1b5236ee Revert "Updated the label for the Tickets button"
This reverts commit a99f6fd20e.
2024-02-13 15:43:24 +00:00
Joao Ramos
a99f6fd20e Updated the label for the Tickets button 2024-02-13 15:37:37 +00:00
Tiago Ribeiro
c0c9d22864 Added the ability for a user to select their preferred topics 2024-02-13 11:39:19 +00:00
Tiago Ribeiro
718782cfd5 Merged in develop (pull request #35)
Updated the main branch - 13/02/24
2024-02-13 00:52:45 +00:00
Tiago Ribeiro
f643430068 Merge branch 'develop' into feature/user-choose-topics 2024-02-13 00:45:56 +00:00
João Ramos
2823af7ef8 Merged in privacy-policy (pull request #33)
Added checkbox for accepted terms

Approved-by: Tiago Ribeiro
2024-02-13 00:44:55 +00:00
Tiago Ribeiro
57116f50e8 Hard coded the website link because there are some problems with ENV variables in the frontend 2024-02-13 00:44:16 +00:00
Tiago Ribeiro
e382a09ae8 Added the key "topic" to Writing, Speaking and Interactive Speaking exercises 2024-02-13 00:42:09 +00:00
João Ramos
b4c7c9a911 Merged in contact-us-form (pull request #32)
Contact Us support

Approved-by: Tiago Ribeiro
2024-02-13 00:40:23 +00:00
Tiago Ribeiro
86e920f102 Merged develop into contact-us-form 2024-02-13 00:39:59 +00:00
João Ramos
6f12a4a1db Merged in email-notification-ticket-close (pull request #34)
Added an email notification when the ticket is submitted
2024-02-13 00:39:39 +00:00
Tiago Ribeiro
a27a3c1fb0 Merged develop into contact-us-form 2024-02-13 00:38:56 +00:00
Joao Ramos
63618405bc Added an email notification when the ticket is submitted 2024-02-12 22:37:16 +00:00
João Ramos
7ab67fdf15 Merged develop into privacy-policy 2024-02-12 21:49:11 +00:00
Joao Ramos
17ec004a59 Added checkbox for accepted terms 2024-02-12 21:45:37 +00:00
Tiago Ribeiro
417bd7fecb Added tooltips to the student dashboard 2024-02-12 19:26:55 +00:00
Joao Ramos
e82895351d Published the tickets POST route 2024-02-12 18:18:25 +00:00
Tiago Ribeiro
4802310474 Added the ability to delete already finished assignments 2024-02-12 11:15:04 +00:00
Tiago Ribeiro
dc3373be6a Added the ability to choose a difficulty when generating an exam 2024-02-10 13:26:08 +00:00
Tiago Ribeiro
2e894622d0 Updated users to get exams related to their level 2024-02-10 09:10:52 +00:00
Tiago Ribeiro
1895b9e183 Disabled the navigation on the mobile menu 2024-02-09 16:48:25 +00:00
58 changed files with 3414 additions and 2058 deletions

View File

@@ -1,4 +1,5 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const websiteUrl = process.env.NODE_ENV === 'production' ? "https://encoach.com" : "http://localhost:3000";
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
output: "standalone", output: "standalone",
@@ -8,7 +9,7 @@ const nextConfig = {
source: "/api/packages", source: "/api/packages",
headers: [ headers: [
{key: "Access-Control-Allow-Credentials", value: "false"}, {key: "Access-Control-Allow-Credentials", value: "false"},
{key: "Access-Control-Allow-Origin", value: "https://encoach.com"}, {key: "Access-Control-Allow-Origin", value: websiteUrl},
{ {
key: "Access-Control-Allow-Methods", key: "Access-Control-Allow-Methods",
value: "GET", value: "GET",
@@ -19,6 +20,36 @@ const nextConfig = {
}, },
], ],
}, },
{
source: "/api/tickets",
headers: [
{key: "Access-Control-Allow-Credentials", value: "false"},
{key: "Access-Control-Allow-Origin", value: websiteUrl},
{
key: "Access-Control-Allow-Methods",
value: "POST,OPTIONS",
},
{
key: "Access-Control-Allow-Headers",
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
},
],
},
{
source: "/api/users/agents",
headers: [
{key: "Access-Control-Allow-Credentials", value: "false"},
{key: "Access-Control-Allow-Origin", value: websiteUrl},
{
key: "Access-Control-Allow-Methods",
value: "POST,OPTIONS",
},
{
key: "Access-Control-Allow-Headers",
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
},
],
},
]; ];
}, },
}; };

View File

@@ -76,7 +76,7 @@ export default function InteractiveSpeaking({
onBack({ onBack({
exercise: id, exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer], solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 1, total: 1, missing: 0}, score: {correct: 100, total: 100, missing: 0},
type, type,
}); });
}; };
@@ -96,7 +96,7 @@ export default function InteractiveSpeaking({
onNext({ onNext({
exercise: id, exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer], solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 1, total: 1, missing: 0}, score: {correct: 100, total: 100, missing: 0},
type, type,
}); });
}; };
@@ -131,7 +131,7 @@ export default function InteractiveSpeaking({
onNext({ onNext({
exercise: id, exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer], solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 1, total: 1, missing: 0}, score: {correct: 100, total: 100, missing: 0},
type, type,
}); });
} }
@@ -176,7 +176,7 @@ export default function InteractiveSpeaking({
{ {
exercise: id, exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer], solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 1, total: 1, missing: 0}, score: {correct: 100, total: 100, missing: 0},
module: "speaking", module: "speaking",
exam: examID, exam: examID,
type, type,

View File

@@ -81,7 +81,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
onNext({ onNext({
exercise: id, exercise: id,
solutions: storagePath ? [{id, solution: storagePath}] : [], solutions: storagePath ? [{id, solution: storagePath}] : [],
score: {correct: 1, total: 1, missing: 0}, score: {correct: 100, total: 100, missing: 0},
type, type,
}); });
}; };
@@ -94,7 +94,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
onBack({ onBack({
exercise: id, exercise: id,
solutions: storagePath ? [{id, solution: storagePath}] : [], solutions: storagePath ? [{id, solution: storagePath}] : [],
score: {correct: 1, total: 1, missing: 0}, score: {correct: 100, total: 100, missing: 0},
type, type,
}); });
}; };

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: 100, total: 100, 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: 100, total: 100, missing: 0}, type, module: "writing"});
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]); }, [hasExamEnded]);
@@ -147,7 +148,9 @@ export default function Writing({
<Button <Button
color="purple" color="purple"
variant="outline" variant="outline"
onClick={() => onBack({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type})} onClick={() =>
onBack({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type})
}
className="max-w-[200px] self-end w-full"> className="max-w-[200px] self-end w-full">
Back Back
</Button> </Button>
@@ -158,8 +161,9 @@ export default function Writing({
onNext({ onNext({
exercise: id, exercise: id,
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: 100, total: 100, 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,201 +1,160 @@
import { User } from "@/interfaces/user"; import {User} from "@/interfaces/user";
import { Dialog, Transition } from "@headlessui/react"; import {Dialog, Transition} from "@headlessui/react";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import {useRouter} from "next/router";
import { Fragment } from "react"; import {Fragment} from "react";
import { BsXLg } from "react-icons/bs"; import {BsXLg} from "react-icons/bs";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
path: string; path: string;
user: User; user: User;
disableNavigation?: boolean;
} }
export default function MobileMenu({ isOpen, onClose, path, user }: Props) { export default function MobileMenu({isOpen, onClose, path, user, disableNavigation}: Props) {
const router = useRouter(); const router = useRouter();
const logout = async () => { const logout = async () => {
axios.post("/api/logout").finally(() => { axios.post("/api/logout").finally(() => {
setTimeout(() => router.reload(), 500); setTimeout(() => router.reload(), 500);
}); });
}; };
return ( return (
<Transition appear show={isOpen} as={Fragment}> <Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={onClose}> <Dialog as="div" className="relative z-10" onClose={onClose}>
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0">
> <div className="fixed inset-0 bg-black bg-opacity-25" />
<div className="fixed inset-0 bg-black bg-opacity-25" /> </Transition.Child>
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto"> <div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center text-center"> <div className="flex min-h-full items-center justify-center text-center">
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0 scale-95" enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100" enterTo="opacity-100 scale-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95">
> <Dialog.Panel className="flex h-screen w-full transform flex-col gap-8 overflow-hidden bg-white text-left align-middle text-black shadow-xl transition-all">
<Dialog.Panel className="flex h-screen w-full transform flex-col gap-8 overflow-hidden bg-white text-left align-middle text-black shadow-xl transition-all"> <Dialog.Title as="header" className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden">
<Dialog.Title <Link href={disableNavigation ? "" : "/"}>
as="header" <Image src="/logo_title.png" alt="EnCoach logo" width={69} height={69} />
className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden" </Link>
> <div className="cursor-pointer" onClick={onClose} tabIndex={0}>
<Link href="/"> <BsXLg className="text-mti-purple-light text-2xl" onClick={onClose} />
<Image </div>
src="/logo_title.png" </Dialog.Title>
alt="EnCoach logo" <div className="flex h-full flex-col gap-6 px-8 text-lg">
width={69} <Link
height={69} href={disableNavigation ? "" : "/"}
/> className={clsx(
</Link> "w-fit transition duration-300 ease-in-out",
<div path === "/" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
className="cursor-pointer" )}>
onClick={onClose} Dashboard
tabIndex={0} </Link>
> {(user.type === "student" || user.type === "teacher" || user.type === "developer") && (
<BsXLg <>
className="text-mti-purple-light text-2xl" <Link
onClick={onClose} href={disableNavigation ? "" : "/exam"}
/> className={clsx(
</div> "w-fit transition duration-300 ease-in-out",
</Dialog.Title> path === "/exam" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
<div className="flex h-full flex-col gap-6 px-8 text-lg"> )}>
<Link Exams
href="/" </Link>
className={clsx( <Link
"w-fit transition duration-300 ease-in-out", href={disableNavigation ? "" : "/exercises"}
path === "/" && className={clsx(
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", "w-fit transition duration-300 ease-in-out",
)} path === "/exercises" &&
> "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
Dashboard )}>
</Link> Exercises
{(user.type === "student" || </Link>
user.type === "teacher" || </>
user.type === "developer") && ( )}
<> <Link
<Link href={disableNavigation ? "" : "/stats"}
href="/exam" className={clsx(
className={clsx( "w-fit transition duration-300 ease-in-out",
"w-fit transition duration-300 ease-in-out", path === "/stats" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
path === "/exam" && )}>
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", Stats
)} </Link>
> <Link
Exams href={disableNavigation ? "" : "/record"}
</Link> className={clsx(
<Link "w-fit transition duration-300 ease-in-out",
href="/exercises" path === "/record" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
className={clsx( )}>
"w-fit transition duration-300 ease-in-out", Record
path === "/exercises" && </Link>
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", {["admin", "developer", "agent", "corporate"].includes(user.type) && (
)} <Link
> href={disableNavigation ? "" : "/payment-record"}
Exercises className={clsx(
</Link> "w-fit transition duration-300 ease-in-out",
</> path === "/payment-record" &&
)} "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
<Link )}>
href="/stats" Payment Record
className={clsx( </Link>
"w-fit transition duration-300 ease-in-out", )}
path === "/stats" && {["admin", "developer", "corporate", "teacher"].includes(user.type) && (
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", <Link
)} href={disableNavigation ? "" : "/settings"}
> className={clsx(
Stats "w-fit transition duration-300 ease-in-out",
</Link> path === "/settings" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
<Link )}>
href="/record" Settings
className={clsx( </Link>
"w-fit transition duration-300 ease-in-out", )}
path === "/record" && {["admin", "developer", "agent"].includes(user.type) && (
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", <Link
)} href={disableNavigation ? "" : "/tickets"}
> className={clsx(
Record "w-fit transition duration-300 ease-in-out",
</Link> path === "/tickets" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
{["admin", "developer", "agent", "corporate"].includes( )}>
user.type, Tickets
) && ( </Link>
<Link )}
href="/payment-record" <Link
className={clsx( href={disableNavigation ? "" : "/profile"}
"w-fit transition duration-300 ease-in-out", className={clsx(
path === "/payment-record" && "w-fit transition duration-300 ease-in-out",
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", path === "/profile" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
)} )}>
> Profile
Payment Record </Link>
</Link>
)}
{["admin", "developer", "corporate", "teacher"].includes(
user.type,
) && (
<Link
href="/settings"
className={clsx(
"w-fit transition duration-300 ease-in-out",
path === "/settings" &&
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
)}
>
Settings
</Link>
)}
{["admin", "developer", "agent"].includes(user.type) && (
<Link
href="/tickets"
className={clsx(
"w-fit transition duration-300 ease-in-out",
path === "/tickets" &&
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
)}
>
Tickets
</Link>
)}
<Link
href="/profile"
className={clsx(
"w-fit transition duration-300 ease-in-out",
path === "/profile" &&
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
)}
>
Profile
</Link>
<span <span
className={clsx( className={clsx("w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out")}
"w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out", onClick={logout}>
)} Logout
onClick={logout} </span>
> </div>
Logout </Dialog.Panel>
</span> </Transition.Child>
</div> </div>
</Dialog.Panel> </div>
</Transition.Child> </Dialog>
</div> </Transition>
</div> );
</Dialog>
</Transition>
);
} }

View File

@@ -62,7 +62,9 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
<TicketSubmission user={user} page={window.location.href} onClose={() => setIsTicketOpen(false)} /> <TicketSubmission user={user} page={window.location.href} onClose={() => setIsTicketOpen(false)} />
</Modal> </Modal>
{user && <MobileMenu path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />} {user && (
<MobileMenu disableNavigation={disableNavigation} path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />
)}
<header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4"> <header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4">
<Link href={disableNavigation ? "" : "/"} className=" flex items-center gap-8 md:px-8"> <Link href={disableNavigation ? "" : "/"} className=" flex items-center gap-8 md:px-8">
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" /> <img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" />

View File

@@ -1,77 +1,124 @@
import {DurationUnit} from "@/interfaces/paypal"; import { DurationUnit } from "@/interfaces/paypal";
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OnCancelledActions, OrderResponseBody} from "@paypal/paypal-js"; import {
import {PayPalButtons, PayPalScriptProvider, usePayPalScriptReducer} from "@paypal/react-paypal-js"; CreateOrderActions,
CreateOrderData,
OnApproveActions,
OnApproveData,
OnCancelledActions,
OrderResponseBody,
} from "@paypal/paypal-js";
import {
PayPalButtons,
PayPalScriptProvider,
usePayPalScriptReducer,
} from "@paypal/react-paypal-js";
import axios from "axios"; import axios from "axios";
import {useEffect, useState} from "react"; import { useState, useEffect } from "react";
import {toast} from "react-toastify"; import { toast } from "react-toastify";
interface Props { interface Props {
clientID: string; clientID: string;
currency: string; currency: string;
price: number; price: number;
duration: number; duration: number;
duration_unit: DurationUnit; duration_unit: DurationUnit;
loadScript?: boolean; loadScript?: boolean;
setIsLoading: (isLoading: boolean) => void; setIsLoading: (isLoading: boolean) => void;
onSuccess: (duration: number, duration_unit: DurationUnit) => void; onSuccess: (duration: number, duration_unit: DurationUnit) => void;
trackingId?: string;
} }
export default function PayPalPayment({clientID, price, currency, duration, duration_unit, loadScript, setIsLoading, onSuccess}: Props) { export default function PayPalPayment({
const createOrder = async (data: CreateOrderData, actions: CreateOrderActions): Promise<string> => { clientID,
setIsLoading(true); price,
currency,
duration,
duration_unit,
loadScript,
setIsLoading,
onSuccess,
trackingId,
}: Props) {
const createOrder = async (
data: CreateOrderData,
actions: CreateOrderActions
): Promise<string> => {
if (!trackingId) {
throw new Error("trackingId is not set");
}
setIsLoading(true);
return axios return axios
.post<OrderResponseBody>("/api/paypal", {currencyCode: currency, price}) .post<OrderResponseBody>("/api/paypal", {
.then((response) => response.data) currencyCode: currency,
.then((data) => data.id); price,
}; trackingId,
})
.then((response) => response.data)
.then((data) => data.id);
};
const onApprove = async (data: OnApproveData, actions: OnApproveActions) => { const onApprove = async (data: OnApproveData, actions: OnApproveActions) => {
const request = await axios.post<{ok: boolean; reason?: string}>("/api/paypal/approve", {id: data.orderID, duration, duration_unit}); if (!trackingId) {
throw new Error("trackingId is not set");
}
if (request.status !== 200) { const request = await axios.post<{ ok: boolean; reason?: string }>(
toast.error("Something went wrong, please try again later"); "/api/paypal/approve",
return; { id: data.orderID, duration, duration_unit, trackingId }
} );
toast.success("Your account has been credited more time!"); if (request.status !== 200) {
return onSuccess(duration, duration_unit); toast.error("Something went wrong, please try again later");
}; return;
}
const onError = async (data: Record<string, unknown>) => { toast.success("Your account has been credited more time!");
setIsLoading(false); return onSuccess(duration, duration_unit);
}; };
const onCancel = async (data: Record<string, unknown>, actions: OnCancelledActions) => { const onError = async (data: Record<string, unknown>) => {
setIsLoading(false); setIsLoading(false);
}; };
return loadScript ? ( const onCancel = async (
<PayPalScriptProvider data: Record<string, unknown>,
options={{ actions: OnCancelledActions
clientId: clientID, ) => {
currency, setIsLoading(false);
intent: "capture", };
commit: true,
vault: true, if (trackingId) {
}}> return loadScript ? (
<PayPalButtons <PayPalScriptProvider
className="w-full" options={{
style={{layout: "vertical"}} clientId: clientID,
createOrder={createOrder} currency,
onApprove={onApprove} intent: "capture",
onCancel={onCancel} commit: true,
onError={onError} vault: true,
/> }}
</PayPalScriptProvider> >
) : ( <PayPalButtons
<PayPalButtons className="w-full"
className="w-full" style={{ layout: "vertical" }}
style={{layout: "vertical"}} createOrder={createOrder}
createOrder={createOrder} onApprove={onApprove}
onApprove={onApprove} onCancel={onCancel}
onCancel={onCancel} onError={onError}
onError={onError} />
/> </PayPalScriptProvider>
); ) : (
<PayPalButtons
className="w-full"
style={{ layout: "vertical" }}
createOrder={createOrder}
onApprove={onApprove}
onCancel={onCancel}
onError={onError}
/>
);
}
return null;
} }

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

@@ -224,9 +224,9 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
defaultValue={monthlyDuration} defaultValue={monthlyDuration}
disabled={disabled} disabled={disabled}
/> />
<div className="flex flex-col gap-3 w-full lg:col-span-2"> <div className="flex flex-col gap-3 w-full lg:col-span-3">
<label className="font-normal text-base text-mti-gray-dim">Pricing</label> <label className="font-normal text-base text-mti-gray-dim">Pricing</label>
<div className="w-full grid grid-cols-5 gap-2"> <div className="w-full grid grid-cols-6 gap-2">
<Input <Input
name="paymentValue" name="paymentValue"
onChange={(e) => setPaymentValue(e ? parseInt(e) : undefined)} onChange={(e) => setPaymentValue(e ? parseInt(e) : undefined)}
@@ -237,7 +237,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
/> />
<Select <Select
className={clsx( className={clsx(
"px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none", "px-4 py-4 col-span-3 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed", disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
)} )}
options={CURRENCIES_OPTIONS} options={CURRENCIES_OPTIONS}

View File

@@ -13,11 +13,14 @@ import {
BsPen, BsPen,
} from "react-icons/bs"; } from "react-icons/bs";
import { usePDFDownload } from "@/hooks/usePDFDownload"; import { usePDFDownload } from "@/hooks/usePDFDownload";
import { useAssignmentArchive } from "@/hooks/useAssignmentArchive";
import { uniqBy } from "lodash"; import { uniqBy } from "lodash";
interface Props { interface Props {
onClick?: () => void; onClick?: () => void;
allowDownload?: boolean; allowDownload?: boolean;
reload?: Function;
allowArchive?: boolean;
} }
export default function AssignmentCard({ export default function AssignmentCard({
@@ -29,11 +32,14 @@ export default function AssignmentCard({
assignees, assignees,
results, results,
exams, exams,
archived,
onClick, onClick,
allowDownload, allowDownload,
reload,
allowArchive,
}: Assignment & Props) { }: Assignment & Props) {
const { users } = useUsers();
const renderPdfIcon = usePDFDownload("assignments"); const renderPdfIcon = usePDFDownload("assignments");
const renderArchiveIcon = useAssignmentArchive(id, reload);
const calculateAverageModuleScore = (module: Module) => { const calculateAverageModuleScore = (module: Module) => {
const resultModuleBandScores = results.map((r) => { const resultModuleBandScores = results.map((r) => {
@@ -41,11 +47,11 @@ export default function AssignmentCard({
const correct = moduleStats.reduce( const correct = moduleStats.reduce(
(acc, curr) => acc + curr.score.correct, (acc, curr) => acc + curr.score.correct,
0, 0
); );
const total = moduleStats.reduce( const total = moduleStats.reduce(
(acc, curr) => acc + curr.score.total, (acc, curr) => acc + curr.score.total,
0, 0
); );
return calculateBandScore(correct, total, module, r.type); return calculateBandScore(correct, total, module, r.type);
}); });
@@ -64,8 +70,13 @@ export default function AssignmentCard({
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex flex-row justify-between"> <div className="flex flex-row justify-between">
<h3 className="text-xl font-semibold">{name}</h3> <h3 className="text-xl font-semibold">{name}</h3>
{allowDownload && <div className="flex gap-2">
renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")} {allowDownload &&
renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
{allowArchive &&
!archived &&
renderArchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
</div>
</div> </div>
<ProgressBar <ProgressBar
color={results.length / assignees.length < 0.5 ? "red" : "purple"} color={results.length / assignees.length < 0.5 ? "red" : "purple"}
@@ -94,7 +105,7 @@ export default function AssignmentCard({
module === "listening" && "bg-ielts-listening", module === "listening" && "bg-ielts-listening",
module === "writing" && "bg-ielts-writing", module === "writing" && "bg-ielts-writing",
module === "speaking" && "bg-ielts-speaking", module === "speaking" && "bg-ielts-speaking",
module === "level" && "bg-ielts-level", module === "level" && "bg-ielts-level"
)} )}
> >
{module === "reading" && <BsBook className="h-4 w-4" />} {module === "reading" && <BsBook className="h-4 w-4" />}

View File

@@ -1,354 +1,307 @@
import Button from "@/components/Low/Button";
import ProgressBar from "@/components/Low/ProgressBar"; import ProgressBar from "@/components/Low/ProgressBar";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import { Module } from "@/interfaces"; import {Module} from "@/interfaces";
import { Assignment } from "@/interfaces/results"; import {Assignment} from "@/interfaces/results";
import { Stat, User } from "@/interfaces/user"; import {Stat, User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import { getExamById } from "@/utils/exams"; import {getExamById} from "@/utils/exams";
import { sortByModule } from "@/utils/moduleUtils"; import {sortByModule} from "@/utils/moduleUtils";
import { calculateBandScore } from "@/utils/score"; import {calculateBandScore} from "@/utils/score";
import { convertToUserSolutions } from "@/utils/stats"; import {convertToUserSolutions} from "@/utils/stats";
import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import { capitalize, uniqBy } from "lodash"; import {capitalize, uniqBy} from "lodash";
import moment from "moment"; import moment from "moment";
import { useRouter } from "next/router"; import {useRouter} from "next/router";
import { import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
BsBook, import {toast} from "react-toastify";
BsClipboard,
BsHeadphones,
BsMegaphone,
BsPen,
} from "react-icons/bs";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
assignment?: Assignment; assignment?: Assignment;
onClose: () => void; onClose: () => void;
} }
export default function AssignmentView({ isOpen, assignment, onClose }: Props) { export default function AssignmentView({isOpen, assignment, onClose}: Props) {
const { users } = useUsers(); const {users} = useUsers();
const router = useRouter(); const router = useRouter();
const setExams = useExamStore((state) => state.setExams); const setExams = useExamStore((state) => state.setExams);
const setShowSolutions = useExamStore((state) => state.setShowSolutions); const setShowSolutions = useExamStore((state) => state.setShowSolutions);
const setUserSolutions = useExamStore((state) => state.setUserSolutions); const setUserSolutions = useExamStore((state) => state.setUserSolutions);
const setSelectedModules = useExamStore((state) => state.setSelectedModules); const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const formatTimestamp = (timestamp: string) => { const deleteAssignment = async () => {
const date = moment(parseInt(timestamp)); if (!confirm("Are you sure you want to delete this assignment?")) return;
const formatter = "YYYY/MM/DD - HH:mm";
return date.format(formatter); axios
}; .delete(`/api/assignments/${assignment?.id}`)
.then(() => toast.success(`Successfully deleted the assignment "${assignment?.name}".`))
.catch(() => toast.error("Something went wrong, please try again later."))
.finally(onClose);
};
const calculateAverageModuleScore = (module: Module) => { const formatTimestamp = (timestamp: string) => {
if (!assignment) return -1; const date = moment(parseInt(timestamp));
const formatter = "YYYY/MM/DD - HH:mm";
const resultModuleBandScores = assignment.results.map((r) => { return date.format(formatter);
const moduleStats = r.stats.filter((s) => s.module === module); };
const correct = moduleStats.reduce( const calculateAverageModuleScore = (module: Module) => {
(acc, curr) => acc + curr.score.correct, if (!assignment) return -1;
0,
);
const total = moduleStats.reduce(
(acc, curr) => acc + curr.score.total,
0,
);
return calculateBandScore(correct, total, module, r.type);
});
return resultModuleBandScores.length === 0 const resultModuleBandScores = assignment.results.map((r) => {
? -1 const moduleStats = r.stats.filter((s) => s.module === module);
: resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
assignment.results.length;
};
const aggregateScoresByModule = ( const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
stats: Stat[], const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
): { module: Module; total: number; missing: number; correct: number }[] => { return calculateBandScore(correct, total, module, r.type);
const scores: { });
[key in Module]: { total: number; missing: number; correct: number };
} = {
reading: {
total: 0,
correct: 0,
missing: 0,
},
listening: {
total: 0,
correct: 0,
missing: 0,
},
writing: {
total: 0,
correct: 0,
missing: 0,
},
speaking: {
total: 0,
correct: 0,
missing: 0,
},
level: {
total: 0,
correct: 0,
missing: 0,
},
};
stats.forEach((x) => { return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length;
scores[x.module!] = { };
total: scores[x.module!].total + x.score.total,
correct: scores[x.module!].correct + x.score.correct,
missing: scores[x.module!].missing + x.score.missing,
};
});
return Object.keys(scores) const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => {
.filter((x) => scores[x as Module].total > 0) const scores: {
.map((x) => ({ module: x as Module, ...scores[x as Module] })); [key in Module]: {total: number; missing: number; correct: number};
}; } = {
reading: {
total: 0,
correct: 0,
missing: 0,
},
listening: {
total: 0,
correct: 0,
missing: 0,
},
writing: {
total: 0,
correct: 0,
missing: 0,
},
speaking: {
total: 0,
correct: 0,
missing: 0,
},
level: {
total: 0,
correct: 0,
missing: 0,
},
};
const customContent = ( stats.forEach((x) => {
stats: Stat[], scores[x.module!] = {
user: string, total: scores[x.module!].total + x.score.total,
focus: "academic" | "general", correct: scores[x.module!].correct + x.score.correct,
) => { missing: scores[x.module!].missing + x.score.missing,
const correct = stats.reduce( };
(accumulator, current) => accumulator + current.score.correct, });
0,
);
const total = stats.reduce(
(accumulator, current) => accumulator + current.score.total,
0,
);
const aggregatedScores = aggregateScoresByModule(stats).filter(
(x) => x.total > 0,
);
const aggregatedLevels = aggregatedScores.map((x) => ({ return Object.keys(scores)
module: x.module, .filter((x) => scores[x as Module].total > 0)
level: calculateBandScore(x.correct, x.total, x.module, focus), .map((x) => ({module: x as Module, ...scores[x as Module]}));
})); };
const timeSpent = stats[0].timeSpent; const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => {
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
const selectExam = () => { const aggregatedLevels = aggregatedScores.map((x) => ({
const examPromises = uniqBy(stats, "exam").map((stat) => module: x.module,
getExamById(stat.module, stat.exam), level: calculateBandScore(x.correct, x.total, x.module, focus),
); }));
Promise.all(examPromises).then((exams) => { const timeSpent = stats[0].timeSpent;
if (exams.every((x) => !!x)) {
setUserSolutions(convertToUserSolutions(stats));
setShowSolutions(true);
setExams(exams.map((x) => x!).sort(sortByModule));
setSelectedModules(
exams
.map((x) => x!)
.sort(sortByModule)
.map((x) => x!.module),
);
router.push("/exercises");
}
});
};
const content = ( const selectExam = () => {
<> const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam));
<div className="-md:items-center flex w-full justify-between 2xl:items-center">
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
<span className="font-medium">
{formatTimestamp(stats[0].date.toString())}
</span>
{timeSpent && (
<>
<span className="md:hidden 2xl:flex"> </span>
<span className="text-sm">
{Math.floor(timeSpent / 60)} minutes
</span>
</>
)}
</div>
<span
className={clsx(
correct / total >= 0.7 && "text-mti-purple",
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
correct / total < 0.3 && "text-mti-rose",
)}
>
Level{" "}
{(
aggregatedLevels.reduce(
(accumulator, current) => accumulator + current.level,
0,
) / aggregatedLevels.length
).toFixed(1)}
</span>
</div>
<div className="flex w-full flex-col gap-1"> Promise.all(examPromises).then((exams) => {
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2"> if (exams.every((x) => !!x)) {
{aggregatedLevels.map(({ module, level }) => ( setUserSolutions(convertToUserSolutions(stats));
<div setShowSolutions(true);
key={module} setExams(exams.map((x) => x!).sort(sortByModule));
className={clsx( setSelectedModules(
"-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4", exams
module === "reading" && "bg-ielts-reading", .map((x) => x!)
module === "listening" && "bg-ielts-listening", .sort(sortByModule)
module === "writing" && "bg-ielts-writing", .map((x) => x!.module),
module === "speaking" && "bg-ielts-speaking", );
module === "level" && "bg-ielts-level", router.push("/exercises");
)} }
> });
{module === "reading" && <BsBook className="h-4 w-4" />} };
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
{module === "writing" && <BsPen className="h-4 w-4" />}
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
{module === "level" && <BsClipboard className="h-4 w-4" />}
<span className="text-sm">{level.toFixed(1)}</span>
</div>
))}
</div>
</div>
</>
);
return ( const content = (
<div className="flex flex-col gap-2"> <>
<span> <div className="-md:items-center flex w-full justify-between 2xl:items-center">
{(() => { <div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
const student = users.find((u) => u.id === user); <span className="font-medium">{formatTimestamp(stats[0].date.toString())}</span>
return `${student?.name} (${student?.email})`; {timeSpent && (
})()} <>
</span> <span className="md:hidden 2xl:flex"> </span>
<div <span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span>
key={user} </>
className={clsx( )}
"border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out", </div>
correct / total >= 0.7 && "hover:border-mti-purple", <span
correct / total >= 0.3 && className={clsx(
correct / total < 0.7 && correct / total >= 0.7 && "text-mti-purple",
"hover:border-mti-red", correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
correct / total < 0.3 && "hover:border-mti-rose", correct / total < 0.3 && "text-mti-rose",
)} )}>
onClick={selectExam} Level{" "}
role="button" {(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
> </span>
{content} </div>
</div>
<div
key={user}
className={clsx(
"border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden",
correct / total >= 0.7 && "hover:border-mti-purple",
correct / total >= 0.3 &&
correct / total < 0.7 &&
"hover:border-mti-red",
correct / total < 0.3 && "hover:border-mti-rose",
)}
data-tip="Your screen size is too small to view previous exams."
role="button"
>
{content}
</div>
</div>
);
};
return ( <div className="flex w-full flex-col gap-1">
<Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}> <div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
<div className="mt-4 flex w-full flex-col gap-4"> {aggregatedLevels.map(({module, level}) => (
<ProgressBar <div
color="purple" key={module}
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`} className={clsx(
className="h-6" "-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
textClassName={ module === "reading" && "bg-ielts-reading",
(assignment?.results.length || 0) / module === "listening" && "bg-ielts-listening",
(assignment?.assignees.length || 1) < module === "writing" && "bg-ielts-writing",
0.5 module === "speaking" && "bg-ielts-speaking",
? "!text-mti-gray-dim font-light" module === "level" && "bg-ielts-level",
: "text-white" )}>
} {module === "reading" && <BsBook className="h-4 w-4" />}
percentage={ {module === "listening" && <BsHeadphones className="h-4 w-4" />}
((assignment?.results.length || 0) / {module === "writing" && <BsPen className="h-4 w-4" />}
(assignment?.assignees.length || 1)) * {module === "speaking" && <BsMegaphone className="h-4 w-4" />}
100 {module === "level" && <BsClipboard className="h-4 w-4" />}
} <span className="text-sm">{level.toFixed(1)}</span>
/> </div>
<div className="flex items-start gap-8"> ))}
<div className="flex flex-col gap-2"> </div>
<span> </div>
Start Date:{" "} </>
{moment(assignment?.startDate).format("DD/MM/YY, HH:mm")} );
</span>
<span> return (
End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")} <div className="flex flex-col gap-2">
</span> <span>
</div> {(() => {
<span> const student = users.find((u) => u.id === user);
Assignees:{" "} return `${student?.name} (${student?.email})`;
{users })()}
.filter((u) => assignment?.assignees.includes(u.id)) </span>
.map((u) => `${u.name} (${u.email})`) <div
.join(", ")} key={user}
</span> className={clsx(
</div> "border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
<div className="flex flex-col gap-2"> correct / total >= 0.7 && "hover:border-mti-purple",
<span className="text-xl font-bold">Average Scores</span> correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
<div className="-md:mt-2 flex w-full items-center gap-4"> correct / total < 0.3 && "hover:border-mti-rose",
{assignment && )}
uniqBy(assignment.exams, (x) => x.module).map(({ module }) => ( onClick={selectExam}
<div role="button">
data-tip={capitalize(module)} {content}
key={module} </div>
className={clsx( <div
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4", key={user}
module === "reading" && "bg-ielts-reading", className={clsx(
module === "listening" && "bg-ielts-listening", "border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden",
module === "writing" && "bg-ielts-writing", correct / total >= 0.7 && "hover:border-mti-purple",
module === "speaking" && "bg-ielts-speaking", correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
module === "level" && "bg-ielts-level", correct / total < 0.3 && "hover:border-mti-rose",
)} )}
> data-tip="Your screen size is too small to view previous exams."
{module === "reading" && <BsBook className="h-4 w-4" />} role="button">
{module === "listening" && ( {content}
<BsHeadphones className="h-4 w-4" /> </div>
)} </div>
{module === "writing" && <BsPen className="h-4 w-4" />} );
{module === "speaking" && <BsMegaphone className="h-4 w-4" />} };
{module === "level" && <BsClipboard className="h-4 w-4" />}
{calculateAverageModuleScore(module) > -1 && ( return (
<span className="text-sm"> <Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}>
{calculateAverageModuleScore(module).toFixed(1)} <div className="mt-4 flex w-full flex-col gap-4">
</span> <ProgressBar
)} color="purple"
</div> label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
))} className="h-6"
</div> textClassName={
</div> (assignment?.results.length || 0) / (assignment?.assignees.length || 1) < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"
<div className="flex flex-col gap-2"> }
<span className="text-xl font-bold"> percentage={((assignment?.results.length || 0) / (assignment?.assignees.length || 1)) * 100}
Results ({assignment?.results.length}/{assignment?.assignees.length} />
) <div className="flex items-start gap-8">
</span> <div className="flex flex-col gap-2">
<div> <span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span>
{assignment && assignment?.results.length > 0 && ( <span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span>
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6"> </div>
{assignment.results.map((r) => <span>
customContent(r.stats, r.user, r.type), Assignees:{" "}
)} {users
</div> .filter((u) => assignment?.assignees.includes(u.id))
)} .map((u) => `${u.name} (${u.email})`)
{assignment && assignment?.results.length === 0 && ( .join(", ")}
<span className="ml-1 font-semibold">No results yet...</span> </span>
)} </div>
</div> <div className="flex flex-col gap-2">
</div> <span className="text-xl font-bold">Average Scores</span>
</div> <div className="-md:mt-2 flex w-full items-center gap-4">
</Modal> {assignment &&
); uniqBy(assignment.exams, (x) => x.module).map(({module}) => (
<div
data-tip={capitalize(module)}
key={module}
className={clsx(
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
module === "reading" && "bg-ielts-reading",
module === "listening" && "bg-ielts-listening",
module === "writing" && "bg-ielts-writing",
module === "speaking" && "bg-ielts-speaking",
module === "level" && "bg-ielts-level",
)}>
{module === "reading" && <BsBook className="h-4 w-4" />}
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
{module === "writing" && <BsPen className="h-4 w-4" />}
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
{module === "level" && <BsClipboard className="h-4 w-4" />}
{calculateAverageModuleScore(module) > -1 && (
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
)}
</div>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-xl font-bold">
Results ({assignment?.results.length}/{assignment?.assignees.length})
</span>
<div>
{assignment && assignment?.results.length > 0 && (
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
{assignment.results.map((r) => customContent(r.stats, r.user, r.type))}
</div>
)}
{assignment && assignment?.results.length === 0 && <span className="ml-1 font-semibold">No results yet...</span>}
</div>
</div>
<div className="flex gap-4 w-full items-center justify-end">
{assignment && (assignment.results.length === assignment.assignees.length || moment().isAfter(moment(assignment.endDate))) && (
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={deleteAssignment}>
Delete
</Button>
)}
<Button onClick={onClose} className="w-full max-w-[200px]">
Close
</Button>
</div>
</div>
</Modal>
);
} }

View File

@@ -14,6 +14,7 @@ import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams"; import {getExamById} from "@/utils/exams";
import {getUserCorporate} from "@/utils/groups"; import {getUserCorporate} from "@/utils/groups";
import {MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils"; import {MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
import {getLevelLabel, getLevelScore} from "@/utils/score";
import {averageScore, groupBySession} from "@/utils/stats"; import {averageScore, groupBySession} from "@/utils/stats";
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js"; import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js";
import {PayPalButtons} from "@paypal/react-paypal-js"; import {PayPalButtons} from "@paypal/react-paypal-js";
@@ -86,16 +87,19 @@ export default function StudentDashboard({user}: Props) {
icon: <BsFileEarmarkText className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />, icon: <BsFileEarmarkText className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
value: Object.keys(groupBySession(stats)).length, value: Object.keys(groupBySession(stats)).length,
label: "Exams", label: "Exams",
tooltip: "Number of all conducted completed exams",
}, },
{ {
icon: <BsPencil className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />, icon: <BsPencil className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
value: stats.length, value: stats.length,
label: "Exercises", label: "Exercises",
tooltip: "Number of all conducted exercises including Level Test",
}, },
{ {
icon: <BsStar className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />, icon: <BsStar className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
value: `${stats.length > 0 ? averageScore(stats) : 0}%`, value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
label: "Average Score", label: "Average Score",
tooltip: "Average success rate for questions responded",
}, },
]} ]}
/> />
@@ -224,35 +228,40 @@ export default function StudentDashboard({user}: Props) {
<section className="flex flex-col gap-3"> <section className="flex flex-col gap-3">
<span className="text-lg font-bold">Score History</span> <span className="text-lg font-bold">Score History</span>
<div className="-md:grid-rows-4 grid gap-6 md:grid-cols-2"> <div className="-md:grid-rows-4 grid gap-6 md:grid-cols-2">
{MODULE_ARRAY.map((module) => ( {MODULE_ARRAY.map((module) => {
<div className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4" key={module}> const desiredLevel = user.desiredLevels[module] || 9;
<div className="flex items-center gap-2 md:gap-3"> const level = user.levels[module] || 0;
<div className="bg-mti-gray-smoke flex h-8 w-8 items-center justify-center rounded-lg md:h-12 md:w-12 md:rounded-xl"> return (
{module === "reading" && <BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" />} <div className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4" key={module}>
{module === "listening" && <BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />} <div className="flex items-center gap-2 md:gap-3">
{module === "writing" && <BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" />} <div className="bg-mti-gray-smoke flex h-8 w-8 items-center justify-center rounded-lg md:h-12 md:w-12 md:rounded-xl">
{module === "speaking" && <BsMegaphone className="text-ielts-speaking h-4 w-4 md:h-5 md:w-5" />} {module === "reading" && <BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" />}
{module === "level" && <BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" />} {module === "listening" && <BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />}
{module === "writing" && <BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" />}
{module === "speaking" && <BsMegaphone className="text-ielts-speaking h-4 w-4 md:h-5 md:w-5" />}
{module === "level" && <BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" />}
</div>
<div className="flex w-full justify-between">
<span className="text-sm font-bold md:font-extrabold">{capitalize(module)}</span>
<span className="text-mti-gray-dim text-sm font-normal">
{module === "level" && `English Level: ${getLevelLabel(level).join(" / ")}`}
{module !== "level" && `Level ${level} / Level 9 (Desired Level: ${desiredLevel})`}
</span>
</div>
</div> </div>
<div className="flex w-full justify-between"> <div className="md:pl-14">
<span className="text-sm font-bold md:font-extrabold">{capitalize(module)}</span> <ProgressBar
<span className="text-mti-gray-dim text-sm font-normal"> color={module}
Level {user.levels[module] || 0} / Level 9 (Desired Level: {user.desiredLevels[module] || 9}) label=""
</span> mark={Math.round((desiredLevel * 100) / 9)}
markLabel={`Desired Level: ${desiredLevel}`}
percentage={Math.round((level * 100) / 9)}
className="h-2 w-full"
/>
</div> </div>
</div> </div>
<div className="md:pl-14"> );
<ProgressBar })}
color={module}
label=""
mark={Math.round((user.desiredLevels[module] * 100) / 9)}
markLabel={`Desired Level: ${user.desiredLevels[module]}`}
percentage={Math.round((user.levels[module] * 100) / 9)}
className="h-2 w-full"
/>
</div>
</div>
))}
</div> </div>
</section> </section>
</> </>

View File

@@ -151,9 +151,8 @@ export default function TeacherDashboard({user}: Props) {
}; };
const AssignmentsPage = () => { const AssignmentsPage = () => {
const activeFilter = (a: Assignment) => const activeFilter = (a: Assignment) => moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()) && a.assignees.length > a.results.length;
moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()) && a.assignees.length > a.results.length; const pastFilter = (a: Assignment) => (moment(a.endDate).isBefore(moment()) || a.assignees.length === a.results.length) && !a.archived;
const pastFilter = (a: Assignment) => moment(a.endDate).isBefore(moment()) || a.assignees.length === a.results.length;
const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment()); const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
return ( return (
@@ -163,6 +162,7 @@ export default function TeacherDashboard({user}: Props) {
onClose={() => { onClose={() => {
setSelectedAssignment(undefined); setSelectedAssignment(undefined);
setIsCreatingAssignment(false); setIsCreatingAssignment(false);
reloadAssignments();
}} }}
assignment={selectedAssignment} assignment={selectedAssignment}
/> />
@@ -234,7 +234,7 @@ export default function TeacherDashboard({user}: Props) {
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2> <h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{assignments.filter(pastFilter).map((a) => ( {assignments.filter(pastFilter).map((a) => (
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} allowDownload /> <AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} allowDownload reload={reloadAssignments} allowArchive/>
))} ))}
</div> </div>
</section> </section>
@@ -280,7 +280,7 @@ export default function TeacherDashboard({user}: Props) {
<BsEnvelopePaper className="text-6xl text-mti-purple-light" /> <BsEnvelopePaper className="text-6xl text-mti-purple-light" />
<span className="flex flex-col gap-1 items-center text-xl"> <span className="flex flex-col gap-1 items-center text-xl">
<span className="text-lg">Assignments</span> <span className="text-lg">Assignments</span>
<span className="font-semibold text-mti-purple-light">{assignments.length}</span> <span className="font-semibold text-mti-purple-light">{assignments.filter((a) => !a.archived).length}</span>
</span> </span>
</div> </div>
</section> </section>

View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
</head>
<div style="background-color: #ffffff; color: #353338;"
class="h-full min-h-screen w-full flex flex-col p-8 gap-16 text-base">
<img src="/logo_title.png" class="w-48 h-48 self-center" />
<div>
<span>Your ticket has been completed!</span>
<br/>
<span>Here is the ticket's information:</span>
<br/>
<br/>
<span><b>ID:</b> {{id}}</span><br/>
<span><b>Subject:</b> {{subject}}</span><br/>
<span><b>Reporter:</b> {{reporter.name}} - {{reporter.email}}</span><br/>
<span><b>Date:</b> {{date}}</span><br/>
<span><b>Type:</b> {{type}}</span><br/>
<span><b>Page:</b> {{reportedFrom}}</span>
<br/>
<br/>
<span><b>Description:</b> {{description}}</span><br/>
</div>
<br />
<br />
<div>
<span>Thanks, <br /> Your EnCoach team</span>
</div>
</div>
</html>

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

@@ -66,26 +66,31 @@ export default function Selection({user, page, onStart, disableSelection = false
icon: <BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />, icon: <BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />,
label: "Reading", label: "Reading",
value: totalExamsByModule(stats, "reading"), value: totalExamsByModule(stats, "reading"),
tooltip: "The amount of reading exams performed.",
}, },
{ {
icon: <BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />, icon: <BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />,
label: "Listening", label: "Listening",
value: totalExamsByModule(stats, "listening"), value: totalExamsByModule(stats, "listening"),
tooltip: "The amount of listening exams performed.",
}, },
{ {
icon: <BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />, icon: <BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />,
label: "Writing", label: "Writing",
value: totalExamsByModule(stats, "writing"), value: totalExamsByModule(stats, "writing"),
tooltip: "The amount of writing exams performed.",
}, },
{ {
icon: <BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />, icon: <BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />,
label: "Speaking", label: "Speaking",
value: totalExamsByModule(stats, "speaking"), value: totalExamsByModule(stats, "speaking"),
tooltip: "The amount of speaking exams performed.",
}, },
{ {
icon: <BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />, icon: <BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />,
label: "Level", label: "Level",
value: totalExamsByModule(stats, "level"), value: totalExamsByModule(stats, "level"),
tooltip: "The amount of level exams performed.",
}, },
]} ]}
/> />

View File

@@ -0,0 +1,26 @@
import React from "react";
import Link from "next/link";
import Checkbox from "@/components/Low/Checkbox";
const useAcceptedTerms = () => {
const [acceptedTerms, setAcceptedTerms] = React.useState(false);
const renderCheckbox = () => (
<Checkbox isChecked={acceptedTerms} onChange={setAcceptedTerms}>
I agree to the
<Link href={`https://encoach.com/terms`} className="text-mti-purple-light">
{" "}
Terms and Conditions
</Link>{" "}
and
<Link href={`https://encoach.com/privacy-policy`} className="text-mti-purple-light">
{" "}
Privacy Policy
</Link>
</Checkbox>
);
return {acceptedTerms, renderCheckbox};
};
export default useAcceptedTerms;

View File

@@ -0,0 +1,45 @@
import React from "react";
import axios from "axios";
import { toast } from "react-toastify";
import { BsArchive } from "react-icons/bs";
export const useAssignmentArchive = (
assignmentId: string,
reload?: Function
) => {
const [loading, setLoading] = React.useState(false);
const archive = () => {
// archive assignment
setLoading(true);
axios
.post(`/api/assignments/${assignmentId}/archive`)
.then((res) => {
toast.success("Assignment archived!");
if(reload) reload();
setLoading(false);
})
.catch((err) => {
toast.error("Failed to archive the assignment!");
setLoading(false);
});
};
const renderIcon = (downloadClasses: string, loadingClasses: string) => {
if (loading) {
return (
<span className={`${loadingClasses} loading loading-infinity w-6`} />
);
}
return (
<BsArchive
className={`${downloadClasses} text-2xl cursor-pointer`}
onClick={(e) => {
e.stopPropagation();
archive();
}}
/>
);
};
return renderIcon;
};

View File

@@ -0,0 +1,23 @@
import {PaypalPayment} from "@/interfaces/paypal";
import axios from "axios";
import {useEffect, useState} from "react";
export default function usePaypalPayments() {
const [payments, setPayments] = useState<PaypalPayment[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
setIsLoading(true);
axios
.get<PaypalPayment[]>("/api/payments/paypal")
.then((response) => {
return setPayments(response.data);
})
.finally(() => setIsLoading(false));
};
useEffect(getData, []);
return {payments, isLoading, isError, reload: getData};
}

View File

@@ -0,0 +1,20 @@
import { useEffect, useState } from "react";
import axios from "axios";
export const usePaypalTracking = () => {
const [trackingId, setTrackingId] = useState<string>();
useEffect(() => {
axios
.put<{ ok: boolean; trackingId: string }>("/api/paypal/raas")
.then((response) => {
if (response.data.ok) {
setTrackingId(response.data.trackingId);
}
})
.catch((error) => {
console.error(error);
});
}, []);
return trackingId;
};

View File

@@ -1,22 +1,22 @@
import { Ticket } from "@/interfaces/ticket"; import { TicketWithCorporate } 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<TicketWithCorporate[]>([]);
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<TicketWithCorporate[]>(`/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

@@ -3,6 +3,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 type InstructorGender = "male" | "female" | "varied";
export type Difficulty = "easy" | "medium" | "hard";
export interface ReadingExam { export interface ReadingExam {
parts: ReadingPart[]; parts: ReadingPart[];
@@ -12,6 +13,7 @@ export interface ReadingExam {
type: "academic" | "general"; type: "academic" | "general";
isDiagnostic: boolean; isDiagnostic: boolean;
variant?: Variant; variant?: Variant;
difficulty?: Difficulty;
} }
export interface ReadingPart { export interface ReadingPart {
@@ -29,6 +31,7 @@ export interface LevelExam {
minTimer: number; minTimer: number;
isDiagnostic: boolean; isDiagnostic: boolean;
variant?: Variant; variant?: Variant;
difficulty?: Difficulty;
} }
export interface ListeningExam { export interface ListeningExam {
@@ -38,6 +41,7 @@ export interface ListeningExam {
minTimer: number; minTimer: number;
isDiagnostic: boolean; isDiagnostic: boolean;
variant?: Variant; variant?: Variant;
difficulty?: Difficulty;
} }
export interface ListeningPart { export interface ListeningPart {
@@ -65,10 +69,11 @@ 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;
difficulty?: Difficulty;
} }
interface WordCounter { interface WordCounter {
@@ -79,11 +84,12 @@ 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;
instructorGender: InstructorGender; instructorGender: InstructorGender;
difficulty?: Difficulty;
} }
export type Exercise = export type Exercise =
@@ -142,6 +148,7 @@ export interface WritingExercise {
solution: string; solution: string;
evaluation?: CommonEvaluation; evaluation?: CommonEvaluation;
}[]; }[];
topic?: string;
} }
export interface SpeakingExercise { export interface SpeakingExercise {
@@ -156,6 +163,7 @@ export interface SpeakingExercise {
solution: string; solution: string;
evaluation?: SpeakingEvaluation; evaluation?: SpeakingEvaluation;
}[]; }[];
topic?: string;
} }
export interface InteractiveSpeakingExercise { export interface InteractiveSpeakingExercise {
@@ -169,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

@@ -35,3 +35,16 @@ export interface Payment {
corporateTransfer?: string; corporateTransfer?: string;
commissionTransfer?: string; commissionTransfer?: string;
} }
export interface PaypalPayment {
orderId: string;
userId: string;
status: string;
createdAt: Date;
value: number;
currency: string;
subscriptionDuration: number;
subscriptionDurationUnit: DurationUnit;
subscriptionExpirationDate: Date;
}

View File

@@ -24,4 +24,5 @@ export interface Assignment {
instructorGender?: InstructorGender; instructorGender?: InstructorGender;
startDate: Date; startDate: Date;
endDate: Date; endDate: Date;
archived?: boolean;
} }

View File

@@ -32,3 +32,7 @@ export const TicketStatusLabel: { [key in TicketStatus]: string } = {
"in-progress": "In Progress", "in-progress": "In Progress",
completed: "Completed", completed: "Completed",
}; };
export interface TicketWithCorporate extends Ticket {
corporate?: string;
}

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,
}; };
}); });
@@ -423,7 +424,7 @@ export default function ExamPage({page}: Props) {
<AbandonPopup <AbandonPopup
isOpen={showAbandonPopup} isOpen={showAbandonPopup}
abandonPopupTitle="Leave Exercise" abandonPopupTitle="Leave Exercise"
abandonPopupDescription="Are you sure you want to leave the exercise? You will lose all your progress." abandonPopupDescription="Are you sure you want to leave the exercise? Your progress will be saved and this exam can be resumed on the Dashboard."
abandonConfirmButtonText="Confirm" abandonConfirmButtonText="Confirm"
onAbandon={() => { onAbandon={() => {
reset(); reset();

View File

@@ -1,23 +1,30 @@
import {LevelExam, MultipleChoiceExercise} from "@/interfaces/exam"; import Select from "@/components/Low/Select";
import {Difficulty, LevelExam, MultipleChoiceExercise} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams"; import {getExamById} from "@/utils/exams";
import {playSound} from "@/utils/sound"; import {playSound} from "@/utils/sound";
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} from "lodash";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {useState} from "react"; import {useState} from "react";
import {BsArrowRepeat} from "react-icons/bs"; import {BsArrowRepeat} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import {v4} from "uuid"; import {v4} from "uuid";
const TaskTab = ({exam, setExam}: {exam?: LevelExam; setExam: (exam: LevelExam) => void}) => { const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
const TaskTab = ({exam, difficulty, setExam}: {exam?: LevelExam; difficulty: Difficulty; setExam: (exam: LevelExam) => void}) => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const generate = () => { const generate = () => {
const url = new URLSearchParams();
url.append("difficulty", difficulty);
setIsLoading(true); setIsLoading(true);
axios axios
.get(`/api/exam/level/generate/level`) .get(`/api/exam/level/generate/level?${url.toString()}`)
.then((result) => { .then((result) => {
playSound(typeof result.data === "string" ? "error" : "check"); playSound(typeof result.data === "string" ? "error" : "check");
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again."); if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
@@ -107,6 +114,7 @@ const LevelGeneration = () => {
const [generatedExam, setGeneratedExam] = useState<LevelExam>(); const [generatedExam, setGeneratedExam] = useState<LevelExam>();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<LevelExam>(); const [resultingExam, setResultingExam] = useState<LevelExam>();
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
const router = useRouter(); const router = useRouter();
@@ -163,6 +171,16 @@ const LevelGeneration = () => {
return ( return (
<> <>
<div className="flex gap-4 w-1/2">
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
<Select
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
value={{value: difficulty, label: capitalize(difficulty)}}
/>
</div>
</div>
<Tab.Group> <Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1"> <Tab.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1">
<Tab <Tab
@@ -178,7 +196,7 @@ const LevelGeneration = () => {
</Tab> </Tab>
</Tab.List> </Tab.List>
<Tab.Panels> <Tab.Panels>
<TaskTab exam={generatedExam} setExam={setGeneratedExam} /> <TaskTab difficulty={difficulty} exam={generatedExam} setExam={setGeneratedExam} />
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
<div className="w-full flex justify-end gap-4"> <div className="w-full flex justify-end gap-4">

View File

@@ -1,5 +1,6 @@
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import {Exercise, ListeningExam} from "@/interfaces/exam"; import Select from "@/components/Low/Select";
import {Difficulty, Exercise, ListeningExam} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams"; import {getExamById} from "@/utils/exams";
import {playSound} from "@/utils/sound"; import {playSound} from "@/utils/sound";
@@ -7,17 +8,34 @@ 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} from "lodash";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {BsArrowRepeat, BsCheck} from "react-icons/bs"; import {BsArrowRepeat, BsCheck} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
const PartTab = ({part, types, index, setPart}: {part?: ListeningPart; types: string[]; index: number; setPart: (part?: ListeningPart) => void}) => { const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
const PartTab = ({
part,
types,
difficulty,
index,
setPart,
}: {
part?: ListeningPart;
difficulty: Difficulty;
types: string[];
index: number;
setPart: (part?: ListeningPart) => void;
}) => {
const [topic, setTopic] = useState(""); const [topic, setTopic] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const generate = () => { const generate = () => {
const url = new URLSearchParams(); const url = new URLSearchParams();
url.append("difficulty", difficulty);
if (topic) url.append("topic", topic); if (topic) url.append("topic", topic);
if (types) types.forEach((t) => url.append("exercises", t)); if (types) types.forEach((t) => url.append("exercises", t));
@@ -115,6 +133,7 @@ const ListeningGeneration = () => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<ListeningExam>(); const [resultingExam, setResultingExam] = useState<ListeningExam>();
const [types, setTypes] = useState<string[]>([]); const [types, setTypes] = useState<string[]>([]);
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
useEffect(() => { useEffect(() => {
const part1Timer = part1 ? 5 : 0; const part1Timer = part1 ? 5 : 0;
@@ -148,7 +167,7 @@ const ListeningGeneration = () => {
setIsLoading(true); setIsLoading(true);
axios axios
.post(`/api/exam/listening/generate/listening`, {parts, minTimer}) .post(`/api/exam/listening/generate/listening`, {parts, minTimer, difficulty})
.then((result) => { .then((result) => {
playSound("sent"); playSound("sent");
console.log(`Generated Exam ID: ${result.data.id}`); console.log(`Generated Exam ID: ${result.data.id}`);
@@ -159,6 +178,7 @@ const ListeningGeneration = () => {
setPart2(undefined); setPart2(undefined);
setPart3(undefined); setPart3(undefined);
setPart4(undefined); setPart4(undefined);
setDifficulty(sample(DIFFICULTIES)!);
setTypes([]); setTypes([]);
}) })
.catch((error) => { .catch((error) => {
@@ -186,15 +206,26 @@ const ListeningGeneration = () => {
return ( return (
<> <>
<div className="flex flex-col gap-3"> <div className="flex gap-4 w-1/2">
<label className="font-normal text-base text-mti-gray-dim">Timer</label> <div className="flex flex-col gap-3">
<Input <label className="font-normal text-base text-mti-gray-dim">Timer</label>
type="number" <Input
name="minTimer" type="number"
onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))} name="minTimer"
value={minTimer} onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))}
className="max-w-[300px]" value={minTimer}
/> className="max-w-[300px]"
/>
</div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
<Select
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
value={{value: difficulty, label: capitalize(difficulty)}}
disabled={!!part1 || !!part2 || !!part3 || !!part4}
/>
</div>
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@@ -271,7 +302,7 @@ const ListeningGeneration = () => {
{part: part3, setPart: setPart3}, {part: part3, setPart: setPart3},
{part: part4, setPart: setPart4}, {part: part4, setPart: setPart4},
].map(({part, setPart}, index) => ( ].map(({part, setPart}, index) => (
<PartTab part={part} types={types} index={index + 1} key={index} setPart={setPart} /> <PartTab part={part} difficulty={difficulty} types={types} index={index + 1} key={index} setPart={setPart} />
))} ))}
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>

View File

@@ -1,5 +1,6 @@
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import {ReadingExam, ReadingPart} from "@/interfaces/exam"; import Select from "@/components/Low/Select";
import {Difficulty, ReadingExam, ReadingPart} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams"; import {getExamById} from "@/utils/exams";
import {playSound} from "@/utils/sound"; import {playSound} from "@/utils/sound";
@@ -7,18 +8,35 @@ 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} from "lodash";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {BsArrowRepeat, BsCheck} from "react-icons/bs"; import {BsArrowRepeat, BsCheck} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import {v4} from "uuid"; import {v4} from "uuid";
const PartTab = ({part, types, index, setPart}: {part?: ReadingPart; types: string[]; index: number; setPart: (part?: ReadingPart) => void}) => { const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
const PartTab = ({
part,
types,
difficulty,
index,
setPart,
}: {
part?: ReadingPart;
types: string[];
index: number;
difficulty: Difficulty;
setPart: (part?: ReadingPart) => void;
}) => {
const [topic, setTopic] = useState(""); const [topic, setTopic] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const generate = () => { const generate = () => {
const url = new URLSearchParams(); const url = new URLSearchParams();
url.append("difficulty", difficulty);
if (topic) url.append("topic", topic); if (topic) url.append("topic", topic);
if (types) types.forEach((t) => url.append("exercises", t)); if (types) types.forEach((t) => url.append("exercises", t));
@@ -92,6 +110,7 @@ const ReadingGeneration = () => {
const [types, setTypes] = useState<string[]>([]); const [types, setTypes] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<ReadingExam>(); const [resultingExam, setResultingExam] = useState<ReadingExam>();
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
useEffect(() => { useEffect(() => {
const parts = [part1, part2, part3].filter((x) => !!x); const parts = [part1, part2, part3].filter((x) => !!x);
@@ -144,6 +163,7 @@ const ReadingGeneration = () => {
id: v4(), id: v4(),
type: "academic", type: "academic",
variant: parts.length === 3 ? "full" : "partial", variant: parts.length === 3 ? "full" : "partial",
difficulty,
}; };
axios axios
@@ -157,6 +177,7 @@ const ReadingGeneration = () => {
setPart1(undefined); setPart1(undefined);
setPart2(undefined); setPart2(undefined);
setPart3(undefined); setPart3(undefined);
setDifficulty(sample(DIFFICULTIES)!);
setMinTimer(60); setMinTimer(60);
setTypes([]); setTypes([]);
}) })
@@ -169,15 +190,26 @@ const ReadingGeneration = () => {
return ( return (
<> <>
<div className="flex flex-col gap-3"> <div className="flex gap-4 w-1/2">
<label className="font-normal text-base text-mti-gray-dim">Timer</label> <div className="flex flex-col gap-3">
<Input <label className="font-normal text-base text-mti-gray-dim">Timer</label>
type="number" <Input
name="minTimer" type="number"
onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))} name="minTimer"
value={minTimer} onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))}
className="max-w-[300px]" value={minTimer}
/> className="max-w-[300px]"
/>
</div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
<Select
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
value={{value: difficulty, label: capitalize(difficulty)}}
disabled={!!part1 || !!part2 || !!part3}
/>
</div>
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@@ -240,7 +272,7 @@ const ReadingGeneration = () => {
{part: part2, setPart: setPart2}, {part: part2, setPart: setPart2},
{part: part3, setPart: setPart3}, {part: part3, setPart: setPart3},
].map(({part, setPart}, index) => ( ].map(({part, setPart}, index) => (
<PartTab part={part} types={types} index={index + 1} key={index} setPart={setPart} /> <PartTab part={part} types={types} difficulty={difficulty} index={index + 1} key={index} setPart={setPart} />
))} ))}
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>

View File

@@ -1,6 +1,6 @@
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import {Exercise, InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise} from "@/interfaces/exam"; import {Difficulty, Exercise, InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise} from "@/interfaces/exam";
import {AVATARS} from "@/resources/speakingAvatars"; 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";
@@ -17,7 +17,19 @@ import {BsArrowRepeat, BsCheck} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import {v4} from "uuid"; import {v4} from "uuid";
const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; setPart: (part?: SpeakingPart) => void}) => { const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
const PartTab = ({
part,
index,
difficulty,
setPart,
}: {
part?: SpeakingPart;
difficulty: Difficulty;
index: number;
setPart: (part?: SpeakingPart) => void;
}) => {
const [gender, setGender] = useState<"male" | "female">("male"); const [gender, setGender] = useState<"male" | "female">("male");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -25,8 +37,11 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
setPart(undefined); setPart(undefined);
setIsLoading(true); setIsLoading(true);
const url = new URLSearchParams();
url.append("difficulty", difficulty);
axios axios
.get(`/api/exam/speaking/generate/speaking_task_${index}`) .get(`/api/exam/speaking/generate/speaking_task_${index}?${url.toString()}`)
.then((result) => { .then((result) => {
playSound(typeof result.data === "string" ? "error" : "check"); playSound(typeof result.data === "string" ? "error" : "check");
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again."); if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
@@ -55,7 +70,7 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
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!");
@@ -64,6 +79,8 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
.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">
@@ -174,6 +191,7 @@ const SpeakingGeneration = () => {
const [minTimer, setMinTimer] = useState(14); const [minTimer, setMinTimer] = useState(14);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<SpeakingExam>(); const [resultingExam, setResultingExam] = useState<SpeakingExam>();
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
useEffect(() => { useEffect(() => {
const parts = [part1, part2, part3].filter((x) => !!x); const parts = [part1, part2, part3].filter((x) => !!x);
@@ -213,6 +231,7 @@ const SpeakingGeneration = () => {
setPart1(undefined); setPart1(undefined);
setPart2(undefined); setPart2(undefined);
setPart3(undefined); setPart3(undefined);
setDifficulty(sample(DIFFICULTIES)!);
setMinTimer(14); setMinTimer(14);
}) })
.catch((error) => { .catch((error) => {
@@ -240,15 +259,26 @@ const SpeakingGeneration = () => {
return ( return (
<> <>
<div className="flex flex-col gap-3"> <div className="flex gap-4 w-1/2">
<label className="font-normal text-base text-mti-gray-dim">Timer</label> <div className="flex flex-col gap-3">
<Input <label className="font-normal text-base text-mti-gray-dim">Timer</label>
type="number" <Input
name="minTimer" type="number"
onChange={(e) => setMinTimer(parseInt(e) < 5 ? 5 : parseInt(e))} name="minTimer"
value={minTimer} onChange={(e) => setMinTimer(parseInt(e) < 5 ? 5 : parseInt(e))}
className="max-w-[300px]" value={minTimer}
/> className="max-w-[300px]"
/>
</div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
<Select
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
value={{value: difficulty, label: capitalize(difficulty)}}
disabled={!!part1 || !!part2 || !!part3}
/>
</div>
</div> </div>
<Tab.Group> <Tab.Group>
@@ -293,7 +323,7 @@ const SpeakingGeneration = () => {
{part: part2, setPart: setPart2}, {part: part2, setPart: setPart2},
{part: part3, setPart: setPart3}, {part: part3, setPart: setPart3},
].map(({part, setPart}, index) => ( ].map(({part, setPart}, index) => (
<PartTab part={part} index={index + 1} key={index} setPart={setPart} /> <PartTab difficulty={difficulty} part={part} index={index + 1} key={index} setPart={setPart} />
))} ))}
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>

View File

@@ -1,24 +1,32 @@
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import {WritingExam, WritingExercise} from "@/interfaces/exam"; import Select from "@/components/Low/Select";
import {Difficulty, WritingExam, WritingExercise} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams"; import {getExamById} from "@/utils/exams";
import {playSound} from "@/utils/sound"; import {playSound} from "@/utils/sound";
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} from "lodash";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {BsArrowRepeat, BsCheck} from "react-icons/bs"; import {BsArrowRepeat, BsCheck} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import {v4} from "uuid"; import {v4} from "uuid";
const TaskTab = ({task, index, setTask}: {task?: string; index: number; setTask: (task: string) => void}) => { const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
const TaskTab = ({task, index, difficulty, setTask}: {task?: string; difficulty: Difficulty; index: number; setTask: (task: string) => void}) => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const generate = () => { const generate = () => {
setIsLoading(true); setIsLoading(true);
const url = new URLSearchParams();
url.append("difficulty", difficulty);
axios axios
.get(`/api/exam/writing/generate/writing_task${index}_general`) .get(`/api/exam/writing/generate/writing_task${index}_general?${url.toString()}`)
.then((result) => { .then((result) => {
playSound(typeof result.data === "string" ? "error" : "check"); playSound(typeof result.data === "string" ? "error" : "check");
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again."); if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
@@ -72,6 +80,7 @@ const WritingGeneration = () => {
const [minTimer, setMinTimer] = useState(60); const [minTimer, setMinTimer] = useState(60);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<WritingExam>(); const [resultingExam, setResultingExam] = useState<WritingExam>();
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
useEffect(() => { useEffect(() => {
const task1Timer = task1 ? 20 : 0; const task1Timer = task1 ? 20 : 0;
@@ -144,6 +153,7 @@ const WritingGeneration = () => {
exercises: [...(exercise1 ? [exercise1] : []), ...(exercise2 ? [exercise2] : [])], exercises: [...(exercise1 ? [exercise1] : []), ...(exercise2 ? [exercise2] : [])],
id: v4(), id: v4(),
variant: exercise1 && exercise2 ? "full" : "partial", variant: exercise1 && exercise2 ? "full" : "partial",
difficulty,
}; };
axios axios
@@ -156,6 +166,7 @@ const WritingGeneration = () => {
setTask1(undefined); setTask1(undefined);
setTask2(undefined); setTask2(undefined);
setDifficulty(sample(DIFFICULTIES)!);
}) })
.catch((error) => { .catch((error) => {
console.log(error); console.log(error);
@@ -166,15 +177,26 @@ const WritingGeneration = () => {
return ( return (
<> <>
<div className="flex flex-col gap-3"> <div className="flex gap-4 w-1/2">
<label className="font-normal text-base text-mti-gray-dim">Timer</label> <div className="flex flex-col gap-3">
<Input <label className="font-normal text-base text-mti-gray-dim">Timer</label>
type="number" <Input
name="minTimer" type="number"
onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))} name="minTimer"
value={minTimer} onChange={(e) => setMinTimer(parseInt(e) < 15 ? 15 : parseInt(e))}
className="max-w-[300px]" value={minTimer}
/> className="max-w-[300px]"
/>
</div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
<Select
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
value={{value: difficulty, label: capitalize(difficulty)}}
disabled={!!task1 || !!task2}
/>
</div>
</div> </div>
<Tab.Group> <Tab.Group>
@@ -207,7 +229,7 @@ const WritingGeneration = () => {
{task: task1, setTask: setTask1}, {task: task1, setTask: setTask1},
{task: task2, setTask: setTask2}, {task: task2, setTask: setTask2},
].map(({task, setTask}, index) => ( ].map(({task, setTask}, index) => (
<TaskTab task={task} index={index + 1} key={index} setTask={setTask} /> <TaskTab difficulty={difficulty} task={task} index={index + 1} key={index} setTask={setTask} />
))} ))}
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>

View File

@@ -10,6 +10,7 @@ import { toast } from "react-toastify";
import { KeyedMutator } from "swr"; import { KeyedMutator } from "swr";
import Select from "react-select"; import Select from "react-select";
import moment from "moment"; import moment from "moment";
import useAcceptedTerms from "@/hooks/useAcceptedTerms";
interface Props { interface Props {
isLoading: boolean; isLoading: boolean;
@@ -40,6 +41,7 @@ export default function RegisterCorporate({
const [companyName, setCompanyName] = useState(""); const [companyName, setCompanyName] = useState("");
const [companyUsers, setCompanyUsers] = useState(0); const [companyUsers, setCompanyUsers] = useState(0);
const [subscriptionDuration, setSubscriptionDuration] = useState(1); const [subscriptionDuration, setSubscriptionDuration] = useState(1);
const {acceptedTerms, renderCheckbox} = useAcceptedTerms();
const { users } = useUsers(); const { users } = useUsers();
@@ -257,7 +259,9 @@ export default function RegisterCorporate({
/> />
</div> </div>
</div> </div>
<div className="flex w-full flex-col items-start gap-4">
{renderCheckbox()}
</div>
<Button <Button
className="w-full lg:mt-8" className="w-full lg:mt-8"
color="purple" color="purple"

View File

@@ -4,9 +4,10 @@ import Input from "@/components/Low/Input";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { sendEmailVerification } from "@/utils/email"; import { sendEmailVerification } from "@/utils/email";
import axios from "axios"; import axios from "axios";
import { useEffect, useState } from "react"; import { useState } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { KeyedMutator } from "swr"; import { KeyedMutator } from "swr";
import useAcceptedTerms from "@/hooks/useAcceptedTerms";
interface Props { interface Props {
queryCode?: string; queryCode?: string;
@@ -35,6 +36,7 @@ export default function RegisterIndividual({
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [code, setCode] = useState(queryCode || ""); const [code, setCode] = useState(queryCode || "");
const [hasCode, setHasCode] = useState<boolean>(!!queryCode); const [hasCode, setHasCode] = useState<boolean>(!!queryCode);
const {acceptedTerms, renderCheckbox} = useAcceptedTerms();
const onSuccess = () => const onSuccess = () =>
toast.success( toast.success(
@@ -146,7 +148,9 @@ export default function RegisterIndividual({
/> />
)} )}
</div> </div>
<div className="flex w-full flex-col items-start gap-4">
{renderCheckbox()}
</div>
<Button <Button
className="w-full lg:mt-8" className="w-full lg:mt-8"
color="purple" color="purple"
@@ -156,6 +160,7 @@ export default function RegisterIndividual({
!name || !name ||
!password || !password ||
!confirmPassword || !confirmPassword ||
!acceptedTerms ||
password !== confirmPassword || password !== confirmPassword ||
(hasCode ? !code : false) (hasCode ? !code : false)
} }

View File

@@ -14,6 +14,7 @@ import {BsArrowRepeat} from "react-icons/bs";
import InviteCard from "@/components/Medium/InviteCard"; import InviteCard from "@/components/Medium/InviteCard";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {PayPalScriptProvider} from "@paypal/react-paypal-js"; import {PayPalScriptProvider} from "@paypal/react-paypal-js";
import { usePaypalTracking } from "@/hooks/usePaypalTracking";
interface Props { interface Props {
user: User; user: User;
@@ -31,7 +32,8 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
const {users} = useUsers(); const {users} = useUsers();
const {groups} = useGroups(); const {groups} = useGroups();
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user?.id}); const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user?.id});
const trackingId = usePaypalTracking();
const isIndividual = () => { const isIndividual = () => {
if (user?.type === "developer") return true; if (user?.type === "developer") return true;
if (user?.type !== "student") return false; if (user?.type !== "student") return false;
@@ -121,6 +123,7 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
onSuccess={() => { onSuccess={() => {
setTimeout(reload, 500); setTimeout(reload, 500);
}} }}
trackingId={trackingId}
/> />
</div> </div>
<div className="flex flex-col items-start gap-1"> <div className="flex flex-col items-start gap-1">
@@ -165,6 +168,7 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
setTimeout(reload, 500); setTimeout(reload, 500);
}} }}
loadScript loadScript
trackingId={trackingId}
/> />
</div> </div>
<div className="flex flex-col items-start gap-1"> <div className="flex flex-col items-start gap-1">

View File

@@ -0,0 +1,33 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
import { getFirestore, doc, getDoc, setDoc } from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function post(req: NextApiRequest, res: NextApiResponse) {
// verify if it's a logged user that is trying to archive
if (req.session.user) {
const { id } = req.query as { id: string };
const docSnap = await getDoc(doc(db, "assignments", id));
if (!docSnap.exists()) {
res.status(404).json({ ok: false });
return;
}
await setDoc(docSnap.ref, { archived: true }, { merge: true });
res.status(200).json({ ok: true });
return;
}
res.status(401).json({ ok: false });
}
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") return post(req, res);
res.status(404).json({ ok: false });
}

View File

@@ -5,7 +5,7 @@ import {getFirestore, collection, getDocs, query, where} from "firebase/firestor
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {shuffle} from "lodash"; import {shuffle} from "lodash";
import {Exam} from "@/interfaces/exam"; import {Difficulty, Exam} from "@/interfaces/exam";
import {Stat} from "@/interfaces/user"; import {Stat} from "@/interfaces/user";
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import axios from "axios"; import axios from "axios";
@@ -25,10 +25,21 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) return res.status(401).json({ok: false}); if (!req.session.user) return res.status(401).json({ok: false});
if (req.session.user.type !== "developer") return res.status(403).json({ok: false}); if (req.session.user.type !== "developer") return res.status(403).json({ok: false});
const {endpoint, topic, exercises} = req.query as {module: Module; endpoint: string; topic?: string; exercises?: string[]}; const {endpoint, topic, exercises, difficulty} = req.query as {
module: Module;
endpoint: string;
topic?: string;
exercises?: string[];
difficulty?: Difficulty;
};
const url = `${process.env.BACKEND_URL}/${endpoint}`; const url = `${process.env.BACKEND_URL}/${endpoint}`;
const result = await axios.get(`${url}${topic && exercises ? `?topic=${topic.toLowerCase()}&exercises=${exercises.join("&exercises=")}` : ""}`, { const params = new URLSearchParams();
if (topic) params.append("topic", topic);
if (exercises) exercises.forEach((exercise) => params.append("exercises", exercise));
if (difficulty) params.append("difficulty", difficulty);
const result = await axios.get(`${url}${params.toString().length > 0 ? `?${params.toString()}` : ""}`, {
headers: {Authorization: `Bearer ${process.env.BACKEND_JWT}`}, headers: {Authorization: `Bearer ${process.env.BACKEND_JWT}`},
}); });

View File

@@ -6,6 +6,7 @@ import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {Exam, InstructorGender, Variant} from "@/interfaces/exam"; import {Exam, InstructorGender, Variant} from "@/interfaces/exam";
import {getExams} from "@/utils/exams.be"; import {getExams} from "@/utils/exams.be";
import {Module} from "@/interfaces";
const db = getFirestore(app); const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -24,7 +25,7 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
} }
const {module, avoidRepeated, variant, instructorGender} = req.query as { const {module, avoidRepeated, variant, instructorGender} = req.query as {
module: string; module: Module;
avoidRepeated: string; avoidRepeated: string;
variant?: Variant; variant?: Variant;
instructorGender?: InstructorGender; instructorGender?: InstructorGender;

View File

@@ -0,0 +1,30 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
import {
getFirestore,
getDocs,
collection,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function get(req: NextApiRequest, res: NextApiResponse) {
const payments = await getDocs(collection(db, "paypalpayments"));
const data = payments.docs.map((doc) => doc.data());
res.status(200).json(data);
}
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
if (req.method === "GET") await get(req, res);
}

View File

@@ -1,83 +1,132 @@
// 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 {getFirestore, collection, getDocs, setDoc, doc} from "firebase/firestore"; import {
import {withIronSessionApiRoute} from "iron-session/next"; getFirestore,
import {sessionOptions} from "@/lib/session"; collection,
getDocs,
setDoc,
doc,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import axios from "axios"; import axios from "axios";
import {DurationUnit, TokenError, TokenSuccess} from "@/interfaces/paypal"; import { DurationUnit, TokenError, TokenSuccess } from "@/interfaces/paypal";
import {base64} from "@firebase/util"; import { base64 } from "@firebase/util";
import {v4} from "uuid"; import { v4 } from "uuid";
import {OrderResponseBody} from "@paypal/paypal-js"; import { OrderResponseBody } from "@paypal/paypal-js";
import {getAccessToken} from "@/utils/paypal"; import { getAccessToken } from "@/utils/paypal";
import moment from "moment"; import moment from "moment";
import {Group} from "@/interfaces/user"; import { Group } from "@/interfaces/user";
const db = getFirestore(app); const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") return res.status(404).json({ok: false, reason: "Method not supported!"}); if (req.method !== "POST")
if (!req.session.user) return res.status(401).json({ok: false}); return res.status(404).json({ ok: false, reason: "Method not supported!" });
if (!req.session.user) return res.status(401).json({ ok: false });
const accessToken = await getAccessToken(); const accessToken = await getAccessToken();
if (!accessToken) return res.status(401).json({ok: false, reason: "Authorization failed!"}); if (!accessToken)
return res.status(401).json({ ok: false, reason: "Authorization failed!" });
const {id, duration, duration_unit} = req.body as {id: string; duration: number; duration_unit: DurationUnit}; const { id, duration, duration_unit, trackingId } = req.body as {
id: string;
duration: number;
duration_unit: DurationUnit;
trackingId: string;
};
const request = await axios.post( if (!trackingId)
`${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders/${id}/capture`, return res.status(401).json({ ok: false, reason: "Missing tracking id!" });
{},
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
if (request.data.status === "COMPLETED") { const request = await axios.post(
const user = req.session.user; `${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders/${id}/capture`,
const subscriptionExpirationDate = req.session.user.subscriptionExpirationDate; {},
const today = moment(new Date()); {
const dateToBeAddedTo = !subscriptionExpirationDate headers: {
? today Authorization: `Bearer ${accessToken}`,
: moment(subscriptionExpirationDate).isAfter(today) "PayPal-Client-Metadata-Id": trackingId,
? moment(subscriptionExpirationDate) },
: today; }
);
const updatedExpirationDate = dateToBeAddedTo.add(duration, duration_unit); if (request.data.status === "COMPLETED") {
await setDoc( const user = req.session.user;
doc(db, "users", req.session.user.id), const subscriptionExpirationDate =
{subscriptionExpirationDate: updatedExpirationDate.toISOString(), status: "active"}, req.session.user.subscriptionExpirationDate;
{merge: true}, const today = moment(new Date());
); const dateToBeAddedTo = !subscriptionExpirationDate
? today
: moment(subscriptionExpirationDate).isAfter(today)
? moment(subscriptionExpirationDate)
: today;
if (user.type === "corporate") { const updatedExpirationDate = dateToBeAddedTo.add(duration, duration_unit);
const snapshot = await getDocs(collection(db, "groups")); await setDoc(
const groups: Group[] = ( doc(db, "users", req.session.user.id),
snapshot.docs.map((doc) => ({ {
id: doc.id, subscriptionExpirationDate: updatedExpirationDate.toISOString(),
...doc.data(), status: "active",
})) as Group[] },
).filter((x) => x.admin === user.id); { merge: true }
);
await Promise.all(
groups try {
.flatMap((x) => x.participants) await setDoc(
.map( doc(db, 'paypalpayments', v4()),
async (x) => {
await setDoc( orderId: id,
doc(db, "users", x), userId: req.session.user.id,
{subscriptionExpirationDate: updatedExpirationDate.toISOString(), status: "active"}, status: request.data.status,
{merge: true}, createdAt: new Date().toISOString(),
), value: request.data.purchase_units[0].payments.captures[0].amount.value,
), currency: request.data.purchase_units[0].payments.captures[0].amount.currency_code,
subscriptionDuration: duration,
subscriptionDurationUnit: duration_unit,
subscriptionExpirationDate: updatedExpirationDate.toISOString(),
}
); );
} catch(err) {
console.error('Failed to insert paypal payment!', err);
} }
return res.status(200).json({ok: true}); if (user.type === "corporate") {
} const snapshot = await getDocs(collection(db, "groups"));
const groups: Group[] = (
snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as Group[]
).filter((x) => x.admin === user.id);
res.status(404).json({ok: false, reason: "Order ID not found or purchase was not approved!"}); await Promise.all(
groups
.flatMap((x) => x.participants)
.map(
async (x) =>
await setDoc(
doc(db, "users", x),
{
subscriptionExpirationDate:
updatedExpirationDate.toISOString(),
status: "active",
},
{ merge: true }
)
)
);
}
return res.status(200).json({ ok: true });
}
res
.status(404)
.json({
ok: false,
reason: "Order ID not found or purchase was not approved!",
});
} }

View File

@@ -20,7 +20,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const accessToken = await getAccessToken(); const accessToken = await getAccessToken();
if (!accessToken) return res.status(401).json({ok: false, reason: "Authorization failed!"}); if (!accessToken) return res.status(401).json({ok: false, reason: "Authorization failed!"});
const {currencyCode, price} = req.body as {currencyCode: string; price: number}; const {currencyCode, price, trackingId} = req.body as {currencyCode: string; price: number, trackingId: string};
if(!trackingId) return res.status(401).json({ok: false, reason: "Missing tracking id!"});
const request = await axios.post<OrderResponseBody>( const request = await axios.post<OrderResponseBody>(
`${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders`, `${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders`,
@@ -34,11 +36,24 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
reference_id: v4(), reference_id: v4(),
}, },
], ],
payment_source: {
paypal: {
email_address: req.session.user.email || "",
experience_context: {
payment_method_preference: "IMMEDIATE_PAYMENT_REQUIRED",
locale: "en-US",
landing_page: "LOGIN",
shipping_preference: "NO_SHIPPING",
user_action: "PAY_NOW",
},
},
},
intent: "CAPTURE", intent: "CAPTURE",
}, },
{ {
headers: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
'PayPal-Client-Metadata-Id': trackingId,
}, },
}, },
); );

View File

@@ -0,0 +1,55 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
import { getFirestore, collection, getDocs } from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import axios from "axios";
import { v4 } from "uuid";
import { OrderResponseBody } from "@paypal/paypal-js";
import { getAccessToken } from "@/utils/paypal";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "PUT")
return res.status(404).json({ ok: false, reason: "Method not supported!" });
if (!req.session.user) return res.status(401).json({ ok: false });
const accessToken = await getAccessToken();
if (!accessToken)
return res.status(401).json({ ok: false, reason: "Authorization failed!" });
const trackingId = `${req.session.user.id}-${Date.now()}`;
try {
const request = await axios.put(
`${process.env.PAYPAL_ACCESS_TOKEN_URL}/v1/risk/transaction-contexts/${process.env.PAYPAL_MERCHANT_ID}/${trackingId}`,
{
additional_data: [
{
key: "user_id",
value: req.session.user.id,
},
],
},
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
return res.status(request.status).json({
ok: true,
trackingId,
});
} catch (err) {
return res
.status(500)
.json({ ok: false, reason: "Failed to create tracking ID" });
}
}

View File

@@ -5,6 +5,7 @@ import {Stat, User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {calculateBandScore} from "@/utils/score"; import {calculateBandScore} from "@/utils/score";
import {groupByModule, groupBySession} from "@/utils/stats"; import {groupByModule, groupBySession} from "@/utils/stats";
import { MODULE_ARRAY } from "@/utils/moduleUtils";
import {getAuth} from "firebase/auth"; import {getAuth} from "firebase/auth";
import {collection, doc, getDoc, getDocs, getFirestore, query, updateDoc, where} from "firebase/firestore"; import {collection, doc, getDoc, getDocs, getFirestore, query, updateDoc, where} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
@@ -55,7 +56,7 @@ async function update(req: NextApiRequest, res: NextApiResponse) {
}, },
}; };
MODULES.forEach((module: Module) => { MODULE_ARRAY.forEach((module: Module) => {
const moduleStats = sessionStats.filter((x) => x.module === module); const moduleStats = sessionStats.filter((x) => x.module === module);
if (moduleStats.length === 0) return; if (moduleStats.length === 0) return;
@@ -87,11 +88,18 @@ async function update(req: NextApiRequest, res: NextApiResponse) {
.filter((x) => x.total > 0) .filter((x) => x.total > 0)
.reduce((acc, cur) => ({total: acc.total + cur.total, correct: acc.correct + cur.correct}), {total: 0, correct: 0}); .reduce((acc, cur) => ({total: acc.total + cur.total, correct: acc.correct + cur.correct}), {total: 0, correct: 0});
const levelLevel = sessionLevels
.map((x) => x.level)
.filter((x) => x.total > 0)
.reduce((acc, cur) => ({total: acc.total + cur.total, correct: acc.correct + cur.correct}), {total: 0, correct: 0});
const levels = { const levels = {
reading: calculateBandScore(readingLevel.correct, readingLevel.total, "reading", req.session.user.focus), reading: calculateBandScore(readingLevel.correct, readingLevel.total, "reading", req.session.user.focus),
listening: calculateBandScore(listeningLevel.correct, listeningLevel.total, "listening", req.session.user.focus), listening: calculateBandScore(listeningLevel.correct, listeningLevel.total, "listening", req.session.user.focus),
writing: calculateBandScore(writingLevel.correct, writingLevel.total, "writing", req.session.user.focus), writing: calculateBandScore(writingLevel.correct, writingLevel.total, "writing", req.session.user.focus),
speaking: calculateBandScore(speakingLevel.correct, speakingLevel.total, "speaking", req.session.user.focus), speaking: calculateBandScore(speakingLevel.correct, speakingLevel.total, "speaking", req.session.user.focus),
level: calculateBandScore(levelLevel.correct, levelLevel.total, "level", req.session.user.focus),
}; };
const userDoc = doc(db, "users", req.session.user.id); const userDoc = doc(db, "users", req.session.user.id);

View File

@@ -28,7 +28,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
} }
const audioFile = files.audio; const audioFile = files.audio;
const audioFileRef = ref(storage, `${fields.root}/${(audioFile as any).path.split("/").pop()!.replace("upload_", "")}`); const audioFileRef = ref(storage, `${fields.root}/${(audioFile as any).path.replace("upload_", "")}`);
const binary = fs.readFileSync((audioFile as any).path).buffer; const binary = fs.readFileSync((audioFile as any).path).buffer;
const snapshot = await uploadBytes(audioFileRef, binary); const snapshot = await uploadBytes(audioFileRef, binary);

View File

@@ -10,7 +10,9 @@ import {
} from "firebase/firestore"; } 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 { Ticket } from "@/interfaces/ticket"; import { Ticket, TicketTypeLabel, TicketStatusLabel } from "@/interfaces/ticket";
import moment from "moment";
import { sendEmail } from "@/email";
const db = getFirestore(app); const db = getFirestore(app);
@@ -69,12 +71,38 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
} }
const { id } = req.query as { id: string }; const { id } = req.query as { id: string };
const body = req.body as Ticket;
const snapshot = await getDoc(doc(db, "tickets", id)); const snapshot = await getDoc(doc(db, "tickets", id));
const user = req.session.user; const user = req.session.user;
if (user.type === "admin" || user.type === "developer") { if (user.type === "admin" || user.type === "developer") {
await setDoc(snapshot.ref, req.body, { merge: true }); const data = snapshot.data() as Ticket;
return res.status(200).json({ ok: true }); await setDoc(snapshot.ref, body, { merge: true });
try {
// send email if the status actually changed to completed
if(data.status !== req.body.status && req.body.status === 'completed') {
await sendEmail(
"ticketStatusCompleted",
{
id,
subject: body.subject,
reporter: body.reporter,
date: moment(body.date).format("DD/MM/YYYY - HH:mm"),
type: TicketTypeLabel[body.type],
reportedFrom: body.reportedFrom,
description: body.description,
},
[data.reporter.email],
`Ticket ${id}: ${data.subject}`,
);
}
} catch(err) {
console.error(err);
// doesnt matter if the email fails
}
res.status(200).json({ ok: true });
return;
} }
res.status(403).json({ ok: false }); res.status(403).json({ ok: false });

View File

@@ -1,7 +1,7 @@
// 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 { sendEmail } from "@/email"; import { sendEmail } from "@/email";
import { app } from "@/firebase"; import { app } from "@/firebase";
import { Ticket, TicketTypeLabel } from "@/interfaces/ticket"; import { Ticket, TicketTypeLabel, TicketWithCorporate } from "@/interfaces/ticket";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { import {
collection, collection,
@@ -9,35 +9,76 @@ import {
getDocs, getDocs,
getFirestore, getFirestore,
setDoc, setDoc,
where,
query,
} from "firebase/firestore"; } from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import moment from "moment"; import moment from "moment";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import ShortUniqueId from "short-unique-id"; import ShortUniqueId from "short-unique-id";
import { Group, CorporateUser } from "@/interfaces/user";
const db = getFirestore(app); const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
// due to integration with the homepage the POST request should be public
if (req.method === "POST") {
await post(req, res);
return;
}
// specific logic for the preflight request
if (req.method === "OPTIONS") {
res.status(200).end();
return;
}
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ ok: false }); res.status(401).json({ ok: false });
return; return;
} }
if (req.method === "GET") await get(req, res); if (req.method === "GET") {
if (req.method === "POST") await post(req, res); await get(req, res);
}
} }
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
const snapshot = await getDocs(collection(db, "tickets")); const snapshot = await getDocs(collection(db, "tickets"));
res.status(200).json( const docs = snapshot.docs.map((doc) => ({
snapshot.docs.map((doc) => ({ id: doc.id,
id: doc.id, ...doc.data(),
...doc.data(), })) as Ticket[];
})),
); // fetch all groups for these users
const reporters = [...new Set(docs.map((d) => d.reporter.id).filter((id) => id))];
const groupsSnapshot = await getDocs(query(collection(db, "groups"), where("participants", "array-contains-any", reporters)));
const groups = groupsSnapshot.docs.map((doc) => doc.data()) as Group[];
// based on the admin of each group, verify if it exists and it's of type corporate
const groupsAdmins = [...new Set(groups.map((g) => g.admin).filter((id) => id))];
const adminsSnapshot = await getDocs(query(collection(db, "users"), where("id", "in", groupsAdmins), where("type", "==", "corporate")));
const admins = adminsSnapshot.docs.map((doc) => doc.data());
const docsWithAdmins = docs.map((d) => {
const group = groups.find((g) => g.participants.includes(d.reporter.id));
const admin = admins.find((a) => a.id === group?.admin) as CorporateUser;
if(admin) {
return {
...d,
corporate: admin.corporateInformation?.companyInformation?.name,
};
}
return d;
}) as TicketWithCorporate[];
res.status(200).json(docsWithAdmins);
} }
async function post(req: NextApiRequest, res: NextApiResponse) { async function post(req: NextApiRequest, res: NextApiResponse) {
@@ -61,7 +102,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
description: body.description, description: body.description,
}, },
[body.reporter.email], [body.reporter.email],
`Ticket ${id}: ${body.subject}`, `Ticket ${id}: ${body.subject}`
); );
} catch (e) { } catch (e) {
console.log(e); console.log(e);

View File

@@ -0,0 +1,57 @@
import { app, adminApp } from "@/firebase";
import { AgentUser } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
import {
collection,
getDocs,
getFirestore,
query,
where,
} from "firebase/firestore";
import { getAuth } from "firebase-admin/auth";
import { withIronSessionApiRoute } from "iron-session/next";
import { NextApiRequest, NextApiResponse } from "next";
import countryCodes from "country-codes-list";
const db = getFirestore(app);
const auth = getAuth(adminApp);
export default withIronSessionApiRoute(user, sessionOptions);
interface Contact {
name: string;
email: string;
number: string;
}
async function get(req: NextApiRequest, res: NextApiResponse) {
const { code, language = 'en' } = req.query as { code: string, language: string};
const usersQuery = query(
collection(db, "users"),
where("type", "==", "agent"),
where("demographicInformation.country", "==", code)
);
const docsUser = await getDocs(usersQuery);
const docs = docsUser.docs.map((doc) => doc.data() as AgentUser);
const entries = docs.map((user: AgentUser) => {
const newUser = {
name: user.agentInformation.companyName,
email: user.email,
number: user.demographicInformation?.phone as string,
} as Contact;
return newUser;
}) as Contact[];
const country = countryCodes.findOne("countryCode" as any, code.toUpperCase());
const key = language === 'ar' ? 'countryNameLocal' : 'countryNameEn';
res.json({
label: country[key],
entries,
});
}
async function user(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return get(req, res);
}

View File

@@ -0,0 +1,68 @@
import { app, adminApp } from "@/firebase";
import { AgentUser } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
import {
collection,
getDocs,
getFirestore,
query,
where,
} from "firebase/firestore";
import { getAuth } from "firebase-admin/auth";
import { withIronSessionApiRoute } from "iron-session/next";
import { NextApiRequest, NextApiResponse } from "next";
import countryCodes from "country-codes-list";
const db = getFirestore(app);
const auth = getAuth(adminApp);
export default withIronSessionApiRoute(user, sessionOptions);
interface Contact {
name: string;
email: string;
number: string;
}
async function get(req: NextApiRequest, res: NextApiResponse) {
const { language = 'en' } = req.query as { language: string };
const usersQuery = query(
collection(db, "users"),
where("type", "==", "agent")
);
const docsUser = await getDocs(usersQuery);
const docs = docsUser.docs.map((doc) => doc.data() as AgentUser);
const data = docs.reduce(
(acc: Record<string, Contact[]>, user: AgentUser) => {
const countryCode = user.demographicInformation?.country as string;
const currentValues = acc[countryCode] || ([] as Contact[]);
const newUser = {
name: user.agentInformation.companyName,
email: user.email,
number: user.demographicInformation?.phone as string,
} as Contact;
return {
...acc,
[countryCode]: [...currentValues, newUser],
};
},
{}
) as Record<string, Contact[]>;
const result = Object.keys(data).map((code) => {
const country = countryCodes.findOne("countryCode" as any, code.toUpperCase());
const key = language === 'ar' ? 'countryNameLocal' : 'countryNameEn';
return {
label: country[key],
key: code,
entries: data[code],
};
});
res.json(result);
}
async function user(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return get(req, res);
}

File diff suppressed because it is too large Load Diff

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 || []}
/>
</> </>
)} )}

View File

@@ -11,6 +11,7 @@ import {
TicketStatusLabel, TicketStatusLabel,
TicketType, TicketType,
TicketTypeLabel, TicketTypeLabel,
TicketWithCorporate,
} from "@/interfaces/ticket"; } from "@/interfaces/ticket";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
@@ -28,7 +29,7 @@ import { useEffect, useState } from "react";
import { BsArrowDown, BsArrowUp } from "react-icons/bs"; import { BsArrowDown, BsArrowUp } from "react-icons/bs";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
const columnHelper = createColumnHelper<Ticket>(); const columnHelper = createColumnHelper<TicketWithCorporate>();
export const getServerSideProps = withIronSessionSsr(({ req, res }) => { export const getServerSideProps = withIronSessionSsr(({ req, res }) => {
const user = req.session.user; const user = req.session.user;
@@ -75,10 +76,26 @@ const TypesClassNames: { [key in TicketType]: string } = {
help: "bg-mti-blue-light", help: "bg-mti-blue-light",
}; };
const escapedURL = process.env.NEXT_PUBLIC_WEBSITE_URL || ''.replace(
/[.*+?^${}()|[\]\\]/g,
"\\$&"
);
const fromHomepage = [new RegExp(`^${escapedURL}`), /\/contact$/];
type Source = "webpage" | "platform" | "";
const SOURCE_OPTIONS = [
{ value: "", label: "All" },
{ value: "webpage", label: "Webpage" },
{ value: "platform", label: "Platform" },
]
export default function Tickets() { export default function Tickets() {
const [filteredTickets, setFilteredTickets] = useState<Ticket[]>([]); const [filteredTickets, setFilteredTickets] = useState<Ticket[]>([]);
const [selectedTicket, setSelectedTicket] = useState<Ticket>(); const [selectedTicket, setSelectedTicket] = useState<Ticket>();
const [assigneeFilter, setAssigneeFilter] = useState<string>(); const [assigneeFilter, setAssigneeFilter] = useState<string>();
const [sourceFilter, setSourceFilter] = useState<Source>("");
const [dateSorting, setDateSorting] = useState<"asc" | "desc">("desc"); const [dateSorting, setDateSorting] = useState<"asc" | "desc">("desc");
const [typeFilter, setTypeFilter] = useState<TicketType>(); const [typeFilter, setTypeFilter] = useState<TicketType>();
@@ -90,7 +107,7 @@ export default function Tickets() {
const sortByDate = (a: Ticket, b: Ticket) => { const sortByDate = (a: Ticket, b: Ticket) => {
return moment((dateSorting === "desc" ? b : a).date).diff( return moment((dateSorting === "desc" ? b : a).date).diff(
moment((dateSorting === "desc" ? a : b).date), moment((dateSorting === "desc" ? a : b).date)
); );
}; };
@@ -102,11 +119,16 @@ export default function Tickets() {
if (statusFilter) filters.push((x: Ticket) => x.status === statusFilter); if (statusFilter) filters.push((x: Ticket) => x.status === statusFilter);
if (assigneeFilter) if (assigneeFilter)
filters.push((x: Ticket) => x.assignedTo === assigneeFilter); filters.push((x: Ticket) => x.assignedTo === assigneeFilter);
if (sourceFilter) {
if (sourceFilter === "webpage")
filters.push((x: Ticket) => fromHomepage.some((r) => r.test(x.reportedFrom)));
if (sourceFilter === "platform")
filters.push((x: Ticket) => !fromHomepage.some((r) => r.test(x.reportedFrom)));
}
setFilteredTickets( setFilteredTickets(
[...filters.reduce((d, f) => d.filter(f), tickets)].sort(sortByDate), [...filters.reduce((d, f) => d.filter(f), tickets)].sort(sortByDate)
); );
}, [tickets, typeFilter, statusFilter, assigneeFilter, dateSorting, user]); }, [tickets, typeFilter, statusFilter, assigneeFilter, dateSorting, user, sourceFilter]);
const columns = [ const columns = [
columnHelper.accessor("id", { columnHelper.accessor("id", {
@@ -119,7 +141,7 @@ export default function Tickets() {
<span <span
className={clsx( className={clsx(
"rounded-lg p-1 px-2 text-white", "rounded-lg p-1 px-2 text-white",
TypesClassNames[info.getValue()], TypesClassNames[info.getValue()]
)} )}
> >
{TicketTypeLabel[info.getValue()]} {TicketTypeLabel[info.getValue()]}
@@ -148,7 +170,9 @@ export default function Tickets() {
{dateSorting === "asc" && <BsArrowUp />} {dateSorting === "asc" && <BsArrowUp />}
</button> </button>
) as any, ) as any,
cell: (info) => moment(info.getValue()).format("DD/MM/YYYY - HH:mm"), cell: (info) => (
<span className="whitespace-nowrap">{moment(info.getValue()).format("DD/MM/YYYY - HH:mm")}</span>
),
}), }),
columnHelper.accessor("subject", { columnHelper.accessor("subject", {
header: "Subject", header: "Subject",
@@ -162,7 +186,7 @@ export default function Tickets() {
<span <span
className={clsx( className={clsx(
"rounded-lg p-1 px-2 text-white", "rounded-lg p-1 px-2 text-white",
StatusClassNames[info.getValue()], StatusClassNames[info.getValue()]
)} )}
> >
{TicketStatusLabel[info.getValue()]} {TicketStatusLabel[info.getValue()]}
@@ -173,6 +197,10 @@ export default function Tickets() {
header: "Assignee", header: "Assignee",
cell: (info) => users.find((x) => x.id === info.getValue())?.name || "", cell: (info) => users.find((x) => x.id === info.getValue())?.name || "",
}), }),
columnHelper.accessor("corporate", {
header: "Corporate",
cell: (info) => info.getValue(),
}),
]; ];
const getAssigneeValue = () => { const getAssigneeValue = () => {
@@ -288,7 +316,7 @@ export default function Tickets() {
{ value: "me", label: "Assigned to me" }, { value: "me", label: "Assigned to me" },
...users ...users
.filter((x) => .filter((x) =>
["admin", "developer", "agent"].includes(x.type), ["admin", "developer", "agent"].includes(x.type)
) )
.map((u) => ({ .map((u) => ({
value: u.id, value: u.id,
@@ -300,7 +328,7 @@ export default function Tickets() {
onChange={(value) => onChange={(value) =>
value value
? setAssigneeFilter( ? setAssigneeFilter(
value.value === "me" ? user.id : value.value, value.value === "me" ? user.id : value.value
) )
: setAssigneeFilter(undefined) : setAssigneeFilter(undefined)
} }
@@ -308,6 +336,18 @@ export default function Tickets() {
isClearable isClearable
/> />
</div> </div>
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">
Source
</label>
<Select
options={SOURCE_OPTIONS}
disabled={user.type === "agent"}
value={SOURCE_OPTIONS.find((x) => x.value === sourceFilter)}
onChange={(value) => setSourceFilter(value?.value as Source)
}
/>
</div>
</div> </div>
<table className="bg-mti-purple-ultralight/40 w-full rounded-xl"> <table className="bg-mti-purple-ultralight/40 w-full rounded-xl">
@@ -320,7 +360,7 @@ export default function Tickets() {
? null ? null
: flexRender( : flexRender(
header.column.columnDef.header, header.column.columnDef.header,
header.getContext(), header.getContext()
)} )}
</th> </th>
))} ))}
@@ -332,7 +372,7 @@ export default function Tickets() {
<tr <tr
className={clsx( className={clsx(
"even:bg-mti-purple-ultralight/40 hover:bg-mti-purple-ultralight cursor-pointer rounded-lg py-2 odd:bg-white", "even:bg-mti-purple-ultralight/40 hover:bg-mti-purple-ultralight cursor-pointer rounded-lg py-2 odd:bg-white",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out"
)} )}
onClick={() => setSelectedTicket(row.original)} onClick={() => setSelectedTicket(row.original)}
key={row.id} key={row.id}
@@ -341,7 +381,7 @@ export default function Tickets() {
<td className="w-fit items-center px-4 py-2" key={cell.id}> <td className="w-fit items-center px-4 py-2" key={cell.id}>
{flexRender( {flexRender(
cell.column.columnDef.cell, cell.column.columnDef.cell,
cell.getContext(), cell.getContext()
)} )}
</td> </td>
))} ))}

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,11 +1,12 @@
import {collection, getDocs, query, where, setDoc, doc, Firestore} from "firebase/firestore"; import {collection, getDocs, query, where, setDoc, doc, Firestore, getDoc} from "firebase/firestore";
import {shuffle} from "lodash"; import {shuffle} from "lodash";
import {Exam, InstructorGender, Variant} from "@/interfaces/exam"; import {Difficulty, Exam, InstructorGender, SpeakingExam, Variant, WritingExam} from "@/interfaces/exam";
import {Stat} from "@/interfaces/user"; import {DeveloperUser, Stat, StudentUser, User} from "@/interfaces/user";
import {Module} from "@/interfaces";
export const getExams = async ( export const getExams = async (
db: Firestore, db: Firestore,
module: string, module: Module,
avoidRepeated: string, avoidRepeated: string,
// added userId as due to assignments being set from the teacher to the student // added userId as due to assignments being set from the teacher to the student
// we need to make sure we are serving exams not executed by the user and not // we need to make sure we are serving exams not executed by the user and not
@@ -27,8 +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);
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));
@@ -38,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 = genderedExams.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 : genderedExams; return filteredExams.length > 0 ? filteredExams : exams;
} }
return genderedExams; return exams;
}; };
const filterByInstructorGender = (exams: Exam[], instructorGender?: InstructorGender) => { const filterByInstructorGender = (exams: Exam[], instructorGender?: InstructorGender) => {
@@ -56,3 +59,37 @@ const filterByVariant = (exams: Exam[], variant?: Variant) => {
const filtered = variant && variant === "partial" ? exams.filter((x) => x.variant === "partial") : exams.filter((x) => x.variant !== "partial"); const filtered = variant && variant === "partial" ? exams.filter((x) => x.variant === "partial") : exams.filter((x) => x.variant !== "partial");
return filtered.length > 0 ? filtered : exams; return filtered.length > 0 ? filtered : exams;
}; };
const filterByDifficulty = async (db: Firestore, exams: Exam[], module: Module, userID?: string) => {
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 User;
const difficulty = user.levels[module] <= 3 ? "easy" : user.levels[module] <= 6 ? "medium" : "hard";
const filteredExams = exams.filter((exam) => exam.difficulty === difficulty);
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;
};

View File

@@ -163,3 +163,14 @@ export const getLevelScore = (level: number) => {
return []; return [];
} }
}; };
export const getLevelLabel = (level: number) => {
if (level < 2) return ["Foundation", "Pre-A1"];
if (level < 4) return ["Elementary", "A1"];
if (level < 5) return ["Pre-intermediate", "A2"];
if (level < 6) return ["Intermediate", "B1"];
if (level < 7) return ["Upper Intermediate", "B2"];
if (level < 8) return ["Advanced", "C1"];
return ["Proficiency", "C2"];
};