Merge branch 'develop'

This commit is contained in:
Tiago Ribeiro
2024-01-31 11:46:07 +00:00
45 changed files with 5570 additions and 2711 deletions

View File

@@ -0,0 +1,254 @@
import useUsers from "@/hooks/useUsers";
import {
Ticket,
TicketStatus,
TicketStatusLabel,
TicketType,
TicketTypeLabel,
} from "@/interfaces/ticket";
import { User } from "@/interfaces/user";
import { USER_TYPE_LABELS } from "@/resources/user";
import axios from "axios";
import moment from "moment";
import { useState } from "react";
import { toast } from "react-toastify";
import ShortUniqueId from "short-unique-id";
import Button from "../Low/Button";
import Input from "../Low/Input";
import Select from "../Low/Select";
interface Props {
user: User;
ticket: Ticket;
onClose: () => void;
}
export default function TicketDisplay({ user, ticket, onClose }: Props) {
const [subject] = useState(ticket.subject);
const [type, setType] = useState<TicketType>(ticket.type);
const [description] = useState(ticket.description);
const [reporter] = useState(ticket.reporter);
const [reportedFrom] = useState(ticket.reportedFrom);
const [status, setStatus] = useState(ticket.status);
const [assignedTo, setAssignedTo] = useState<string | null>(
ticket.assignedTo || null,
);
const [isLoading, setIsLoading] = useState(false);
const { users } = useUsers();
const submit = () => {
if (!type)
return toast.error("Please choose a type!", { toastId: "missing-type" });
setIsLoading(true);
axios
.patch(`/api/tickets/${ticket.id}`, {
subject,
type,
description,
reporter,
reportedFrom,
status,
assignedTo,
})
.then(() => {
toast.success(`The ticket has been updated!`, { toastId: "submitted" });
onClose();
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong, please try again later!", {
toastId: "error",
});
})
.finally(() => setIsLoading(false));
};
const del = () => {
if (!confirm("Are you sure you want to delete this ticket?")) return;
setIsLoading(true);
axios
.delete(`/api/tickets/${ticket.id}`)
.then(() => {
toast.success(`The ticket has been deleted!`, { toastId: "submitted" });
onClose();
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong, please try again later!", {
toastId: "error",
});
})
.finally(() => setIsLoading(false));
};
return (
<form className="flex flex-col gap-4 pt-8">
<Input
label="Subject"
type="text"
name="subject"
placeholder="Subject..."
value={subject}
onChange={(e) => null}
disabled
/>
<div className="-md:flex-col flex w-full items-center gap-4">
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">
Status
</label>
<Select
options={Object.keys(TicketStatusLabel).map((x) => ({
value: x,
label: TicketStatusLabel[x as keyof typeof TicketStatusLabel],
}))}
value={{ value: status, label: TicketStatusLabel[status] }}
onChange={(value) =>
setStatus((value?.value as TicketStatus) ?? undefined)
}
placeholder="Status..."
/>
</div>
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">
Type
</label>
<Select
options={Object.keys(TicketTypeLabel).map((x) => ({
value: x,
label: TicketTypeLabel[x as keyof typeof TicketTypeLabel],
}))}
value={{ value: type, label: TicketTypeLabel[type] }}
onChange={(value) => setType(value!.value as TicketType)}
placeholder="Type..."
/>
</div>
</div>
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">
Assignee
</label>
<Select
options={[
{ value: "me", label: "Assign to me" },
...users
.filter((x) => ["admin", "developer"].includes(x.type))
.map((u) => ({
value: u.id,
label: `${u.name} - ${u.email}`,
})),
]}
disabled={user.type === "agent"}
value={
assignedTo
? {
value: assignedTo,
label: `${users.find((u) => u.id === assignedTo)?.name} - ${users.find((u) => u.id === assignedTo)?.email}`,
}
: null
}
onChange={(value) =>
value
? setAssignedTo(value.value === "me" ? user.id : value.value)
: setAssignedTo(null)
}
placeholder="Assignee..."
isClearable
/>
</div>
<div className="-md:flex-col flex w-full items-center gap-4">
<Input
label="Reported From"
type="text"
name="reportedFrom"
onChange={() => null}
value={reportedFrom}
disabled
/>
<Input
label="Date"
type="text"
name="date"
onChange={() => null}
value={moment(ticket.date).format("DD/MM/YYYY - HH:mm")}
disabled
/>
</div>
<div className="-md:flex-col flex w-full items-center gap-4">
<Input
label="Reporter's Name"
type="text"
name="reporter"
onChange={() => null}
value={reporter.name}
disabled
/>
<Input
label="Reporter's E-mail"
type="text"
name="reporter"
onChange={() => null}
value={reporter.email}
disabled
/>
<Input
label="Reporter's Type"
type="text"
name="reporterType"
onChange={() => null}
value={USER_TYPE_LABELS[reporter.type]}
disabled
/>
</div>
<textarea
className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8"
placeholder="Write your ticket's description here..."
contentEditable={false}
value={description}
spellCheck
/>
<div className="-md:flex-col-reverse mt-2 flex w-full items-center justify-between gap-4">
<Button
type="button"
color="red"
className="w-full md:max-w-[200px]"
variant="outline"
onClick={del}
isLoading={isLoading}
>
Delete
</Button>
<div className="-md:flex-col-reverse flex w-full items-center justify-end gap-4">
<Button
type="button"
color="red"
className="w-full md:max-w-[200px]"
variant="outline"
onClick={onClose}
isLoading={isLoading}
>
Cancel
</Button>
<Button
type="button"
className="w-full md:max-w-[200px]"
isLoading={isLoading}
onClick={submit}
>
Update
</Button>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,134 @@
import { Ticket, TicketType, TicketTypeLabel } from "@/interfaces/ticket";
import { User } from "@/interfaces/user";
import axios from "axios";
import { useState } from "react";
import { toast } from "react-toastify";
import ShortUniqueId from "short-unique-id";
import Button from "../Low/Button";
import Input from "../Low/Input";
import Select from "../Low/Select";
interface Props {
user: User;
page: string;
onClose: () => void;
}
export default function TicketSubmission({ user, page, onClose }: Props) {
const [subject, setSubject] = useState("");
const [type, setType] = useState<TicketType>();
const [description, setDescription] = useState("");
const [isLoading, setIsLoading] = useState(false);
const submit = () => {
if (!type)
return toast.error("Please choose a type!", { toastId: "missing-type" });
if (subject.trim() === "")
return toast.error("Please input a subject!", {
toastId: "missing-subject",
});
if (description.trim() === "")
return toast.error("Please describe your ticket!", {
toastId: "missing-desc",
});
setIsLoading(true);
const shortUID = new ShortUniqueId();
const ticket: Ticket = {
id: shortUID.randomUUID(8),
date: new Date().toISOString(),
reporter: {
id: user.id,
email: user.email,
name: user.name,
type: user.type,
},
status: "submitted",
subject,
type,
reportedFrom: page,
description,
};
axios
.post(`/api/tickets`, ticket)
.then(() => {
toast.success(
`Your ticket has been submitted! You will be contacted by e-mail for further discussion.`,
{ toastId: "submitted" },
);
onClose();
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong, please try again later!", {
toastId: "error",
});
})
.finally(() => setIsLoading(false));
};
return (
<form className="flex flex-col gap-4 pt-8">
<Input
label="Subject"
type="text"
name="subject"
placeholder="Subject..."
onChange={(e) => setSubject(e)}
/>
<div className="-md:flex-col flex w-full items-center gap-4">
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">
Type
</label>
<Select
options={Object.keys(TicketTypeLabel).map((x) => ({
value: x,
label: TicketTypeLabel[x as keyof typeof TicketTypeLabel],
}))}
onChange={(value) =>
setType((value?.value as TicketType) ?? undefined)
}
placeholder="Type..."
/>
</div>
<Input
label="Reporter"
type="text"
name="reporter"
onChange={() => null}
value={`${user.name} - ${user.email}`}
disabled
/>
</div>
<textarea
className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8"
onChange={(e) => setDescription(e.target.value)}
placeholder="Write your ticket's description here..."
spellCheck
/>
<div className="mt-2 flex w-full items-center justify-end gap-4">
<Button
type="button"
color="red"
className="w-full max-w-[200px]"
variant="outline"
onClick={onClose}
isLoading={isLoading}
>
Cancel
</Button>
<Button
type="button"
className="w-full max-w-[200px]"
isLoading={isLoading}
onClick={submit}
>
Submit
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,68 @@
import clsx from "clsx";
import { ComponentProps } from "react";
import ReactSelect from "react-select";
interface Option {
[key: string]: any;
value: string;
label: string;
}
interface Props {
defaultValue?: Option;
value?: Option | null;
options: Option[];
disabled?: boolean;
placeholder?: string;
onChange: (value: Option | null) => void;
isClearable?: boolean;
}
export default function Select({
value,
defaultValue,
options,
placeholder,
disabled,
onChange,
isClearable,
}: Props) {
return (
<ReactSelect
className={clsx(
"placeholder:text-mti-gray-cool border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none",
disabled &&
"!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
)}
options={options}
value={value}
onChange={onChange}
placeholder={placeholder}
menuPortalTarget={document?.body}
defaultValue={defaultValue}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
control: (styles) => ({
...styles,
paddingLeft: "4px",
border: "none",
outline: "none",
":focus": {
outline: "none",
},
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused
? "#D5D9F0"
: state.isSelected
? "#7872BF"
: "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
isDisabled={disabled}
isClearable={isClearable}
/>
);
}

View File

@@ -1,149 +1,201 @@
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 {BsShield, BsShieldFill, 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;
} }
export default function MobileMenu({isOpen, onClose, path, user}: Props) { export default function MobileMenu({ isOpen, onClose, path, user }: 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" /> >
</Transition.Child> <div className="fixed inset-0 bg-black bg-opacity-25" />
</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="w-full h-screen transform overflow-hidden bg-white text-left align-middle shadow-xl transition-all text-black flex flex-col gap-8"> >
<Dialog.Title as="header" className="w-full px-8 py-2 -md:flex justify-between items-center md:hidden shadow-sm"> <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">
<Link href="/"> <Dialog.Title
<Image src="/logo_title.png" alt="EnCoach logo" width={69} height={69} /> as="header"
</Link> className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden"
<div className="cursor-pointer" onClick={onClose} tabIndex={0}> >
<BsXLg className="text-2xl text-mti-purple-light" onClick={onClose} /> <Link href="/">
</div> <Image
</Dialog.Title> src="/logo_title.png"
<div className="flex flex-col h-full gap-6 px-8 text-lg"> alt="EnCoach logo"
<Link width={69}
href="/" height={69}
className={clsx( />
"transition ease-in-out duration-300 w-fit", </Link>
path === "/" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ", <div
)}> className="cursor-pointer"
Dashboard onClick={onClose}
</Link> tabIndex={0}
{(user.type === "student" || user.type === "teacher" || user.type === "developer") && ( >
<> <BsXLg
<Link className="text-mti-purple-light text-2xl"
href="/exam" onClick={onClose}
className={clsx( />
"transition ease-in-out duration-300 w-fit", </div>
path === "/exam" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ", </Dialog.Title>
)}> <div className="flex h-full flex-col gap-6 px-8 text-lg">
Exams <Link
</Link> href="/"
<Link className={clsx(
href="/exercises" "w-fit transition duration-300 ease-in-out",
className={clsx( path === "/" &&
"transition ease-in-out duration-300 w-fit", "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
path === "/exercises" && )}
"text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ", >
)}> Dashboard
Exercises </Link>
</Link> {(user.type === "student" ||
</> user.type === "teacher" ||
)} user.type === "developer") && (
<Link <>
href="/stats" <Link
className={clsx( href="/exam"
"transition ease-in-out duration-300 w-fit", className={clsx(
path === "/stats" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ", "w-fit transition duration-300 ease-in-out",
)}> path === "/exam" &&
Stats "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
</Link> )}
<Link >
href="/record" Exams
className={clsx( </Link>
"transition ease-in-out duration-300 w-fit", <Link
path === "/record" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ", href="/exercises"
)}> className={clsx(
Record "w-fit transition duration-300 ease-in-out",
</Link> path === "/exercises" &&
{["admin", "developer", "agent", "corporate"].includes(user.type) && ( "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
<Link )}
href="/payment-record" >
className={clsx( Exercises
"transition ease-in-out duration-300 w-fit", </Link>
path === "/payment-record" && </>
"text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ", )}
)}> <Link
Payment Record href="/stats"
</Link> className={clsx(
)} "w-fit transition duration-300 ease-in-out",
{["admin", "developer", "corporate", "teacher"].includes(user.type) && ( path === "/stats" &&
<Link "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
href="/settings" )}
className={clsx( >
"transition ease-in-out duration-300 w-fit", Stats
path === "/settings" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ", </Link>
)}> <Link
Settings href="/record"
</Link> className={clsx(
)} "w-fit transition duration-300 ease-in-out",
<Link path === "/record" &&
href="/profile" "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
className={clsx( )}
"transition ease-in-out duration-300 w-fit", >
path === "/profile" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ", Record
)}> </Link>
Profile {["admin", "developer", "agent", "corporate"].includes(
</Link> user.type,
) && (
<Link
href="/payment-record"
className={clsx(
"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 ",
)}
>
Payment Record
</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("transition ease-in-out duration-300 w-fit justify-self-end cursor-pointer")} className={clsx(
onClick={logout}> "w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out",
Logout )}
</span> onClick={logout}
</div> >
</Dialog.Panel> Logout
</Transition.Child> </span>
</div> </div>
</div> </Dialog.Panel>
</Dialog> </Transition.Child>
</Transition> </div>
); </div>
</Dialog>
</Transition>
);
} }

View File

@@ -1,95 +1,161 @@
import {User} from "@/interfaces/user"; import { User } from "@/interfaces/user";
import Link from "next/link"; import Link from "next/link";
import FocusLayer from "@/components/FocusLayer"; import FocusLayer from "@/components/FocusLayer";
import {preventNavigation} from "@/utils/navigation.disabled"; import { preventNavigation } from "@/utils/navigation.disabled";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import {BsList} from "react-icons/bs"; import { BsList, BsQuestionCircle, BsQuestionCircleFill } from "react-icons/bs";
import clsx from "clsx"; import clsx from "clsx";
import moment from "moment"; import moment from "moment";
import MobileMenu from "./MobileMenu"; import MobileMenu from "./MobileMenu";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import {Type} from "@/interfaces/user"; import { Type } from "@/interfaces/user";
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 {isUserFromCorporate} from "@/utils/groups"; import { isUserFromCorporate } from "@/utils/groups";
import Button from "./Low/Button";
import Modal from "./Modal";
import Input from "./Low/Input";
import TicketSubmission from "./High/TicketSubmission";
interface Props { interface Props {
user: User; user: User;
navDisabled?: boolean; navDisabled?: boolean;
focusMode?: boolean; focusMode?: boolean;
onFocusLayerMouseEnter?: () => void; onFocusLayerMouseEnter?: () => void;
path: string; path: string;
} }
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
export default function Navbar({user, path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) { export default function Navbar({
const [isMenuOpen, setIsMenuOpen] = useState(false); user,
const [disablePaymentPage, setDisablePaymentPage] = useState(true); path,
navDisabled = false,
focusMode = false,
onFocusLayerMouseEnter,
}: Props) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
const [isTicketOpen, setIsTicketOpen] = useState(false);
const disableNavigation = preventNavigation(navDisabled, focusMode); const disableNavigation = preventNavigation(navDisabled, focusMode);
const router = useRouter();
const expirationDateColor = (date: Date) => { const expirationDateColor = (date: Date) => {
const momentDate = moment(date); const momentDate = moment(date);
const today = moment(new Date()); const today = moment(new Date());
if (today.add(1, "days").isAfter(momentDate)) return "!bg-mti-red-ultralight border-mti-red-light"; if (today.add(1, "days").isAfter(momentDate))
if (today.add(3, "days").isAfter(momentDate)) return "!bg-mti-rose-ultralight border-mti-rose-light"; return "!bg-mti-red-ultralight border-mti-red-light";
if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light"; if (today.add(3, "days").isAfter(momentDate))
}; return "!bg-mti-rose-ultralight border-mti-rose-light";
if (today.add(7, "days").isAfter(momentDate))
return "!bg-mti-orange-ultralight border-mti-orange-light";
};
const showExpirationDate = () => { const showExpirationDate = () => {
if (!user.subscriptionExpirationDate) return false; if (!user.subscriptionExpirationDate) return false;
const momentDate = moment(user.subscriptionExpirationDate); const momentDate = moment(user.subscriptionExpirationDate);
const today = moment(new Date()); const today = moment(new Date());
return today.add(7, "days").isAfter(momentDate); return today.add(7, "days").isAfter(momentDate);
}; };
useEffect(() => { useEffect(() => {
if (user.type !== "student" && user.type !== "teacher") setDisablePaymentPage(false); if (user.type !== "student" && user.type !== "teacher")
isUserFromCorporate(user.id).then((result) => setDisablePaymentPage(result)); setDisablePaymentPage(false);
}, [user]); isUserFromCorporate(user.id).then((result) =>
setDisablePaymentPage(result),
);
}, [user]);
return ( return (
<> <>
{user && <MobileMenu path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />} <Modal
<header className="w-full bg-transparent py-2 md:py-4 -md:justify-between md:gap-12 flex items-center relative -md:px-4"> isOpen={isTicketOpen}
<Link href={disableNavigation ? "" : "/"} className=" md:px-8 flex gap-8 items-center"> onClose={() => setIsTicketOpen(false)}
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" /> title="Submit a ticket"
<h1 className="font-bold text-2xl w-1/6 -md:hidden">EnCoach</h1> >
</Link> <TicketSubmission
<div className="flex justify-end -md:items-center gap-4 md:w-5/6 md:mr-8"> user={user}
{showExpirationDate() && ( page={window.location.href}
<Link onClose={() => setIsTicketOpen(false)}
href={disablePaymentPage ? "/payment" : ""} />
data-tip="Expiry date" </Modal>
className={clsx(
"py-2 px-6 w-fit flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", {user && (
"transition duration-300 ease-in-out tooltip tooltip-bottom", <MobileMenu
!user.subscriptionExpirationDate path={path}
? "bg-mti-green-ultralight border-mti-green-light" isOpen={isMenuOpen}
: expirationDateColor(user.subscriptionExpirationDate), onClose={() => setIsMenuOpen(false)}
"bg-white border-mti-gray-platinum", user={user}
)}> />
{!user.subscriptionExpirationDate && "Unlimited"} )}
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")} <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> <Link
)} href={disableNavigation ? "" : "/"}
<Link href={disableNavigation ? "" : "/profile"} className="flex gap-6 items-center justify-end -md:hidden"> className=" flex items-center gap-8 md:px-8"
<img src={user.profilePicture} alt={user.name} className="w-10 h-10 rounded-full object-cover" /> >
<span className="text-right -md:hidden"> <img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" />
{user.type === "corporate" ? `${user.corporateInformation?.companyInformation.name} |` : ""} {user.name} |{" "} <h1 className="-md:hidden w-1/6 text-2xl font-bold">EnCoach</h1>
{USER_TYPE_LABELS[user.type]} </Link>
</span> <div className="flex items-center justify-end gap-4 md:mr-8 md:w-5/6">
</Link> {/* OPEN TICKET SYSTEM */}
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}> <button
<BsList className="text-mti-purple-light w-8 h-8" /> className={clsx(
</div> "border-mti-purple-light tooltip tooltip-bottom flex h-8 w-8 flex-col items-center justify-center rounded-full border p-1",
</div> "hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white",
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />} )}
</header> data-tip="Submit a help/feedback ticket"
</> onClick={() => setIsTicketOpen(true)}
); >
<BsQuestionCircleFill />
</button>
{showExpirationDate() && (
<Link
href={disablePaymentPage ? "/payment" : ""}
data-tip="Expiry date"
className={clsx(
"flex w-fit cursor-pointer justify-center rounded-full border px-6 py-2 text-sm font-normal focus:outline-none",
"tooltip tooltip-bottom transition duration-300 ease-in-out",
!user.subscriptionExpirationDate
? "bg-mti-green-ultralight border-mti-green-light"
: expirationDateColor(user.subscriptionExpirationDate),
"border-mti-gray-platinum bg-white",
)}
>
{!user.subscriptionExpirationDate && "Unlimited"}
{user.subscriptionExpirationDate &&
moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
</Link>
)}
<Link
href={disableNavigation ? "" : "/profile"}
className="-md:hidden flex items-center justify-end gap-6"
>
<img
src={user.profilePicture}
alt={user.name}
className="h-10 w-10 rounded-full object-cover"
/>
<span className="-md:hidden text-right">
{user.type === "corporate"
? `${user.corporateInformation?.companyInformation.name} |`
: ""}{" "}
{user.name} | {USER_TYPE_LABELS[user.type]}
</span>
</Link>
<div
className="cursor-pointer md:hidden"
onClick={() => setIsMenuOpen(true)}
>
<BsList className="text-mti-purple-light h-8 w-8" />
</div>
</div>
{focusMode && (
<FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
)}
</header>
</>
);
} }

View File

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

@@ -1,79 +1,115 @@
import ProgressBar from "@/components/Low/ProgressBar"; import ProgressBar from "@/components/Low/ProgressBar";
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 {calculateBandScore} from "@/utils/score"; import { calculateBandScore } from "@/utils/score";
import clsx from "clsx"; import clsx from "clsx";
import moment from "moment"; import moment from "moment";
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs"; import {
BsBook,
BsClipboard,
BsHeadphones,
BsMegaphone,
BsPen,
} from "react-icons/bs";
import { usePDFDownload } from "@/hooks/usePDFDownload"; import { usePDFDownload } from "@/hooks/usePDFDownload";
import { uniqBy } from "lodash";
interface Props { interface Props {
onClick?: () => void; onClick?: () => void;
allowDownload?: boolean; allowDownload?: boolean;
} }
export default function AssignmentCard({id, name, assigner, startDate, endDate, assignees, results, exams, onClick, allowDownload}: Assignment & Props) { export default function AssignmentCard({
const {users} = useUsers(); id,
const renderPdfIcon = usePDFDownload("assignments"); name,
assigner,
startDate,
endDate,
assignees,
results,
exams,
onClick,
allowDownload,
}: Assignment & Props) {
const { users } = useUsers();
const renderPdfIcon = usePDFDownload("assignments");
const calculateAverageModuleScore = (module: Module) => { const calculateAverageModuleScore = (module: Module) => {
const resultModuleBandScores = results.map((r) => { const resultModuleBandScores = results.map((r) => {
const moduleStats = r.stats.filter((s) => s.module === module); const moduleStats = r.stats.filter((s) => s.module === module);
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0); const correct = moduleStats.reduce(
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0); (acc, curr) => acc + curr.score.correct,
return calculateBandScore(correct, total, module, r.type); 0,
}); );
const total = moduleStats.reduce(
(acc, curr) => acc + curr.score.total,
0,
);
return calculateBandScore(correct, total, module, r.type);
});
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / results.length; return resultModuleBandScores.length === 0
}; ? -1
: resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
results.length;
};
return ( return (
<div <div
onClick={onClick} onClick={onClick}
className="w-[350px] h-fit flex flex-col gap-6 bg-white border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300"> className="border-mti-gray-platinum flex h-fit w-[350px] cursor-pointer flex-col gap-6 rounded-xl border bg-white p-4 transition duration-300 ease-in-out hover:drop-shadow"
<div className="flex flex-col gap-3"> >
<div className="flex flex-row justify-between"> <div className="flex flex-col gap-3">
<h3 className="font-semibold text-xl">{name}</h3> <div className="flex flex-row justify-between">
{allowDownload && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")} <h3 className="text-xl font-semibold">{name}</h3>
</div> {allowDownload &&
<ProgressBar renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
color={results.length / assignees.length < 0.5 ? "red" : "purple"} </div>
percentage={(results.length / assignees.length) * 100} <ProgressBar
label={`${results.length}/${assignees.length}`} color={results.length / assignees.length < 0.5 ? "red" : "purple"}
className="h-5" percentage={(results.length / assignees.length) * 100}
textClassName={results.length / assignees.length < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"} label={`${results.length}/${assignees.length}`}
/> className="h-5"
</div> textClassName={
<span className="flex gap-1 justify-between"> results.length / assignees.length < 0.5
<span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span> ? "!text-mti-gray-dim font-light"
<span>-</span> : "text-white"
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span> }
</span> />
<div className="grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2"> </div>
{exams.map(({module}) => ( <span className="flex justify-between gap-1">
<div <span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span>
key={module} <span>-</span>
className={clsx( <span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl", </span>
module === "reading" && "bg-ielts-reading", <div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
module === "listening" && "bg-ielts-listening", {uniqBy(exams, (x) => x.module).map(({ module }) => (
module === "writing" && "bg-ielts-writing", <div
module === "speaking" && "bg-ielts-speaking", key={module}
module === "level" && "bg-ielts-level", className={clsx(
)}> "-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
{module === "reading" && <BsBook className="w-4 h-4" />} module === "reading" && "bg-ielts-reading",
{module === "listening" && <BsHeadphones className="w-4 h-4" />} module === "listening" && "bg-ielts-listening",
{module === "writing" && <BsPen className="w-4 h-4" />} module === "writing" && "bg-ielts-writing",
{module === "speaking" && <BsMegaphone className="w-4 h-4" />} module === "speaking" && "bg-ielts-speaking",
{module === "level" && <BsClipboard className="w-4 h-4" />} module === "level" && "bg-ielts-level",
{calculateAverageModuleScore(module) > -1 && ( )}
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span> >
)} {module === "reading" && <BsBook className="h-4 w-4" />}
</div> {module === "listening" && <BsHeadphones className="h-4 w-4" />}
))} {module === "writing" && <BsPen className="h-4 w-4" />}
</div> {module === "speaking" && <BsMegaphone className="h-4 w-4" />}
</div> {module === "level" && <BsClipboard className="h-4 w-4" />}
); {calculateAverageModuleScore(module) > -1 && (
<span className="text-sm">
{calculateAverageModuleScore(module).toFixed(1)}
</span>
)}
</div>
))}
</div>
</div>
);
} }

View File

@@ -1,280 +1,354 @@
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 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 {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs"; import {
BsBook,
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 formatTimestamp = (timestamp: string) => {
const date = moment(parseInt(timestamp)); const date = moment(parseInt(timestamp));
const formatter = "YYYY/MM/DD - HH:mm"; const formatter = "YYYY/MM/DD - HH:mm";
return date.format(formatter); return date.format(formatter);
}; };
const calculateAverageModuleScore = (module: Module) => { const calculateAverageModuleScore = (module: Module) => {
if (!assignment) return -1; if (!assignment) return -1;
const resultModuleBandScores = assignment.results.map((r) => { const resultModuleBandScores = assignment.results.map((r) => {
const moduleStats = r.stats.filter((s) => s.module === module); const moduleStats = r.stats.filter((s) => s.module === module);
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0); const correct = moduleStats.reduce(
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0); (acc, curr) => acc + curr.score.correct,
return calculateBandScore(correct, total, module, r.type); 0,
}); );
const total = moduleStats.reduce(
(acc, curr) => acc + curr.score.total,
0,
);
return calculateBandScore(correct, total, module, r.type);
});
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length; return resultModuleBandScores.length === 0
}; ? -1
: resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
assignment.results.length;
};
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => { const aggregateScoresByModule = (
const scores: {[key in Module]: {total: number; missing: number; correct: number}} = { stats: Stat[],
reading: { ): { module: Module; total: number; missing: number; correct: number }[] => {
total: 0, const scores: {
correct: 0, [key in Module]: { total: number; missing: number; correct: number };
missing: 0, } = {
}, reading: {
listening: { total: 0,
total: 0, correct: 0,
correct: 0, missing: 0,
missing: 0, },
}, listening: {
writing: { total: 0,
total: 0, correct: 0,
correct: 0, missing: 0,
missing: 0, },
}, writing: {
speaking: { total: 0,
total: 0, correct: 0,
correct: 0, missing: 0,
missing: 0, },
}, speaking: {
level: { total: 0,
total: 0, correct: 0,
correct: 0, missing: 0,
missing: 0, },
}, level: {
}; total: 0,
correct: 0,
missing: 0,
},
};
stats.forEach((x) => { stats.forEach((x) => {
scores[x.module!] = { scores[x.module!] = {
total: scores[x.module!].total + x.score.total, total: scores[x.module!].total + x.score.total,
correct: scores[x.module!].correct + x.score.correct, correct: scores[x.module!].correct + x.score.correct,
missing: scores[x.module!].missing + x.score.missing, missing: scores[x.module!].missing + x.score.missing,
}; };
}); });
return Object.keys(scores) return Object.keys(scores)
.filter((x) => scores[x as Module].total > 0) .filter((x) => scores[x as Module].total > 0)
.map((x) => ({module: x as Module, ...scores[x as Module]})); .map((x) => ({ module: x as Module, ...scores[x as Module] }));
}; };
const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => { const customContent = (
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0); stats: Stat[],
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0); user: string,
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0); 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 aggregatedLevels = aggregatedScores.map((x) => ({ const aggregatedLevels = aggregatedScores.map((x) => ({
module: x.module, module: x.module,
level: calculateBandScore(x.correct, x.total, x.module, focus), level: calculateBandScore(x.correct, x.total, x.module, focus),
})); }));
const timeSpent = stats[0].timeSpent; const timeSpent = stats[0].timeSpent;
const selectExam = () => { const selectExam = () => {
const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam)); const examPromises = uniqBy(stats, "exam").map((stat) =>
getExamById(stat.module, stat.exam),
);
Promise.all(examPromises).then((exams) => { Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) { if (exams.every((x) => !!x)) {
setUserSolutions(convertToUserSolutions(stats)); setUserSolutions(convertToUserSolutions(stats));
setShowSolutions(true); setShowSolutions(true);
setExams(exams.map((x) => x!).sort(sortByModule)); setExams(exams.map((x) => x!).sort(sortByModule));
setSelectedModules( setSelectedModules(
exams exams
.map((x) => x!) .map((x) => x!)
.sort(sortByModule) .sort(sortByModule)
.map((x) => x!.module), .map((x) => x!.module),
); );
router.push("/exercises"); router.push("/exercises");
} }
}); });
}; };
const content = ( const content = (
<> <>
<div className="w-full flex justify-between -md:items-center 2xl:items-center"> <div className="-md:items-center flex w-full justify-between 2xl:items-center">
<div className="flex md:flex-col 2xl:flex-row md:gap-1 -md:gap-2 2xl:gap-2 -md:items-center 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> <span className="font-medium">
{timeSpent && ( {formatTimestamp(stats[0].date.toString())}
<> </span>
<span className="md:hidden 2xl:flex"> </span> {timeSpent && (
<span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span> <>
</> <span className="md:hidden 2xl:flex"> </span>
)} <span className="text-sm">
</div> {Math.floor(timeSpent / 60)} minutes
<span </span>
className={clsx( </>
correct / total >= 0.7 && "text-mti-purple", )}
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red", </div>
correct / total < 0.3 && "text-mti-rose", <span
)}> className={clsx(
Level{" "} correct / total >= 0.7 && "text-mti-purple",
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)} correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
</span> correct / total < 0.3 && "text-mti-rose",
</div> )}
>
Level{" "}
{(
aggregatedLevels.reduce(
(accumulator, current) => accumulator + current.level,
0,
) / aggregatedLevels.length
).toFixed(1)}
</span>
</div>
<div className="w-full flex flex-col gap-1"> <div className="flex w-full flex-col gap-1">
<div className="grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2"> <div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
{aggregatedLevels.map(({module, level}) => ( {aggregatedLevels.map(({ module, level }) => (
<div <div
key={module} key={module}
className={clsx( className={clsx(
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl", "-md:px-4 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 === "reading" && "bg-ielts-reading",
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="w-4 h-4" />} >
{module === "listening" && <BsHeadphones className="w-4 h-4" />} {module === "reading" && <BsBook className="h-4 w-4" />}
{module === "writing" && <BsPen className="w-4 h-4" />} {module === "listening" && <BsHeadphones className="h-4 w-4" />}
{module === "speaking" && <BsMegaphone className="w-4 h-4" />} {module === "writing" && <BsPen className="h-4 w-4" />}
{module === "level" && <BsClipboard className="w-4 h-4" />} {module === "speaking" && <BsMegaphone className="h-4 w-4" />}
<span className="text-sm">{level.toFixed(1)}</span> {module === "level" && <BsClipboard className="h-4 w-4" />}
</div> <span className="text-sm">{level.toFixed(1)}</span>
))} </div>
</div> ))}
</div> </div>
</> </div>
); </>
);
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span> <span>
{(() => { {(() => {
const student = users.find((u) => u.id === user); const student = users.find((u) => u.id === user);
return `${student?.name} (${student?.email})`; return `${student?.name} (${student?.email})`;
})()} })()}
</span> </span>
<div <div
key={user} key={user}
className={clsx( className={clsx(
"flex flex-col gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:hidden", "border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
correct / total >= 0.7 && "hover:border-mti-purple", correct / total >= 0.7 && "hover:border-mti-purple",
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red", correct / total >= 0.3 &&
correct / total < 0.3 && "hover:border-mti-rose", correct / total < 0.7 &&
)} "hover:border-mti-red",
onClick={selectExam} correct / total < 0.3 && "hover:border-mti-rose",
role="button"> )}
{content} onClick={selectExam}
</div> role="button"
<div >
key={user} {content}
className={clsx( </div>
"flex flex-col gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:tooltip md:hidden", <div
correct / total >= 0.7 && "hover:border-mti-purple", key={user}
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red", className={clsx(
correct / total < 0.3 && "hover:border-mti-rose", "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",
data-tip="Your screen size is too small to view previous exams." correct / total >= 0.3 &&
role="button"> correct / total < 0.7 &&
{content} "hover:border-mti-red",
</div> correct / total < 0.3 && "hover:border-mti-rose",
</div> )}
); data-tip="Your screen size is too small to view previous exams."
}; role="button"
>
{content}
</div>
</div>
);
};
return ( return (
<Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}> <Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}>
<div className="mt-4 flex flex-col w-full gap-4"> <div className="mt-4 flex w-full flex-col gap-4">
<ProgressBar <ProgressBar
color="purple" color="purple"
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`} label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
className="h-6" className="h-6"
textClassName={ textClassName={
(assignment?.results.length || 0) / (assignment?.assignees.length || 1) < 0.5 ? "!text-mti-gray-dim font-light" : "text-white" (assignment?.results.length || 0) /
} (assignment?.assignees.length || 1) <
percentage={((assignment?.results.length || 0) / (assignment?.assignees.length || 1)) * 100} 0.5
/> ? "!text-mti-gray-dim font-light"
<div className="flex gap-8 items-start"> : "text-white"
<div className="flex flex-col gap-2"> }
<span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span> percentage={
<span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span> ((assignment?.results.length || 0) /
</div> (assignment?.assignees.length || 1)) *
<span> 100
Assignees:{" "} }
{users />
.filter((u) => assignment?.assignees.includes(u.id)) <div className="flex items-start gap-8">
.map((u) => `${u.name} (${u.email})`) <div className="flex flex-col gap-2">
.join(", ")} <span>
</span> Start Date:{" "}
</div> {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}
<div className="flex flex-col gap-2"> </span>
<span className="text-xl font-bold">Average Scores</span> <span>
<div className="grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2"> End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}
{assignment?.exams.map(({module}) => ( </span>
<div </div>
data-tip={capitalize(module)} <span>
key={module} Assignees:{" "}
className={clsx( {users
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl tooltip", .filter((u) => assignment?.assignees.includes(u.id))
module === "reading" && "bg-ielts-reading", .map((u) => `${u.name} (${u.email})`)
module === "listening" && "bg-ielts-listening", .join(", ")}
module === "writing" && "bg-ielts-writing", </span>
module === "speaking" && "bg-ielts-speaking", </div>
module === "level" && "bg-ielts-level", <div className="flex flex-col gap-2">
)}> <span className="text-xl font-bold">Average Scores</span>
{module === "reading" && <BsBook className="w-4 h-4" />} <div className="-md:mt-2 flex w-full items-center gap-4">
{module === "listening" && <BsHeadphones className="w-4 h-4" />} {assignment &&
{module === "writing" && <BsPen className="w-4 h-4" />} uniqBy(assignment.exams, (x) => x.module).map(({ module }) => (
{module === "speaking" && <BsMegaphone className="w-4 h-4" />} <div
{module === "level" && <BsClipboard className="w-4 h-4" />} data-tip={capitalize(module)}
{calculateAverageModuleScore(module) > -1 && ( key={module}
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span> 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",
</div> module === "reading" && "bg-ielts-reading",
))} module === "listening" && "bg-ielts-listening",
</div> module === "writing" && "bg-ielts-writing",
</div> module === "speaking" && "bg-ielts-speaking",
<div className="flex flex-col gap-2"> module === "level" && "bg-ielts-level",
<span className="text-xl font-bold"> )}
Results ({assignment?.results.length}/{assignment?.assignees.length}) >
</span> {module === "reading" && <BsBook className="h-4 w-4" />}
<div> {module === "listening" && (
{assignment && assignment?.results.length > 0 && ( <BsHeadphones className="h-4 w-4" />
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 w-full gap-4 xl:gap-6"> )}
{assignment.results.map((r) => customContent(r.stats, r.user, r.type))} {module === "writing" && <BsPen className="h-4 w-4" />}
</div> {module === "speaking" && <BsMegaphone className="h-4 w-4" />}
)} {module === "level" && <BsClipboard className="h-4 w-4" />}
{assignment && assignment?.results.length === 0 && <span className="font-semibold ml-1">No results yet...</span>} {calculateAverageModuleScore(module) > -1 && (
</div> <span className="text-sm">
</div> {calculateAverageModuleScore(module).toFixed(1)}
</div> </span>
</Modal> )}
); </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>
</Modal>
);
} }

View File

@@ -3,228 +3,415 @@ import ProgressBar from "@/components/Low/ProgressBar";
import PayPalPayment from "@/components/PayPalPayment"; import PayPalPayment from "@/components/PayPalPayment";
import ProfileSummary from "@/components/ProfileSummary"; import ProfileSummary from "@/components/ProfileSummary";
import useAssignments from "@/hooks/useAssignments"; import useAssignments from "@/hooks/useAssignments";
import useInvites from "@/hooks/useInvites";
import useStats from "@/hooks/useStats"; import useStats from "@/hooks/useStats";
import {Assignment} from "@/interfaces/results"; import useUsers from "@/hooks/useUsers";
import {CorporateUser, User} from "@/interfaces/user"; import { Invite } from "@/interfaces/invite";
import { Assignment } from "@/interfaces/results";
import { CorporateUser, 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 {getUserCorporate} from "@/utils/groups"; import { getUserCorporate } from "@/utils/groups";
import {MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils"; import {
import {averageScore, groupBySession} from "@/utils/stats"; MODULE_ARRAY,
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js"; sortByModule,
import {PayPalButtons} from "@paypal/react-paypal-js"; sortByModuleName,
} from "@/utils/moduleUtils";
import { averageScore, groupBySession } from "@/utils/stats";
import {
CreateOrderActions,
CreateOrderData,
OnApproveActions,
OnApproveData,
OrderResponseBody,
} from "@paypal/paypal-js";
import { PayPalButtons } from "@paypal/react-paypal-js";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize} from "lodash"; import { capitalize } from "lodash";
import moment from "moment"; import moment from "moment";
import Link from "next/link"; import Link from "next/link";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import {BsArrowRepeat, BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs"; import {
import {toast} from "react-toastify"; BsArrowRepeat,
BsBook,
BsClipboard,
BsFileEarmarkText,
BsHeadphones,
BsMegaphone,
BsPen,
BsPencil,
BsStar,
} from "react-icons/bs";
import { toast } from "react-toastify";
interface Props { interface Props {
user: User; user: User;
} }
export default function StudentDashboard({user}: Props) { export default function StudentDashboard({ user }: Props) {
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>(); const [corporateUserToShow, setCorporateUserToShow] =
useState<CorporateUser>();
const {stats} = useStats(user.id); const { stats } = useStats(user.id);
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id}); const { users } = useUsers();
const {
assignments,
isLoading: isAssignmentsLoading,
reload: reloadAssignments,
} = useAssignments({ assignees: user?.id });
const {
invites,
isLoading: isInvitesLoading,
reload: reloadInvites,
} = useInvites({ to: user.id });
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 setAssignment = useExamStore((state) => state.setAssignment); const setAssignment = useExamStore((state) => state.setAssignment);
useEffect(() => { useEffect(() => {
getUserCorporate(user.id).then(setCorporateUserToShow); getUserCorporate(user.id).then(setCorporateUserToShow);
}, [user]); }, [user]);
const startAssignment = (assignment: Assignment) => { const startAssignment = (assignment: Assignment) => {
const examPromises = assignment.exams.filter((e) => e.assignee === user.id).map((e) => getExamById(e.module, e.id)); const examPromises = assignment.exams
.filter((e) => e.assignee === user.id)
.map((e) => getExamById(e.module, e.id));
Promise.all(examPromises).then((exams) => { Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) { if (exams.every((x) => !!x)) {
setUserSolutions([]); setUserSolutions([]);
setShowSolutions(false); setShowSolutions(false);
setExams(exams.map((x) => x!).sort(sortByModule)); setExams(exams.map((x) => x!).sort(sortByModule));
setSelectedModules( setSelectedModules(
exams exams
.map((x) => x!) .map((x) => x!)
.sort(sortByModule) .sort(sortByModule)
.map((x) => x!.module), .map((x) => x!.module),
); );
setAssignment(assignment); setAssignment(assignment);
router.push("/exercises"); router.push("/exercises");
} }
}); });
}; };
return ( const InviteCard = (invite: Invite) => {
<> const [isLoading, setIsLoading] = useState(false);
{corporateUserToShow && (
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
Linked to: <b>{corporateUserToShow?.corporateInformation?.companyInformation.name || corporateUserToShow.name}</b>
</div>
)}
<ProfileSummary
user={user}
items={[
{
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: Object.keys(groupBySession(stats)).length,
label: "Exams",
},
{
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: stats.length,
label: "Exercises",
},
{
icon: <BsStar className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
label: "Average Score",
},
]}
/>
<section className="flex flex-col gap-1 md:gap-3"> const inviter = users.find((u) => u.id === invite.from);
<span className="font-bold text-lg">Bio</span> const name = !inviter
<span className="text-mti-gray-taupe"> ? null
{user.bio || "Your bio will appear here, you can change it by clicking on your name in the top right corner."} : inviter.type === "corporate"
</span> ? inviter.corporateInformation?.companyInformation?.name || inviter.name
</section> : inviter.name;
<section className="flex flex-col gap-1 md:gap-3"> const decide = (decision: "accept" | "decline") => {
<div className="flex gap-4 items-center"> if (!confirm(`Are you sure you want to ${decision} this invite?`)) return;
<div
onClick={reloadAssignments}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<span className="font-bold text-lg text-mti-black">Assignments</span>
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
</div>
</div>
<span className="text-mti-gray-taupe flex gap-8 overflow-x-scroll scrollbar-hide">
{assignments.filter((a) => moment(a.endDate).isSameOrAfter(moment())).length === 0 &&
"Assignments will appear here. It seems that for now there are no assignments for you."}
{assignments
.filter((a) => moment(a.endDate).isSameOrAfter(moment()))
.sort((a, b) => moment(a.startDate).diff(b.startDate))
.map((assignment) => (
<div
className={clsx(
"border border-mti-gray-anti-flash rounded-xl flex flex-col gap-6 p-4 min-w-[300px]",
assignment.results.map((r) => r.user).includes(user.id) && "border-mti-green-light",
)}
key={assignment.id}>
<div className="flex flex-col gap-1">
<h3 className="font-semibold text-xl text-mti-black/90">{assignment.name}</h3>
<span className="flex gap-1 justify-between">
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
<span>-</span>
<span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span>
</span>
</div>
<div className="flex justify-between w-full items-center">
<div className="grid grid-cols-2 gap-2 place-items-center justify-center w-fit min-w-[104px] -md:mt-2">
{assignment.exams
.filter((e) => e.assignee === user.id)
.map((e) => e.module)
.sort(sortByModuleName)
.map((module) => (
<div
key={module}
data-tip={capitalize(module)}
className={clsx(
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl tooltip",
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="w-4 h-4" />}
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
{module === "writing" && <BsPen className="w-4 h-4" />}
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
{module === "level" && <BsClipboard className="w-4 h-4" />}
</div>
))}
</div>
{!assignment.results.map((r) => r.user).includes(user.id) && (
<>
<div
className="tooltip w-full md:hidden h-full flex items-center justify-end pl-8"
data-tip="Your screen size is too small to perform an assignment">
<Button
disabled={moment(assignment.startDate).isAfter(moment())}
className="w-full h-full !rounded-xl"
variant="outline">
Start
</Button>
</div>
<Button
disabled={moment(assignment.startDate).isAfter(moment())}
className="w-full max-w-[50%] h-full !rounded-xl -md:hidden"
onClick={() => startAssignment(assignment)}
variant="outline">
Start
</Button>
</>
)}
{assignment.results.map((r) => r.user).includes(user.id) && (
<Button
onClick={() => router.push("/record")}
color="green"
className="w-full max-w-[50%] h-full !rounded-xl -md:hidden"
variant="outline">
Submitted
</Button>
)}
</div>
</div>
))}
</span>
</section>
<section className="flex flex-col gap-3"> setIsLoading(true);
<span className="font-bold text-lg">Score History</span> axios
<div className="grid -md:grid-rows-4 md:grid-cols-2 gap-6"> .get(`/api/invites/${decision}/${invite.id}`)
{MODULE_ARRAY.map((module) => ( .then(() => {
<div className="border border-mti-gray-anti-flash rounded-xl flex flex-col gap-2 p-4" key={module}> toast.success(
<div className="flex gap-2 md:gap-3 items-center"> `Successfully ${decision === "accept" ? "accepted" : "declined"} the invite!`,
<div className="w-8 h-8 md:w-12 md:h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg md:rounded-xl"> { toastId: "success" },
{module === "reading" && <BsBook className="text-ielts-reading w-4 h-4 md:w-5 md:h-5" />} );
{module === "listening" && <BsHeadphones className="text-ielts-listening w-4 h-4 md:w-5 md:h-5" />} reloadInvites();
{module === "writing" && <BsPen className="text-ielts-writing w-4 h-4 md:w-5 md:h-5" />} })
{module === "speaking" && <BsMegaphone className="text-ielts-speaking w-4 h-4 md:w-5 md:h-5" />} .catch((e) => {
{module === "level" && <BsClipboard className="text-ielts-level w-4 h-4 md:w-5 md:h-5" />} toast.success(`Something went wrong, please try again later!`, {
</div> toastId: "error",
<div className="flex justify-between w-full"> });
<span className="font-bold md:font-extrabold text-sm">{capitalize(module)}</span> reloadInvites();
<span className="text-sm font-normal text-mti-gray-dim"> })
Level {user.levels[module] || 0} / Level {user.desiredLevels[module] || 9} .finally(() => setIsLoading(false));
</span> };
</div>
</div> return (
<div className="md:pl-14"> <div className="border-mti-gray-anti-flash flex min-w-[200px] flex-col gap-6 rounded-xl border p-4 text-black">
<ProgressBar <span>Invited by {name}</span>
color={module} <div className="flex items-center gap-2">
label="" <button
percentage={Math.round((user.levels[module] * 100) / user.desiredLevels[module])} onClick={() => decide("accept")}
className="w-full h-2" disabled={isLoading}
/> className="bg-mti-green-ultralight hover:bg-mti-green-light w-24 rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed"
</div> >
</div> {!isLoading && "Accept"}
))} {isLoading && (
</div> <div className="flex items-center justify-center">
</section> <BsArrowRepeat className="animate-spin text-white" size={25} />
</> </div>
); )}
</button>
<button
onClick={() => decide("decline")}
disabled={isLoading}
className="bg-mti-red-ultralight hover:bg-mti-red-light w-24 rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed"
>
{!isLoading && "Decline"}
{isLoading && (
<div className="flex items-center justify-center">
<BsArrowRepeat className="animate-spin text-white" size={25} />
</div>
)}
</button>
</div>
</div>
);
};
return (
<>
{corporateUserToShow && (
<div className="absolute right-4 top-4 rounded-lg bg-neutral-200 px-2 py-1">
Linked to:{" "}
<b>
{corporateUserToShow?.corporateInformation?.companyInformation
.name || corporateUserToShow.name}
</b>
</div>
)}
<ProfileSummary
user={user}
items={[
{
icon: (
<BsFileEarmarkText className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />
),
value: Object.keys(groupBySession(stats)).length,
label: "Exams",
},
{
icon: (
<BsPencil className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />
),
value: stats.length,
label: "Exercises",
},
{
icon: (
<BsStar className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />
),
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
label: "Average Score",
},
]}
/>
<section className="flex flex-col gap-1 md:gap-3">
<span className="text-lg font-bold">Bio</span>
<span className="text-mti-gray-taupe">
{user.bio ||
"Your bio will appear here, you can change it by clicking on your name in the top right corner."}
</span>
</section>
<section className="flex flex-col gap-1 md:gap-3">
<div className="flex items-center gap-4">
<div
onClick={reloadAssignments}
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out"
>
<span className="text-mti-black text-lg font-bold">
Assignments
</span>
<BsArrowRepeat
className={clsx(
"text-xl",
isAssignmentsLoading && "animate-spin",
)}
/>
</div>
</div>
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
{assignments.filter((a) => moment(a.endDate).isSameOrAfter(moment()))
.length === 0 &&
"Assignments will appear here. It seems that for now there are no assignments for you."}
{assignments
.filter((a) => moment(a.endDate).isSameOrAfter(moment()))
.sort((a, b) => moment(a.startDate).diff(b.startDate))
.map((assignment) => (
<div
className={clsx(
"border-mti-gray-anti-flash flex min-w-[300px] flex-col gap-6 rounded-xl border p-4",
assignment.results.map((r) => r.user).includes(user.id) &&
"border-mti-green-light",
)}
key={assignment.id}
>
<div className="flex flex-col gap-1">
<h3 className="text-mti-black/90 text-xl font-semibold">
{assignment.name}
</h3>
<span className="flex justify-between gap-1">
<span>
{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}
</span>
<span>-</span>
<span>
{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}
</span>
</span>
</div>
<div className="flex w-full items-center justify-between">
<div className="-md:mt-2 grid w-fit min-w-[104px] grid-cols-2 place-items-center justify-center gap-2">
{assignment.exams
.filter((e) => e.assignee === user.id)
.map((e) => e.module)
.sort(sortByModuleName)
.map((module) => (
<div
key={module}
data-tip={capitalize(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" />
)}
</div>
))}
</div>
{!assignment.results.map((r) => r.user).includes(user.id) && (
<>
<div
className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden"
data-tip="Your screen size is too small to perform an assignment"
>
<Button
disabled={moment(assignment.startDate).isAfter(
moment(),
)}
className="h-full w-full !rounded-xl"
variant="outline"
>
Start
</Button>
</div>
<Button
disabled={moment(assignment.startDate).isAfter(
moment(),
)}
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
onClick={() => startAssignment(assignment)}
variant="outline"
>
Start
</Button>
</>
)}
{assignment.results.map((r) => r.user).includes(user.id) && (
<Button
onClick={() => router.push("/record")}
color="green"
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
variant="outline"
>
Submitted
</Button>
)}
</div>
</div>
))}
</span>
</section>
{invites.length > 0 && (
<section className="flex flex-col gap-1 md:gap-3">
<div className="flex items-center gap-4">
<div
onClick={reloadInvites}
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out"
>
<span className="text-mti-black text-lg font-bold">Invites</span>
<BsArrowRepeat
className={clsx("text-xl", isInvitesLoading && "animate-spin")}
/>
</div>
</div>
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
{invites.map((invite) => (
<InviteCard key={invite.id} {...invite} />
))}
</span>
</section>
)}
<section className="flex flex-col gap-3">
<span className="text-lg font-bold">Score History</span>
<div className="-md:grid-rows-4 grid gap-6 md:grid-cols-2">
{MODULE_ARRAY.map((module) => (
<div
className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4"
key={module}
>
<div className="flex items-center gap-2 md:gap-3">
<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 === "reading" && (
<BsBook className="text-ielts-reading 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">
Level {user.levels[module] || 0} / Level{" "}
{user.desiredLevels[module] || 9}
</span>
</div>
</div>
<div className="md:pl-14">
<ProgressBar
color={module}
label=""
percentage={Math.round(
(user.levels[module] * 100) / user.desiredLevels[module],
)}
className="h-2 w-full"
/>
</div>
</div>
))}
</div>
</section>
</>
);
} }

View File

@@ -0,0 +1,28 @@
<!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>Hello {{name}},</span>
<br/>
<br/>
<span>You have been invited to join {{corporateName}}'s group!</span>
<br />
<br/>
<span>Please access the platform to accept or decline the invite.</span>
</div>
<br />
<br />
<div>
<span>Thanks, <br /> Your EnCoach team</span>
</div>
</div>
</html>

View File

@@ -0,0 +1,25 @@
<!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>Hello {{corporateName}},</span>
<br />
<br />
<span>{{name}} has decided to {{decision}} your invite!</span>
</div>
<br />
<br />
<div>
<span>Thanks, <br /> Your EnCoach team</span>
</div>
</div>
</html>

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>Thank you for your ticket submission!</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

@@ -1,244 +1,319 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import ModuleTitle from "@/components/Medium/ModuleTitle"; import ModuleTitle from "@/components/Medium/ModuleTitle";
import {moduleResultText} from "@/constants/ielts"; import { moduleResultText } from "@/constants/ielts";
import {Module} from "@/interfaces"; import { Module } from "@/interfaces";
import {User} from "@/interfaces/user"; import { User } from "@/interfaces/user";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {calculateBandScore} from "@/utils/score"; import { calculateBandScore } from "@/utils/score";
import clsx from "clsx"; import clsx from "clsx";
import Link from "next/link"; import Link from "next/link";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import {Fragment, useEffect, useState} from "react"; import { Fragment, useEffect, useState } from "react";
import {BsArrowCounterclockwise, BsBook, BsClipboard, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs"; import {
import {LevelScore} from "@/constants/ielts"; BsArrowCounterclockwise,
import {getLevelScore} from "@/utils/score"; BsBook,
BsClipboard,
BsEyeFill,
BsHeadphones,
BsMegaphone,
BsPen,
BsShareFill,
} from "react-icons/bs";
import { LevelScore } from "@/constants/ielts";
import { getLevelScore } from "@/utils/score";
interface Score { interface Score {
module: Module; module: Module;
correct: number; correct: number;
total: number; total: number;
missing: number; missing: number;
} }
interface Props { interface Props {
user: User; user: User;
modules: Module[]; modules: Module[];
scores: Score[]; scores: Score[];
isLoading: boolean; isLoading: boolean;
onViewResults: () => void; onViewResults: () => void;
} }
export default function Finish({user, scores, modules, isLoading, onViewResults}: Props) { export default function Finish({
const [selectedModule, setSelectedModule] = useState(modules[0]); user,
const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!); scores,
modules,
isLoading,
onViewResults,
}: Props) {
const [selectedModule, setSelectedModule] = useState(modules[0]);
const [selectedScore, setSelectedScore] = useState<Score>(
scores.find((x) => x.module === modules[0])!,
);
const exams = useExamStore((state) => state.exams); const exams = useExamStore((state) => state.exams);
useEffect(() => setSelectedScore(scores.find((x) => x.module === selectedModule)!), [scores, selectedModule]); useEffect(
() => setSelectedScore(scores.find((x) => x.module === selectedModule)!),
[scores, selectedModule],
);
useEffect(() => console.log(scores), [scores]);
const moduleColors: {[key in Module]: {progress: string; inner: string}} = { const moduleColors: { [key in Module]: { progress: string; inner: string } } =
reading: { {
progress: "text-ielts-reading", reading: {
inner: "bg-ielts-reading-light", progress: "text-ielts-reading",
}, inner: "bg-ielts-reading-light",
listening: { },
progress: "text-ielts-listening", listening: {
inner: "bg-ielts-listening-light", progress: "text-ielts-listening",
}, inner: "bg-ielts-listening-light",
writing: { },
progress: "text-ielts-writing", writing: {
inner: "bg-ielts-writing-light", progress: "text-ielts-writing",
}, inner: "bg-ielts-writing-light",
speaking: { },
progress: "text-ielts-speaking", speaking: {
inner: "bg-ielts-speaking-light", progress: "text-ielts-speaking",
}, inner: "bg-ielts-speaking-light",
level: { },
progress: "text-ielts-level", level: {
inner: "bg-ielts-level-light", progress: "text-ielts-level",
}, inner: "bg-ielts-level-light",
}; },
};
const getTotalExercises = () => { const getTotalExercises = () => {
const exam = exams.find((x) => x.module === selectedModule)!; const exam = exams.find((x) => x.module === selectedModule)!;
if (exam.module === "reading" || exam.module === "listening") { if (exam.module === "reading" || exam.module === "listening") {
return exam.parts.flatMap((x) => x.exercises).length; return exam.parts.flatMap((x) => x.exercises).length;
} }
return exam.exercises.length; return exam.exercises.length;
}; };
const bandScore: number = calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus); const bandScore: number = calculateBandScore(
selectedScore.correct,
selectedScore.total,
selectedModule,
user.focus,
);
const showLevel = (level: number) => { const showLevel = (level: number) => {
if (selectedModule === "level") { if (selectedModule === "level") {
const [levelStr, grade] = getLevelScore(level); const [levelStr, grade] = getLevelScore(level);
return ( return (
<div className="flex flex-col items-center justify-center gap-1"> <div className="flex flex-col items-center justify-center gap-1">
<span className="text-xl font-bold">{levelStr}</span> <span className="text-xl font-bold">{levelStr}</span>
<span className="text-xl">{grade}</span> <span className="text-xl">{grade}</span>
</div> </div>
); );
} }
return <span className="text-3xl font-bold">{level}</span>; return <span className="text-3xl font-bold">{level}</span>;
}; };
return ( return (
<> <>
<div className="w-full min-h-full h-fit flex flex-col items-center justify-between gap-8"> <div className="flex h-fit min-h-full w-full flex-col items-center justify-between gap-8">
<ModuleTitle <ModuleTitle
module={selectedModule} module={selectedModule}
totalExercises={getTotalExercises()} totalExercises={getTotalExercises()}
exerciseIndex={getTotalExercises()} exerciseIndex={getTotalExercises()}
minTimer={exams.find((x) => x.module === selectedModule)!.minTimer} minTimer={exams.find((x) => x.module === selectedModule)!.minTimer}
disableTimer disableTimer
/> />
<div className="flex gap-4 self-start"> <div className="flex gap-4 self-start">
{modules.includes("reading") && ( {modules.includes("reading") && (
<div <div
onClick={() => setSelectedModule("reading")} onClick={() => setSelectedModule("reading")}
className={clsx( className={clsx(
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-reading hover:text-white", "hover:bg-ielts-reading flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
selectedModule === "reading" ? "bg-ielts-reading text-white" : "bg-mti-gray-smoke text-ielts-reading", selectedModule === "reading"
)}> ? "bg-ielts-reading text-white"
<BsBook className="w-6 h-6" /> : "bg-mti-gray-smoke text-ielts-reading",
<span className="font-semibold">Reading</span> )}
</div> >
)} <BsBook className="h-6 w-6" />
{modules.includes("listening") && ( <span className="font-semibold">Reading</span>
<div </div>
onClick={() => setSelectedModule("listening")} )}
className={clsx( {modules.includes("listening") && (
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-listening hover:text-white", <div
selectedModule === "listening" ? "bg-ielts-listening text-white" : "bg-mti-gray-smoke text-ielts-listening", onClick={() => setSelectedModule("listening")}
)}> className={clsx(
<BsHeadphones className="w-6 h-6" /> "hover:bg-ielts-listening flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
<span className="font-semibold">Listening</span> selectedModule === "listening"
</div> ? "bg-ielts-listening text-white"
)} : "bg-mti-gray-smoke text-ielts-listening",
{modules.includes("writing") && ( )}
<div >
onClick={() => setSelectedModule("writing")} <BsHeadphones className="h-6 w-6" />
className={clsx( <span className="font-semibold">Listening</span>
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-writing hover:text-white", </div>
selectedModule === "writing" ? "bg-ielts-writing text-white" : "bg-mti-gray-smoke text-ielts-writing", )}
)}> {modules.includes("writing") && (
<BsPen className="w-6 h-6" /> <div
<span className="font-semibold">Writing</span> onClick={() => setSelectedModule("writing")}
</div> className={clsx(
)} "hover:bg-ielts-writing flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
{modules.includes("speaking") && ( selectedModule === "writing"
<div ? "bg-ielts-writing text-white"
onClick={() => setSelectedModule("speaking")} : "bg-mti-gray-smoke text-ielts-writing",
className={clsx( )}
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-speaking hover:text-white", >
selectedModule === "speaking" ? "bg-ielts-speaking text-white" : "bg-mti-gray-smoke text-ielts-speaking", <BsPen className="h-6 w-6" />
)}> <span className="font-semibold">Writing</span>
<BsMegaphone className="w-6 h-6" /> </div>
<span className="font-semibold">Speaking</span> )}
</div> {modules.includes("speaking") && (
)} <div
{modules.includes("level") && ( onClick={() => setSelectedModule("speaking")}
<div className={clsx(
onClick={() => setSelectedModule("level")} "hover:bg-ielts-speaking flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
className={clsx( selectedModule === "speaking"
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-level hover:text-white", ? "bg-ielts-speaking text-white"
selectedModule === "level" ? "bg-ielts-level text-white" : "bg-mti-gray-smoke text-ielts-level", : "bg-mti-gray-smoke text-ielts-speaking",
)}> )}
<BsClipboard className="w-6 h-6" /> >
<span className="font-semibold">Level</span> <BsMegaphone className="h-6 w-6" />
</div> <span className="font-semibold">Speaking</span>
)} </div>
</div> )}
{isLoading && ( {modules.includes("level") && (
<div className="w-fit h-fit absolute top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 animate-pulse flex flex-col gap-12 items-center"> <div
<span className={clsx("loading loading-infinity w-32", moduleColors[selectedModule].progress)} /> onClick={() => setSelectedModule("level")}
<span className={clsx("font-bold text-2xl text-center", moduleColors[selectedModule].progress)}> className={clsx(
Evaluating your answers, please be patient... "hover:bg-ielts-level flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
<br /> selectedModule === "level"
You can also check it later on your records page! ? "bg-ielts-level text-white"
</span> : "bg-mti-gray-smoke text-ielts-level",
</div> )}
)} >
{!isLoading && ( <BsClipboard className="h-6 w-6" />
<div className="w-full flex gap-9 mt-32 items-center justify-between mb-20"> <span className="font-semibold">Level</span>
<span className="max-w-3xl">{moduleResultText(selectedModule, bandScore)}</span> </div>
<div className="flex gap-9 px-16"> )}
<div </div>
className={clsx("radial-progress overflow-hidden", moduleColors[selectedModule].progress)} {isLoading && (
style={ <div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
{"--value": (selectedScore.correct / selectedScore.total) * 100, "--thickness": "12px", "--size": "13rem"} as any <span
}> className={clsx(
<div "loading loading-infinity w-32",
className={clsx( moduleColors[selectedModule].progress,
"w-48 h-48 rounded-full flex flex-col items-center justify-center", )}
moduleColors[selectedModule].inner, />
)}> <span
<span className="text-xl">Level</span> className={clsx(
{showLevel(bandScore)} "text-center text-2xl font-bold",
</div> moduleColors[selectedModule].progress,
</div> )}
<div className="flex flex-col gap-5"> >
<div className="flex gap-2"> Evaluating your answers, please be patient...
<div className="w-3 h-3 bg-mti-red-light rounded-full mt-1" /> <br />
<div className="flex flex-col"> You can also check it later on your records page!
<span className="text-mti-red-light"> </span>
{(((selectedScore.total - selectedScore.missing) / selectedScore.total) * 100).toFixed(0)}% </div>
</span> )}
<span className="text-lg">Completion</span> {!isLoading && (
</div> <div className="mb-20 mt-32 flex w-full items-center justify-between gap-9">
</div> <span className="max-w-3xl">
<div className="flex gap-2"> {moduleResultText(selectedModule, bandScore)}
<div className="w-3 h-3 bg-mti-purple-light rounded-full mt-1" /> </span>
<div className="flex flex-col"> <div className="flex gap-9 px-16">
<span className="text-mti-purple-light">{selectedScore.correct.toString().padStart(2, "0")}</span> <div
<span className="text-lg">Correct</span> className={clsx(
</div> "radial-progress overflow-hidden",
</div> moduleColors[selectedModule].progress,
<div className="flex gap-2"> )}
<div className="w-3 h-3 bg-mti-rose-light rounded-full mt-1" /> style={
<div className="flex flex-col"> {
<span className="text-mti-rose-light"> "--value":
{(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")} (selectedScore.correct / selectedScore.total) * 100,
</span> "--thickness": "12px",
<span className="text-lg">Wrong</span> "--size": "13rem",
</div> } as any
</div> }
</div> >
</div> <div
</div> className={clsx(
)} "flex h-48 w-48 flex-col items-center justify-center rounded-full",
</div> moduleColors[selectedModule].inner,
)}
>
<span className="text-xl">Level</span>
{showLevel(bandScore)}
</div>
</div>
<div className="flex flex-col gap-5">
<div className="flex gap-2">
<div className="bg-mti-red-light mt-1 h-3 w-3 rounded-full" />
<div className="flex flex-col">
<span className="text-mti-red-light">
{(
((selectedScore.total - selectedScore.missing) /
selectedScore.total) *
100
).toFixed(0)}
%
</span>
<span className="text-lg">Completion</span>
</div>
</div>
<div className="flex gap-2">
<div className="bg-mti-purple-light mt-1 h-3 w-3 rounded-full" />
<div className="flex flex-col">
<span className="text-mti-purple-light">
{selectedScore.correct.toString().padStart(2, "0")}
</span>
<span className="text-lg">Correct</span>
</div>
</div>
<div className="flex gap-2">
<div className="bg-mti-rose-light mt-1 h-3 w-3 rounded-full" />
<div className="flex flex-col">
<span className="text-mti-rose-light">
{(selectedScore.total - selectedScore.correct)
.toString()
.padStart(2, "0")}
</span>
<span className="text-lg">Wrong</span>
</div>
</div>
</div>
</div>
</div>
)}
</div>
{!isLoading && ( {!isLoading && (
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8"> <div className="absolute bottom-8 left-0 flex w-full justify-between gap-8 self-end px-8">
<div className="flex gap-8"> <div className="flex gap-8">
<div className="w-fit flex flex-col items-center gap-1 cursor-pointer"> <div className="flex w-fit cursor-pointer flex-col items-center gap-1">
<button <button
onClick={() => window.location.reload()} onClick={() => window.location.reload()}
className="w-11 h-11 rounded-full bg-mti-purple-light hover:bg-mti-purple flex items-center justify-center transition duration-300 ease-in-out"> className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out"
<BsArrowCounterclockwise className="text-white w-7 h-7" /> >
</button> <BsArrowCounterclockwise className="h-7 w-7 text-white" />
<span>Play Again</span> </button>
</div> <span>Play Again</span>
<div className="w-fit flex flex-col items-center gap-1 cursor-pointer"> </div>
<button <div className="flex w-fit cursor-pointer flex-col items-center gap-1">
onClick={onViewResults} <button
className="w-11 h-11 rounded-full bg-mti-purple-light hover:bg-mti-purple flex items-center justify-center transition duration-300 ease-in-out"> onClick={onViewResults}
<BsEyeFill className="text-white w-7 h-7" /> className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out"
</button> >
<span>Review Answers</span> <BsEyeFill className="h-7 w-7 text-white" />
</div> </button>
</div> <span>Review Answers</span>
</div>
</div>
<Link href="/" className="max-w-[200px] w-full self-end"> <Link href="/" className="w-full max-w-[200px] self-end">
<Button color="purple" className="max-w-[200px] self-end w-full"> <Button color="purple" className="w-full max-w-[200px] self-end">
Dashboard Dashboard
</Button> </Button>
</Link> </Link>
</div> </div>
)} )}
</> </>
); );
} }

View File

@@ -112,11 +112,6 @@ const GroupTestReport = ({
Candidate Information: Candidate Information:
</Text> </Text>
<View style={styles.textMargin}> <View style={styles.textMargin}>
<Text style={defaultTextStyle}>Name: {name}</Text>
<Text style={defaultTextStyle}>ID: {id}</Text>
<Text style={defaultTextStyle}>Email: {email}</Text>
<Text style={defaultTextStyle}>Gender: {gender}</Text>
<Text style={defaultTextStyle}>Passport ID: {passportId}</Text>
<Text style={defaultTextStyle}> <Text style={defaultTextStyle}>
Total Number of Students: {numberOfStudents} Total Number of Students: {numberOfStudents}
</Text> </Text>
@@ -242,10 +237,10 @@ const GroupTestReport = ({
Sr Sr
</Text> </Text>
<Text style={customStyles.tableCell}>Candidate Name</Text> <Text style={customStyles.tableCell}>Candidate Name</Text>
<Text style={customStyles.tableCell}>Email ID</Text> <Text style={customStyles.tableCell}>
<Text style={[customStyles.tableCell, { maxWidth: "48px" }]}> Passport ID
Gender
</Text> </Text>
<Text style={customStyles.tableCell}>Email ID</Text>
<Text style={[customStyles.tableCell, { maxWidth: "64px" }]}> <Text style={[customStyles.tableCell, { maxWidth: "64px" }]}>
Date of test Date of test
</Text> </Text>
@@ -255,7 +250,19 @@ const GroupTestReport = ({
{showLevel && <Text style={customStyles.tableCell}>Level</Text>} {showLevel && <Text style={customStyles.tableCell}>Level</Text>}
</View> </View>
{studentsData.map( {studentsData.map(
({ id, name, email, gender, date, result, level }, index) => ( (
{
id,
name,
email,
gender,
date,
result,
level,
passportId: studentPassportId,
},
index
) => (
<View <View
style={[ style={[
customStyles.tableRow, customStyles.tableRow,
@@ -273,10 +280,8 @@ const GroupTestReport = ({
{index + 1} {index + 1}
</Text> </Text>
<Text style={customStyles.tableCell}>{name}</Text> <Text style={customStyles.tableCell}>{name}</Text>
<Text style={customStyles.tableCell}>{studentPassportId}</Text>
<Text style={customStyles.tableCell}>{email}</Text> <Text style={customStyles.tableCell}>{email}</Text>
<Text style={[customStyles.tableCell, { maxWidth: "48px" }]}>
{gender}
</Text>
<Text style={[customStyles.tableCell, { maxWidth: "64px" }]}> <Text style={[customStyles.tableCell, { maxWidth: "64px" }]}>
{date} {date}
</Text> </Text>

View File

@@ -2,7 +2,11 @@ import React from "react";
import { styles } from "./styles"; import { styles } from "./styles";
import { View, Text } from "@react-pdf/renderer"; import { View, Text } from "@react-pdf/renderer";
const TestReportFooter = () => ( interface Props {
userId?: string;
}
const TestReportFooter = ({ userId }: Props) => (
<View <View
style={[ style={[
{ {
@@ -25,10 +29,23 @@ const TestReportFooter = () => (
</Text> </Text>
</View> </View>
<View> <View>
<Text style={styles.textBold}>Confidential <Text style={[styles.textFont, styles.textNormal]}>circulated for concern people</Text></Text> <Text style={styles.textBold}>
Confidential {" "}
<Text style={[styles.textFont, styles.textNormal]}>
circulated for concern people
</Text>
</Text>
</View> </View>
</View> </View>
<View style={{ paddingTop: 10 }}> {userId && (
<View>
<Text style={styles.textBold}>
User ID:{" "}
<Text style={[styles.textFont, styles.textNormal]}>{userId}</Text>
</Text>
</View>
)}
<View style={{ paddingTop: 4 }}>
<Text style={styles.textBold}>Declaration</Text> <Text style={styles.textBold}>Declaration</Text>
<Text style={{ paddingTop: 5 }}> <Text style={{ paddingTop: 5 }}>
We hereby declare that exam results on our platform, assessed by AI, are We hereby declare that exam results on our platform, assessed by AI, are

View File

@@ -124,7 +124,7 @@ const TestReport = ({
</View> </View>
</View> </View>
<View style={[{ paddingTop: 30 }, styles.separator]}></View> <View style={[{ paddingTop: 30 }, styles.separator]}></View>
<TestReportFooter /> <TestReportFooter userId={id}/>
</Page> </Page>
<Page style={styles.body}> <Page style={styles.body}>
<View> <View>
@@ -165,7 +165,7 @@ const TestReport = ({
</View> </View>
<View style={[{ paddingBottom: 30 }, styles.separator]}></View> <View style={[{ paddingBottom: 30 }, styles.separator]}></View>
<View style={{ flexGrow: 1 }}></View> <View style={{ flexGrow: 1 }}></View>
<TestReportFooter /> <TestReportFooter userId={id}/>
</Page> </Page>
</Document> </Document>
); );

35
src/hooks/useInvites.tsx Normal file
View File

@@ -0,0 +1,35 @@
import { Invite } from "@/interfaces/invite";
import { Ticket } from "@/interfaces/ticket";
import { Code, Group, User } from "@/interfaces/user";
import axios from "axios";
import { useEffect, useState } from "react";
export default function useInvites({
from,
to,
}: {
from?: string;
to?: string;
}) {
const [invites, setInvites] = useState<Invite[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
const filters: ((i: Invite) => boolean)[] = [];
if (from) filters.push((i: Invite) => i.from === from);
if (to) filters.push((i: Invite) => i.to === to);
setIsLoading(true);
axios
.get<Invite[]>(`/api/invites`)
.then((response) =>
setInvites(filters.reduce((d, f) => d.filter(f), response.data)),
)
.finally(() => setIsLoading(false));
};
useEffect(getData, [to, from]);
return { invites, isLoading, isError, reload: getData };
}

22
src/hooks/useTickets.tsx Normal file
View File

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

5
src/interfaces/invite.ts Normal file
View File

@@ -0,0 +1,5 @@
export interface Invite {
id: string;
from: string;
to: string;
}

View File

@@ -19,4 +19,5 @@ export interface StudentData {
result: string; result: string;
level?: string; level?: string;
bandScore: number; bandScore: number;
passportId?: string;
} }

34
src/interfaces/ticket.ts Normal file
View File

@@ -0,0 +1,34 @@
import { Type } from "./user";
export interface Ticket {
id: string;
date: string;
status: TicketStatus;
type: TicketType;
reporter: TicketReporter;
reportedFrom: string;
description: string;
subject: string;
assignedTo?: string;
}
export interface TicketReporter {
id: string;
name: string;
email: string;
type: Type;
}
export type TicketType = "feedback" | "bug" | "help";
export const TicketTypeLabel: { [key in TicketType]: string } = {
feedback: "Feedback",
bug: "Bug",
help: "Help",
};
export type TicketStatus = "submitted" | "in-progress" | "completed";
export const TicketStatusLabel: { [key in TicketStatus]: string } = {
submitted: "Submitted",
"in-progress": "In Progress",
completed: "Completed",
};

View File

@@ -1,217 +1,318 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import {PERMISSIONS} from "@/constants/userPermissions"; import { PERMISSIONS } from "@/constants/userPermissions";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import {Type, User} from "@/interfaces/user"; import { Type, User } from "@/interfaces/user";
import {USER_TYPE_LABELS} from "@/resources/user"; import { USER_TYPE_LABELS } from "@/resources/user";
import axios from "axios"; 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 {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import ReactDatePicker from "react-datepicker"; import ReactDatePicker from "react-datepicker";
import {toast} from "react-toastify"; import { toast } from "react-toastify";
import ShortUniqueId from "short-unique-id"; import ShortUniqueId from "short-unique-id";
import {useFilePicker} from "use-file-picker"; import { useFilePicker } from "use-file-picker";
import readXlsxFile from "read-excel-file"; import readXlsxFile from "read-excel-file";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import {BsQuestionCircleFill} from "react-icons/bs"; import { BsFileEarmarkEaselFill, BsQuestionCircleFill } from "react-icons/bs";
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/); const EMAIL_REGEX = new RegExp(
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/,
);
const USER_TYPE_PERMISSIONS: {[key in Type]: Type[]} = { const USER_TYPE_PERMISSIONS: { [key in Type]: Type[] } = {
student: [], student: [],
teacher: [], teacher: [],
agent: [], agent: [],
corporate: ["student", "teacher"], corporate: ["student", "teacher"],
admin: ["student", "teacher", "agent", "corporate", "admin"], admin: ["student", "teacher", "agent", "corporate", "admin"],
developer: ["student", "teacher", "agent", "corporate", "admin", "developer"], developer: ["student", "teacher", "agent", "corporate", "admin", "developer"],
}; };
export default function BatchCodeGenerator({user}: {user: User}) { export default function BatchCodeGenerator({ user }: { user: User }) {
const [infos, setInfos] = useState<{email: string; name: string; passport_id: string}[]>([]); const [infos, setInfos] = useState<
const [isLoading, setIsLoading] = useState(false); { email: string; name: string; passport_id: string }[]
const [expiryDate, setExpiryDate] = useState<Date | null>(null); >([]);
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); const [isLoading, setIsLoading] = useState(false);
const [type, setType] = useState<Type>("student"); const [expiryDate, setExpiryDate] = useState<Date | null>(null);
const [showHelp, setShowHelp] = useState(false); const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [type, setType] = useState<Type>("student");
const [showHelp, setShowHelp] = useState(false);
const {users} = useUsers(); const { users } = useUsers();
const {openFilePicker, filesContent, clear} = useFilePicker({ const { openFilePicker, filesContent, clear } = useFilePicker({
accept: ".xlsx", accept: ".xlsx",
multiple: false, multiple: false,
readAs: "ArrayBuffer", readAs: "ArrayBuffer",
}); });
useEffect(() => { useEffect(() => {
if (user && (user.type === "corporate" || user.type === "teacher")) { if (user && (user.type === "corporate" || user.type === "teacher")) {
setExpiryDate(user.subscriptionExpirationDate || null); setExpiryDate(user.subscriptionExpirationDate || null);
} }
}, [user]); }, [user]);
useEffect(() => { useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null); if (!isExpiryDateEnabled) setExpiryDate(null);
}, [isExpiryDateEnabled]); }, [isExpiryDateEnabled]);
useEffect(() => { useEffect(() => {
if (filesContent.length > 0) { if (filesContent.length > 0) {
const file = filesContent[0]; const file = filesContent[0];
readXlsxFile(file.content).then((rows) => { readXlsxFile(file.content).then((rows) => {
try { try {
const information = uniqBy( const information = uniqBy(
rows rows
.map((row) => { .map((row) => {
const [firstName, lastName, country, passport_id, email, ...phone] = row as string[]; const [
return EMAIL_REGEX.test(email.toString().trim()) && !users.map((u) => u.email).includes(email.toString().trim()) firstName,
? { lastName,
email: email.toString().trim(), country,
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(), passport_id,
passport_id: passport_id?.toString().trim() || undefined, email,
} ...phone
: undefined; ] = row as string[];
}) return EMAIL_REGEX.test(email.toString().trim())
.filter((x) => !!x) as typeof infos, ? {
(x) => x.email, email: email.toString().trim().toLowerCase(),
); name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
passport_id: passport_id?.toString().trim() || undefined,
}
: undefined;
})
.filter((x) => !!x) as typeof infos,
(x) => x.email,
);
if (information.length === 0) { if (information.length === 0) {
toast.error( toast.error(
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!", "Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
); );
return clear(); return clear();
} }
setInfos(information); setInfos(information);
} catch { } catch {
toast.error( toast.error(
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!", "Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
); );
return clear(); return clear();
} }
}); });
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [filesContent]); }, [filesContent]);
const generateCode = (type: Type) => { const generateAndInvite = async () => {
if (!confirm(`You are about to generate ${infos.length} codes, are you sure you want to continue?`)) return; const newUsers = infos.filter(
(x) => !users.map((u) => u.email).includes(x.email),
);
const existingUsers = infos
.filter((x) => users.map((u) => u.email).includes(x.email))
.map((i) => users.find((u) => u.email === i.email))
.filter((x) => !!x && x.type === "student") as User[];
const uid = new ShortUniqueId(); const newUsersSentence =
const codes = infos.map(() => uid.randomUUID(6)); newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined;
const existingUsersSentence =
existingUsers.length > 0
? `invite ${existingUsers.length} registered student(s)`
: undefined;
if (
!confirm(
`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`,
)
)
return;
setIsLoading(true); setIsLoading(true);
axios Promise.all(
.post<{ok: boolean; valid?: number; reason?: string}>("/api/code", {type, codes, infos: infos, expiryDate}) existingUsers.map(
.then(({data, status}) => { async (u) =>
if (data.ok) { await axios.post(`/api/invites`, { to: u.id, from: user.id }),
toast.success( ),
`Successfully generated${data.valid ? ` ${data.valid}/${infos.length}` : ""} ${capitalize( )
type, .then(() =>
)} codes and they have been notified by e-mail!`, toast.success(
{toastId: "success"}, `Successfully invited ${existingUsers.length} registered student(s)!`,
); ),
return; )
} .finally(() => {
if (newUsers.length === 0) setIsLoading(false);
});
if (status === 403) { if (newUsers.length > 0) generateCode(type, newUsers);
toast.error(data.reason, {toastId: "forbidden"}); setInfos([]);
} };
})
.catch(({response: {status, data}}) => {
if (status === 403) {
toast.error(data.reason, {toastId: "forbidden"});
return;
}
toast.error(`Something went wrong, please try again later!`, {toastId: "error"}); const generateCode = (type: Type, informations: typeof infos) => {
}) const uid = new ShortUniqueId();
.finally(() => { const codes = informations.map(() => uid.randomUUID(6));
setIsLoading(false);
return clear();
});
};
return ( setIsLoading(true);
<> axios
<Modal isOpen={showHelp} onClose={() => setShowHelp(false)} title="Excel File Format"> .post<{ ok: boolean; valid?: number; reason?: string }>("/api/code", {
<div className="mt-4 flex flex-col gap-2"> type,
<span>Please upload an Excel file with the following format:</span> codes,
<table className="w-full"> infos: informations,
<thead> expiryDate,
<tr> })
<th className="border border-neutral-200 px-2 py-1">First Name</th> .then(({ data, status }) => {
<th className="border border-neutral-200 px-2 py-1">Last Name</th> if (data.ok) {
<th className="border border-neutral-200 px-2 py-1">Country</th> toast.success(
<th className="border border-neutral-200 px-2 py-1">Passport/National ID</th> `Successfully generated${data.valid ? ` ${data.valid}/${informations.length}` : ""} ${capitalize(
<th className="border border-neutral-200 px-2 py-1">E-mail</th> type,
<th className="border border-neutral-200 px-2 py-1">Phone Number</th> )} codes and they have been notified by e-mail!`,
</tr> { toastId: "success" },
</thead> );
</table> return;
<span className="mt-4"> }
<b>Notes:</b>
<ul> if (status === 403) {
<li>- All incorrect e-mails will be ignored;</li> toast.error(data.reason, { toastId: "forbidden" });
<li>- All already registered e-mails will be ignored;</li> }
<li>- You may have a header row with the format above, however, it is not necessary;</li> })
<li>- All of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below.</li> .catch(({ response: { status, data } }) => {
</ul> if (status === 403) {
</span> toast.error(data.reason, { toastId: "forbidden" });
</div> return;
</Modal> }
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
<div className="flex justify-between items-end"> toast.error(`Something went wrong, please try again later!`, {
<label className="font-normal text-base text-mti-gray-dim">Choose an Excel file</label> toastId: "error",
<div className="cursor-pointer tooltip" data-tip="Excel File Format" onClick={() => setShowHelp(true)}> });
<BsQuestionCircleFill /> })
</div> .finally(() => {
</div> setIsLoading(false);
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}> return clear();
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"} });
</Button> };
{user && (user.type === "developer" || user.type === "admin") && (
<> return (
<div className="flex -md:flex-row md:flex-col -md:items-center 2xl:flex-row 2xl:items-center justify-between gap-2"> <>
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label> <Modal
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled}> isOpen={showHelp}
Enabled onClose={() => setShowHelp(false)}
</Checkbox> title="Excel File Format"
</div> >
{isExpiryDateEnabled && ( <div className="mt-4 flex flex-col gap-2">
<ReactDatePicker <span>Please upload an Excel file with the following format:</span>
className={clsx( <table className="w-full">
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", <thead>
"hover:border-mti-purple tooltip", <tr>
"transition duration-300 ease-in-out", <th className="border border-neutral-200 px-2 py-1">
)} First Name
filterDate={(date) => moment(date).isAfter(new Date())} </th>
dateFormat="dd/MM/yyyy" <th className="border border-neutral-200 px-2 py-1">
selected={expiryDate} Last Name
onChange={(date) => setExpiryDate(date)} </th>
/> <th className="border border-neutral-200 px-2 py-1">Country</th>
)} <th className="border border-neutral-200 px-2 py-1">
</> Passport/National ID
)} </th>
<label className="font-normal text-base text-mti-gray-dim">Select the type of user they should be</label> <th className="border border-neutral-200 px-2 py-1">E-mail</th>
{user && ( <th className="border border-neutral-200 px-2 py-1">
<select Phone Number
defaultValue="student" </th>
onChange={(e) => setType(e.target.value as typeof user.type)} </tr>
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white"> </thead>
{Object.keys(USER_TYPE_LABELS) </table>
.filter((x) => USER_TYPE_PERMISSIONS[user.type].includes(x as Type)) <span className="mt-4">
.map((type) => ( <b>Notes:</b>
<option key={type} value={type}> <ul>
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]} <li>- All incorrect e-mails will be ignored;</li>
</option> <li>- All already registered e-mails will be ignored;</li>
))} <li>
</select> - You may have a header row with the format above, however, it
)} is not necessary;
<Button onClick={() => generateCode(type)} disabled={infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}> </li>
Generate & Send <li>
</Button> - All of the e-mails in the file will receive an e-mail to join
</div> EnCoach with the role selected below.
</> </li>
); </ul>
</span>
</div>
</Modal>
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
<div className="flex items-end justify-between">
<label className="text-mti-gray-dim text-base font-normal">
Choose an Excel file
</label>
<div
className="tooltip cursor-pointer"
data-tip="Excel File Format"
onClick={() => setShowHelp(true)}
>
<BsQuestionCircleFill />
</div>
</div>
<Button
onClick={openFilePicker}
isLoading={isLoading}
disabled={isLoading}
>
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
</Button>
{user && (user.type === "developer" || user.type === "admin") && (
<>
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
<label className="text-mti-gray-dim text-base font-normal">
Expiry Date
</label>
<Checkbox
isChecked={isExpiryDateEnabled}
onChange={setIsExpiryDateEnabled}
>
Enabled
</Checkbox>
</div>
{isExpiryDateEnabled && (
<ReactDatePicker
className={clsx(
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
"hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out",
)}
filterDate={(date) => moment(date).isAfter(new Date())}
dateFormat="dd/MM/yyyy"
selected={expiryDate}
onChange={(date) => setExpiryDate(date)}
/>
)}
</>
)}
<label className="text-mti-gray-dim text-base font-normal">
Select the type of user they should be
</label>
{user && (
<select
defaultValue="student"
onChange={(e) => setType(e.target.value as typeof user.type)}
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none"
>
{Object.keys(USER_TYPE_LABELS)
.filter((x) =>
USER_TYPE_PERMISSIONS[user.type].includes(x as Type),
)
.map((type) => (
<option key={type} value={type}>
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
</option>
))}
</select>
)}
<Button
onClick={generateAndInvite}
disabled={
infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)
}
>
Generate & Send
</Button>
</div>
</>
);
} }

View File

@@ -1,301 +1,402 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import Checkbox from "@/components/Low/Checkbox";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import Modal from "@/components/Modal";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import {Module} from "@/interfaces"; import { Group, User } from "@/interfaces/user";
import {Group, User} from "@/interfaces/user"; import {
import {Disclosure, Transition} from "@headlessui/react"; createColumnHelper,
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table"; flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import { capitalize, uniq } from "lodash";
import {capitalize, uniq, uniqBy} from "lodash"; import { useEffect, useState } from "react";
import {useEffect, useRef, useState} from "react"; import { BsPencil, BsQuestionCircleFill, BsTrash } from "react-icons/bs";
import {BsCheck, BsDash, BsPencil, BsPlus, BsQuestionCircleFill, BsTrash} from "react-icons/bs";
import {toast} from "react-toastify";
import Select from "react-select"; import Select from "react-select";
import {uuidv4} from "@firebase/util"; import { toast } from "react-toastify";
import {useFilePicker} from "use-file-picker";
import Modal from "@/components/Modal";
import readXlsxFile from "read-excel-file"; import readXlsxFile from "read-excel-file";
import { useFilePicker } from "use-file-picker";
const columnHelper = createColumnHelper<Group>(); const columnHelper = createColumnHelper<Group>();
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/); const EMAIL_REGEX = new RegExp(
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/,
);
interface CreateDialogProps { interface CreateDialogProps {
user: User; user: User;
users: User[]; users: User[];
group?: Group; group?: Group;
onClose: () => void; onClose: () => void;
} }
const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => { const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
const [name, setName] = useState<string | undefined>(group?.name || undefined); const [name, setName] = useState<string | undefined>(
const [admin, setAdmin] = useState<string>(group?.admin || user.id); group?.name || undefined,
const [participants, setParticipants] = useState<string[]>(group?.participants || []); );
const {openFilePicker, filesContent, clear} = useFilePicker({ const [admin, setAdmin] = useState<string>(group?.admin || user.id);
accept: ".xlsx", const [participants, setParticipants] = useState<string[]>(
multiple: false, group?.participants || [],
readAs: "ArrayBuffer", );
}); const [isLoading, setIsLoading] = useState(false);
useEffect(() => { const { openFilePicker, filesContent, clear } = useFilePicker({
if (filesContent.length > 0) { accept: ".xlsx",
const file = filesContent[0]; multiple: false,
readXlsxFile(file.content).then((rows) => { readAs: "ArrayBuffer",
const emails = uniq( });
rows
.map((row) => {
const [email] = row as string[];
return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email) ? email.toString().trim() : undefined;
})
.filter((x) => !!x),
);
if (emails.length === 0) { useEffect(() => {
toast.error("Please upload an Excel file containing e-mails!"); if (filesContent.length > 0) {
clear(); setIsLoading(true);
return;
}
const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined); const file = filesContent[0];
const filteredUsers = emailUsers.filter( readXlsxFile(file.content).then((rows) => {
(x) => const emails = uniq(
((user.type === "developer" || user.type === "admin" || user.type === "corporate") && rows
(x?.type === "student" || x?.type === "teacher")) || .map((row) => {
(user.type === "teacher" && x?.type === "student"), const [email] = row as string[];
); return EMAIL_REGEX.test(email) &&
!users.map((u) => u.email).includes(email)
? email.toString().trim()
: undefined;
})
.filter((x) => !!x),
);
setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id)); if (emails.length === 0) {
toast.success( toast.error("Please upload an Excel file containing e-mails!");
user.type !== "teacher" clear();
? "Added all teachers and students found in the file you've provided!" setIsLoading(false);
: "Added all students found in the file you've provided!", return;
{toastId: "upload-success"}, }
);
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filesContent, user.type, users]);
const submit = () => { const emailUsers = [...new Set(emails)]
if (name !== group?.name && (name === "Students" || name === "Teachers")) { .map((x) => users.find((y) => y.email.toLowerCase() === x))
toast.error("That group name is reserved and cannot be used, please enter another one."); .filter((x) => x !== undefined);
return; const filteredUsers = emailUsers.filter(
} (x) =>
((user.type === "developer" ||
user.type === "admin" ||
user.type === "corporate") &&
(x?.type === "student" || x?.type === "teacher")) ||
(user.type === "teacher" && x?.type === "student"),
);
(group ? axios.patch : axios.post)(group ? `/api/groups/${group.id}` : "/api/groups", {name, admin, participants}) setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id));
.then(() => { toast.success(
toast.success(`Group "${name}" ${group ? "edited" : "created"} successfully`); user.type !== "teacher"
return true; ? "Added all teachers and students found in the file you've provided!"
}) : "Added all students found in the file you've provided!",
.catch(() => { { toastId: "upload-success" },
toast.error("Something went wrong, please try again later!"); );
return false; setIsLoading(false);
}) });
.finally(onClose); }
}; // eslint-disable-next-line react-hooks/exhaustive-deps
}, [filesContent, user.type, users]);
return ( const submit = () => {
<div className="flex flex-col gap-12 mt-4 w-full px-4 py-2"> setIsLoading(true);
<div className="flex flex-col gap-8">
<Input name="name" type="text" label="Name" defaultValue={name} onChange={setName} required disabled={group?.disableEditing} /> if (name !== group?.name && (name === "Students" || name === "Teachers")) {
<div className="flex flex-col gap-3 w-full"> toast.error(
<div className="flex gap-2 items-center"> "That group name is reserved and cannot be used, please enter another one.",
<label className="font-normal text-base text-mti-gray-dim">Participants</label> );
<div className="tooltip" data-tip="The Excel file should only include a column with the desired e-mails."> setIsLoading(false);
<BsQuestionCircleFill /> return;
</div> }
</div>
<div className="flex gap-8 w-full"> (group ? axios.patch : axios.post)(
<Select group ? `/api/groups/${group.id}` : "/api/groups",
className="w-full" { name, admin, participants },
value={participants.map((x) => ({ )
value: x, .then(() => {
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`, toast.success(
}))} `Group "${name}" ${group ? "edited" : "created"} successfully`,
placeholder="Participants..." );
defaultValue={participants.map((x) => ({ return true;
value: x, })
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`, .catch(() => {
}))} toast.error("Something went wrong, please try again later!");
options={users return false;
.filter((x) => (user.type === "teacher" ? x.type === "student" : x.type === "student" || x.type === "teacher")) })
.map((x) => ({value: x.id, label: `${x.email} - ${x.name}`}))} .finally(() => {
onChange={(value) => setParticipants(value.map((x) => x.value))} setIsLoading(false);
isMulti onClose();
isSearchable });
menuPortalTarget={document?.body} };
styles={{
menuPortal: (base) => ({...base, zIndex: 9999}), return (
control: (styles) => ({ <div className="mt-4 flex w-full flex-col gap-12 px-4 py-2">
...styles, <div className="flex flex-col gap-8">
backgroundColor: "white", <Input
borderRadius: "999px", name="name"
padding: "1rem 1.5rem", type="text"
zIndex: "40", label="Name"
}), defaultValue={name}
}} onChange={setName}
/> required
{user.type !== "teacher" && ( disabled={group?.disableEditing}
<Button className="w-full max-w-[300px]" onClick={openFilePicker} variant="outline"> />
{filesContent.length === 0 ? "Upload participants Excel file" : filesContent[0].name} <div className="flex w-full flex-col gap-3">
</Button> <div className="flex items-center gap-2">
)} <label className="text-mti-gray-dim text-base font-normal">
</div> Participants
</div> </label>
</div> <div
<div className="flex w-full justify-end items-center gap-8 mt-8"> className="tooltip"
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={onClose}> data-tip="The Excel file should only include a column with the desired e-mails."
Cancel >
</Button> <BsQuestionCircleFill />
<Button className="w-full max-w-[200px]" onClick={submit} disabled={!name}> </div>
Submit </div>
</Button> <div className="flex w-full gap-8">
</div> <Select
</div> className="w-full"
); value={participants.map((x) => ({
value: x,
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
}))}
placeholder="Participants..."
defaultValue={participants.map((x) => ({
value: x,
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
}))}
options={users
.filter((x) =>
user.type === "teacher"
? x.type === "student"
: x.type === "student" || x.type === "teacher",
)
.map((x) => ({ value: x.id, label: `${x.email} - ${x.name}` }))}
onChange={(value) => setParticipants(value.map((x) => x.value))}
isMulti
isSearchable
menuPortalTarget={document?.body}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
control: (styles) => ({
...styles,
backgroundColor: "white",
borderRadius: "999px",
padding: "1rem 1.5rem",
zIndex: "40",
}),
}}
/>
{user.type !== "teacher" && (
<Button
className="w-full max-w-[300px]"
onClick={openFilePicker}
isLoading={isLoading}
variant="outline"
>
{filesContent.length === 0
? "Upload participants Excel file"
: filesContent[0].name}
</Button>
)}
</div>
</div>
</div>
<div className="mt-8 flex w-full items-center justify-end gap-8">
<Button
variant="outline"
color="red"
className="w-full max-w-[200px]"
isLoading={isLoading}
onClick={onClose}
>
Cancel
</Button>
<Button
className="w-full max-w-[200px]"
onClick={submit}
isLoading={isLoading}
disabled={!name}
>
Submit
</Button>
</div>
</div>
);
}; };
const filterTypes = ["corporate", "teacher"]; const filterTypes = ["corporate", "teacher"];
export default function GroupList({user}: {user: User}) { export default function GroupList({ user }: { user: User }) {
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [editingGroup, setEditingGroup] = useState<Group>(); const [editingGroup, setEditingGroup] = useState<Group>();
const [filterByUser, setFilterByUser] = useState(false); const [filterByUser, setFilterByUser] = useState(false);
const {users} = useUsers(); const { users } = useUsers();
const {groups, reload} = useGroups(user && filterTypes.includes(user?.type) ? user.id : undefined); const { groups, reload } = useGroups(
user && filterTypes.includes(user?.type) ? user.id : undefined,
);
useEffect(() => { useEffect(() => {
if (user && (user.type === "corporate" || user.type === "teacher")) { if (user && (user.type === "corporate" || user.type === "teacher")) {
setFilterByUser(true); setFilterByUser(true);
} }
}, [user]); }, [user]);
const deleteGroup = (group: Group) => { const deleteGroup = (group: Group) => {
if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return; if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return;
axios axios
.delete<{ok: boolean}>(`/api/groups/${group.id}`) .delete<{ ok: boolean }>(`/api/groups/${group.id}`)
.then(() => toast.success(`Group "${group.name}" deleted successfully`)) .then(() => toast.success(`Group "${group.name}" deleted successfully`))
.catch(() => toast.error("Something went wrong, please try again later!")) .catch(() => toast.error("Something went wrong, please try again later!"))
.finally(reload); .finally(reload);
}; };
const defaultColumns = [ const defaultColumns = [
columnHelper.accessor("id", { columnHelper.accessor("id", {
header: "ID", header: "ID",
cell: (info) => info.getValue(), cell: (info) => info.getValue(),
}), }),
columnHelper.accessor("name", { columnHelper.accessor("name", {
header: "Name", header: "Name",
cell: (info) => info.getValue(), cell: (info) => info.getValue(),
}), }),
columnHelper.accessor("admin", { columnHelper.accessor("admin", {
header: "Admin", header: "Admin",
cell: (info) => ( cell: (info) => (
<div className="tooltip" data-tip={capitalize(users.find((x) => x.id === info.getValue())?.type)}> <div
{users.find((x) => x.id === info.getValue())?.name} className="tooltip"
</div> data-tip={capitalize(
), users.find((x) => x.id === info.getValue())?.type,
}), )}
columnHelper.accessor("participants", { >
header: "Participants", {users.find((x) => x.id === info.getValue())?.name}
cell: (info) => </div>
info ),
.getValue() }),
.map((x) => users.find((y) => y.id === x)?.name) columnHelper.accessor("participants", {
.join(", "), header: "Participants",
}), cell: (info) =>
{ info
header: "", .getValue()
id: "actions", .map((x) => users.find((y) => y.id === x)?.name)
cell: ({row}: {row: {original: Group}}) => { .join(", "),
return ( }),
<> {
{user && (user.type === "developer" || user.type === "admin" || user.id === row.original.admin) && ( header: "",
<div className="flex gap-2"> id: "actions",
{!row.original.disableEditing && ( cell: ({ row }: { row: { original: Group } }) => {
<div data-tip="Edit" className="cursor-pointer tooltip" onClick={() => setEditingGroup(row.original)}> return (
<BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" /> <>
</div> {user &&
)} (user.type === "developer" ||
{!row.original.disableEditing && ( user.type === "admin" ||
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteGroup(row.original)}> user.id === row.original.admin) && (
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" /> <div className="flex gap-2">
</div> {(!row.original.disableEditing ||
)} ["developer", "admin"].includes(user.type)) && (
</div> <div
)} data-tip="Edit"
</> className="tooltip cursor-pointer"
); onClick={() => setEditingGroup(row.original)}
}, >
}, <BsPencil className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
]; </div>
)}
{(!row.original.disableEditing ||
["developer", "admin"].includes(user.type)) && (
<div
data-tip="Delete"
className="tooltip cursor-pointer"
onClick={() => deleteGroup(row.original)}
>
<BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
</div>
)}
</div>
)}
</>
);
},
},
];
const table = useReactTable({ const table = useReactTable({
data: groups, data: groups,
columns: defaultColumns, columns: defaultColumns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}); });
const closeModal = () => { const closeModal = () => {
setIsCreating(false); setIsCreating(false);
setEditingGroup(undefined); setEditingGroup(undefined);
reload(); reload();
}; };
return ( return (
<div className="w-full h-full rounded-xl"> <div className="h-full w-full rounded-xl">
<Modal isOpen={isCreating || !!editingGroup} onClose={closeModal} title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}> <Modal
<CreatePanel isOpen={isCreating || !!editingGroup}
group={editingGroup} onClose={closeModal}
user={user} title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}
onClose={closeModal} >
users={ <CreatePanel
user?.type === "corporate" || user?.type === "teacher" group={editingGroup}
? users.filter( user={user}
(u) => onClose={closeModal}
groups users={
.filter((g) => g.admin === user.id) user?.type === "corporate" || user?.type === "teacher"
.flatMap((g) => g.participants) ? users.filter(
.includes(u.id) || groups.flatMap((g) => g.participants).includes(u.id), (u) =>
) groups
: users .filter((g) => g.admin === user.id)
} .flatMap((g) => g.participants)
/> .includes(u.id) ||
</Modal> groups.flatMap((g) => g.participants).includes(u.id),
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full"> )
<thead> : users
{table.getHeaderGroups().map((headerGroup) => ( }
<tr key={headerGroup.id}> />
{headerGroup.headers.map((header) => ( </Modal>
<th className="py-4" key={header.id}> <table className="bg-mti-purple-ultralight/40 w-full rounded-xl">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} <thead>
</th> {table.getHeaderGroups().map((headerGroup) => (
))} <tr key={headerGroup.id}>
</tr> {headerGroup.headers.map((header) => (
))} <th className="py-4" key={header.id}>
</thead> {header.isPlaceholder
<tbody className="px-2"> ? null
{table.getRowModel().rows.map((row) => ( : flexRender(
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}> header.column.columnDef.header,
{row.getVisibleCells().map((cell) => ( header.getContext(),
<td className="px-4 py-2" key={cell.id}> )}
{flexRender(cell.column.columnDef.cell, cell.getContext())} </th>
</td> ))}
))} </tr>
</tr> ))}
))} </thead>
</tbody> <tbody className="px-2">
</table> {table.getRowModel().rows.map((row) => (
<tr
className="even:bg-mti-purple-ultralight/40 rounded-lg py-2 odd:bg-white"
key={row.id}
>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
<button <button
onClick={() => setIsCreating(true)} onClick={() => setIsCreating(true)}
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white"> className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out"
New Group >
</button> New Group
</div> </button>
); </div>
);
} }

View File

@@ -1,332 +1,441 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Head from "next/head"; import { Module } from "@/interfaces";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import {Module} from "@/interfaces";
import Selection from "@/exams/Selection";
import Reading from "@/exams/Reading";
import {Exam, InteractiveSpeakingExercise, SpeakingExercise, UserSolution, Variant, WritingExercise} from "@/interfaces/exam";
import Listening from "@/exams/Listening";
import Writing from "@/exams/Writing";
import {ToastContainer, toast} from "react-toastify";
import Finish from "@/exams/Finish";
import axios from "axios";
import {Stat} from "@/interfaces/user";
import Speaking from "@/exams/Speaking";
import {v4 as uuidv4} from "uuid";
import useUser from "@/hooks/useUser";
import useExamStore from "@/stores/examStore";
import Layout from "@/components/High/Layout";
import AbandonPopup from "@/components/AbandonPopup"; import AbandonPopup from "@/components/AbandonPopup";
import {evaluateSpeakingAnswer, evaluateWritingAnswer} from "@/utils/evaluation"; import Layout from "@/components/High/Layout";
import {useRouter} from "next/router"; import Finish from "@/exams/Finish";
import {getExam} from "@/utils/exams";
import {capitalize} from "lodash";
import Level from "@/exams/Level"; import Level from "@/exams/Level";
import Listening from "@/exams/Listening";
import Reading from "@/exams/Reading";
import Selection from "@/exams/Selection";
import Speaking from "@/exams/Speaking";
import Writing from "@/exams/Writing";
import useUser from "@/hooks/useUser";
import { Exam, UserSolution, Variant } from "@/interfaces/exam";
import { Stat } from "@/interfaces/user";
import useExamStore from "@/stores/examStore";
import {
evaluateSpeakingAnswer,
evaluateWritingAnswer,
} from "@/utils/evaluation";
import { getExam } from "@/utils/exams";
import axios from "axios";
import { useRouter } from "next/router";
import { toast, ToastContainer } from "react-toastify";
import { v4 as uuidv4 } from "uuid";
interface Props { interface Props {
page: "exams" | "exercises"; page: "exams" | "exercises";
} }
export default function ExamPage({page}: Props) { export default function ExamPage({ page }: Props) {
const [hasBeenUploaded, setHasBeenUploaded] = useState(false); const [hasBeenUploaded, setHasBeenUploaded] = useState(false);
const [moduleIndex, setModuleIndex] = useState(0); const [moduleIndex, setModuleIndex] = useState(0);
const [sessionId, setSessionId] = useState(""); const [sessionId, setSessionId] = useState("");
const [exam, setExam] = useState<Exam>(); const [exam, setExam] = useState<Exam>();
const [isEvaluationLoading, setIsEvaluationLoading] = useState(false); const [isEvaluationLoading, setIsEvaluationLoading] = useState(false);
const [showAbandonPopup, setShowAbandonPopup] = useState(false); const [showAbandonPopup, setShowAbandonPopup] = useState(false);
const [avoidRepeated, setAvoidRepeated] = useState(false); const [avoidRepeated, setAvoidRepeated] = useState(false);
const [timeSpent, setTimeSpent] = useState(0); const [timeSpent, setTimeSpent] = useState(0);
const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<string[]>([]); const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<
const [variant, setVariant] = useState<Variant>("full"); string[]
>([]);
const [variant, setVariant] = useState<Variant>("full");
const [exams, setExams] = useExamStore((state) => [state.exams, state.setExams]); const [exams, setExams] = useExamStore((state) => [
const [userSolutions, setUserSolutions] = useExamStore((state) => [state.userSolutions, state.setUserSolutions]); state.exams,
const [showSolutions, setShowSolutions] = useExamStore((state) => [state.showSolutions, state.setShowSolutions]); state.setExams,
const [selectedModules, setSelectedModules] = useExamStore((state) => [state.selectedModules, state.setSelectedModules]); ]);
const assignment = useExamStore((state) => state.assignment); const [userSolutions, setUserSolutions] = useExamStore((state) => [
state.userSolutions,
state.setUserSolutions,
]);
const [showSolutions, setShowSolutions] = useExamStore((state) => [
state.showSolutions,
state.setShowSolutions,
]);
const [selectedModules, setSelectedModules] = useExamStore((state) => [
state.selectedModules,
state.setSelectedModules,
]);
const assignment = useExamStore((state) => state.assignment);
const {user} = useUser({redirectTo: "/login"}); const { user } = useUser({ redirectTo: "/login" });
const router = useRouter(); const router = useRouter();
useEffect(() => setSessionId(uuidv4()), []); useEffect(() => setSessionId(uuidv4()), []);
useEffect(() => { useEffect(() => {
if (user?.type === "developer") console.log(exam); if (user?.type === "developer") console.log(exam);
}, [exam, user]); }, [exam, user]);
useEffect(() => { useEffect(() => {
selectedModules.length > 0 && timeSpent === 0 && !showSolutions; selectedModules.length > 0 && timeSpent === 0 && !showSolutions;
if (selectedModules.length > 0 && timeSpent === 0 && !showSolutions) { if (selectedModules.length > 0 && timeSpent === 0 && !showSolutions) {
const timerInterval = setInterval(() => { const timerInterval = setInterval(() => {
setTimeSpent((prev) => prev + 1); setTimeSpent((prev) => prev + 1);
}, 1000); }, 1000);
return () => { return () => {
clearInterval(timerInterval); clearInterval(timerInterval);
}; };
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules.length]); }, [selectedModules.length]);
useEffect(() => { useEffect(() => {
if (showSolutions) setModuleIndex(-1); if (showSolutions) setModuleIndex(-1);
}, [showSolutions]); }, [showSolutions]);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) { if (
const nextExam = exams[moduleIndex]; selectedModules.length > 0 &&
setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined); exams.length > 0 &&
} moduleIndex < selectedModules.length
})(); ) {
// eslint-disable-next-line react-hooks/exhaustive-deps const nextExam = exams[moduleIndex];
}, [selectedModules, moduleIndex, exams]); setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules, moduleIndex, exams]);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (selectedModules.length > 0 && exams.length === 0) { if (selectedModules.length > 0 && exams.length === 0) {
const examPromises = selectedModules.map((module) => getExam(module, avoidRepeated, variant)); const examPromises = selectedModules.map((module) =>
Promise.all(examPromises).then((values) => { getExam(module, avoidRepeated, variant),
if (values.every((x) => !!x)) { );
setExams(values.map((x) => x!)); Promise.all(examPromises).then((values) => {
} else { if (values.every((x) => !!x)) {
toast.error("Something went wrong, please try again"); setExams(values.map((x) => x!));
setTimeout(router.reload, 500); } else {
} toast.error("Something went wrong, please try again");
}); setTimeout(router.reload, 500);
} }
})(); });
// eslint-disable-next-line react-hooks/exhaustive-deps }
}, [selectedModules, setExams, exams]); })();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules, setExams, exams]);
useEffect(() => { useEffect(() => {
if (selectedModules.length > 0 && exams.length !== 0 && moduleIndex >= selectedModules.length && !hasBeenUploaded && !showSolutions) { if (
const newStats: Stat[] = userSolutions.map((solution) => ({ selectedModules.length > 0 &&
...solution, exams.length !== 0 &&
id: solution.id || uuidv4(), moduleIndex >= selectedModules.length &&
timeSpent, !hasBeenUploaded &&
session: sessionId, !showSolutions
exam: solution.exam!, ) {
module: solution.module!, const newStats: Stat[] = userSolutions.map((solution) => ({
user: user?.id || "", ...solution,
date: new Date().getTime(), id: solution.id || uuidv4(),
...(assignment ? {assignment: assignment.id} : {}), timeSpent,
})); session: sessionId,
exam: solution.exam!,
module: solution.module!,
user: user?.id || "",
date: new Date().getTime(),
...(assignment ? { assignment: assignment.id } : {}),
}));
axios axios
.post<{ok: boolean}>("/api/stats", newStats) .post<{ ok: boolean }>("/api/stats", newStats)
.then((response) => setHasBeenUploaded(response.data.ok)) .then((response) => setHasBeenUploaded(response.data.ok))
.catch(() => setHasBeenUploaded(false)); .catch(() => setHasBeenUploaded(false));
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules, moduleIndex, hasBeenUploaded]); }, [selectedModules, moduleIndex, hasBeenUploaded]);
useEffect(() => { useEffect(() => {
if (statsAwaitingEvaluation.length === 0) return setIsEvaluationLoading(false); setIsEvaluationLoading(statsAwaitingEvaluation.length !== 0);
return setIsEvaluationLoading(true); }, [statsAwaitingEvaluation]);
}, [statsAwaitingEvaluation]);
useEffect(() => { useEffect(() => {
if (statsAwaitingEvaluation.length > 0) { if (statsAwaitingEvaluation.length > 0) {
statsAwaitingEvaluation.forEach(checkIfStatHasBeenEvaluated); checkIfStatsHaveBeenEvaluated(statsAwaitingEvaluation);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [statsAwaitingEvaluation]); }, [statsAwaitingEvaluation]);
const checkIfStatHasBeenEvaluated = (id: string) => { const checkIfStatsHaveBeenEvaluated = (ids: string[]) => {
setTimeout(async () => { setTimeout(async () => {
const statRequest = await axios.get<Stat>(`/api/stats/${id}`); const awaitedStats = await Promise.all(
const stat = statRequest.data; ids.map(async (id) => (await axios.get<Stat>(`/api/stats/${id}`)).data),
if (stat.solutions.every((x) => x.evaluation !== null)) { );
const userSolution: UserSolution = { const solutionsEvaluated = awaitedStats.every((stat) =>
id, stat.solutions.every((x) => x.evaluation !== null),
exercise: stat.exercise, );
score: stat.score, if (solutionsEvaluated) {
solutions: stat.solutions, const statsUserSolutions: UserSolution[] = awaitedStats.map((stat) => ({
type: stat.type, id: stat.id,
exam: stat.exam, exercise: stat.exercise,
module: stat.module, score: stat.score,
}; solutions: stat.solutions,
type: stat.type,
exam: stat.exam,
module: stat.module,
}));
setUserSolutions(userSolutions.map((x) => (x.exercise === userSolution.exercise ? userSolution : x))); const updatedUserSolutions = userSolutions.map((x) => {
return setStatsAwaitingEvaluation((prev) => prev.filter((x) => x !== id)); const respectiveSolution = statsUserSolutions.find(
} (y) => y.exercise === x.exercise,
);
return respectiveSolution ? respectiveSolution : x;
});
return checkIfStatHasBeenEvaluated(id); setUserSolutions(updatedUserSolutions);
}, 5 * 1000); return setStatsAwaitingEvaluation((prev) =>
}; prev.filter((x) => !ids.includes(x)),
);
}
const updateExamWithUserSolutions = (exam: Exam): Exam => { return checkIfStatsHaveBeenEvaluated(ids);
if (exam.module === "reading" || exam.module === "listening") { }, 5 * 1000);
const parts = exam.parts.map((p) => };
Object.assign(p, {
exercises: p.exercises.map((x) => Object.assign(x, {userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions})),
}),
);
return Object.assign(exam, {parts});
}
const exercises = exam.exercises.map((x) => Object.assign(x, {userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions})); const updateExamWithUserSolutions = (exam: Exam): Exam => {
return Object.assign(exam, {exercises}); if (exam.module === "reading" || exam.module === "listening") {
}; const parts = exam.parts.map((p) =>
Object.assign(p, {
exercises: p.exercises.map((x) =>
Object.assign(x, {
userSolutions: userSolutions.find((y) => x.id === y.exercise)
?.solutions,
}),
),
}),
);
return Object.assign(exam, { parts });
}
const onFinish = (solutions: UserSolution[]) => { const exercises = exam.exercises.map((x) =>
const solutionIds = solutions.map((x) => x.exercise); Object.assign(x, {
const solutionExams = solutions.map((x) => x.exam); userSolutions: userSolutions.find((y) => x.id === y.exercise)
?.solutions,
}),
);
return Object.assign(exam, { exercises });
};
if (exam && !solutionExams.includes(exam.id)) return; const onFinish = (solutions: UserSolution[]) => {
const solutionIds = solutions.map((x) => x.exercise);
const solutionExams = solutions.map((x) => x.exam);
if (exam && (exam.module === "writing" || exam.module === "speaking") && solutions.length > 0 && !showSolutions) { if (exam && !solutionExams.includes(exam.id)) return;
setHasBeenUploaded(true);
setIsEvaluationLoading(true);
Promise.all( if (
exam.exercises.map(async (exercise) => { exam &&
const evaluationID = uuidv4(); (exam.module === "writing" || exam.module === "speaking") &&
if (exercise.type === "writing") solutions.length > 0 &&
return await evaluateWritingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!, evaluationID); !showSolutions
) {
setHasBeenUploaded(true);
setIsEvaluationLoading(true);
if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking") Promise.all(
return await evaluateSpeakingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!, evaluationID); exam.exercises.map(async (exercise) => {
}), const evaluationID = uuidv4();
) if (exercise.type === "writing")
.then((responses) => { return await evaluateWritingAnswer(
setStatsAwaitingEvaluation((prev) => [...prev, ...responses.filter((x) => !!x).map((r) => (r as any).id)]); exercise,
setUserSolutions([...userSolutions, ...responses.filter((x) => !!x)] as any); solutions.find((x) => x.exercise === exercise.id)!,
}) evaluationID,
.finally(() => { );
setHasBeenUploaded(false);
});
}
axios.get("/api/stats/update"); if (
exercise.type === "interactiveSpeaking" ||
exercise.type === "speaking"
)
return await evaluateSpeakingAnswer(
exercise,
solutions.find((x) => x.exercise === exercise.id)!,
evaluationID,
);
}),
)
.then((responses) => {
setStatsAwaitingEvaluation((prev) => [
...prev,
...responses.filter((x) => !!x).map((r) => (r as any).id),
]);
setUserSolutions([
...userSolutions,
...responses.filter((x) => !!x),
] as any);
})
.finally(() => {
setHasBeenUploaded(false);
});
}
setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...solutions]); axios.get("/api/stats/update");
setModuleIndex((prev) => prev + 1);
};
const aggregateScoresByModule = (answers: UserSolution[]): {module: Module; total: number; missing: number; correct: number}[] => { setUserSolutions([
const scores: {[key in Module]: {total: number; missing: number; correct: number}} = { ...userSolutions.filter((x) => !solutionIds.includes(x.exercise)),
reading: { ...solutions,
total: 0, ]);
correct: 0, setModuleIndex((prev) => prev + 1);
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,
},
};
answers.forEach((x) => { const aggregateScoresByModule = (
scores[x.module!] = { answers: UserSolution[],
total: scores[x.module!].total + x.score.total, ): { module: Module; total: number; missing: number; correct: number }[] => {
correct: scores[x.module!].correct + x.score.correct, const scores: {
missing: scores[x.module!].missing + x.score.missing, [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,
},
};
return Object.keys(scores) answers.forEach((x) => {
.filter((x) => scores[x as Module].total > 0) scores[x.module!] = {
.map((x) => ({module: x as Module, ...scores[x as 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,
};
});
const renderScreen = () => { return Object.keys(scores)
if (selectedModules.length === 0) { .filter((x) => scores[x as Module].total > 0)
return ( .map((x) => ({ module: x as Module, ...scores[x as Module] }));
<Selection };
page={page}
user={user!}
disableSelection={page === "exams"}
onStart={(modules: Module[], avoid: boolean, variant: Variant) => {
setModuleIndex(0);
setAvoidRepeated(avoid);
setSelectedModules(modules);
setVariant(variant);
}}
/>
);
}
if (moduleIndex >= selectedModules.length || moduleIndex === -1) { const renderScreen = () => {
return ( if (selectedModules.length === 0) {
<Finish return (
isLoading={isEvaluationLoading} <Selection
user={user!} page={page}
modules={selectedModules} user={user!}
onViewResults={() => { disableSelection={page === "exams"}
setShowSolutions(true); onStart={(modules: Module[], avoid: boolean, variant: Variant) => {
setModuleIndex(0); setModuleIndex(0);
setExam(exams[0]); setAvoidRepeated(avoid);
}} setSelectedModules(modules);
scores={aggregateScoresByModule(userSolutions)} setVariant(variant);
/> }}
); />
} );
}
if (exam && exam.module === "reading") { if (moduleIndex >= selectedModules.length || moduleIndex === -1) {
return <Reading exam={exam} onFinish={onFinish} showSolutions={showSolutions} />; return (
} <Finish
isLoading={isEvaluationLoading}
user={user!}
modules={selectedModules}
onViewResults={() => {
setShowSolutions(true);
setModuleIndex(0);
setExam(exams[0]);
}}
scores={aggregateScoresByModule(userSolutions)}
/>
);
}
if (exam && exam.module === "listening") { if (exam && exam.module === "reading") {
return <Listening exam={exam} onFinish={onFinish} showSolutions={showSolutions} />; return (
} <Reading
exam={exam}
onFinish={onFinish}
showSolutions={showSolutions}
/>
);
}
if (exam && exam.module === "writing") { if (exam && exam.module === "listening") {
return <Writing exam={exam} onFinish={onFinish} showSolutions={showSolutions} />; return (
} <Listening
exam={exam}
onFinish={onFinish}
showSolutions={showSolutions}
/>
);
}
if (exam && exam.module === "speaking") { if (exam && exam.module === "writing") {
return <Speaking exam={exam} onFinish={onFinish} showSolutions={showSolutions} />; return (
} <Writing
exam={exam}
onFinish={onFinish}
showSolutions={showSolutions}
/>
);
}
if (exam && exam.module === "level") { if (exam && exam.module === "speaking") {
return <Level exam={exam} onFinish={onFinish} showSolutions={showSolutions} />; return (
} <Speaking
exam={exam}
onFinish={onFinish}
showSolutions={showSolutions}
/>
);
}
return <>Loading...</>; if (exam && exam.module === "level") {
}; return (
<Level exam={exam} onFinish={onFinish} showSolutions={showSolutions} />
);
}
return ( return <>Loading...</>;
<> };
<ToastContainer />
{user && ( return (
<Layout <>
user={user} <ToastContainer />
className="justify-between" {user && (
focusMode={selectedModules.length !== 0 && !showSolutions && moduleIndex < selectedModules.length} <Layout
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}> user={user}
<> className="justify-between"
{renderScreen()} focusMode={
{!showSolutions && moduleIndex < selectedModules.length && ( selectedModules.length !== 0 &&
<AbandonPopup !showSolutions &&
isOpen={showAbandonPopup} moduleIndex < selectedModules.length
abandonPopupTitle="Leave Exercise" }
abandonPopupDescription="Are you sure you want to leave the exercise? You will lose all your progress." onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}
abandonConfirmButtonText="Confirm" >
onAbandon={() => router.reload()} <>
onCancel={() => setShowAbandonPopup(false)} {renderScreen()}
/> {!showSolutions && moduleIndex < selectedModules.length && (
)} <AbandonPopup
</> isOpen={showAbandonPopup}
</Layout> abandonPopupTitle="Leave Exercise"
)} abandonPopupDescription="Are you sure you want to leave the exercise? You will lose all your progress."
</> abandonConfirmButtonText="Confirm"
); onAbandon={() => router.reload()}
onCancel={() => setShowAbandonPopup(false)}
/>
)}
</>
</Layout>
)}
</>
);
} }

View File

@@ -1,215 +1,279 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
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 {Divider} from "primereact/divider"; import { Divider } from "primereact/divider";
import {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 Select from "react-select"; import Select from "react-select";
import moment from "moment"; import moment from "moment";
interface Props { interface Props {
isLoading: boolean; isLoading: boolean;
setIsLoading: (isLoading: boolean) => void; setIsLoading: (isLoading: boolean) => void;
mutateUser: KeyedMutator<User>; mutateUser: KeyedMutator<User>;
sendEmailVerification: typeof sendEmailVerification; sendEmailVerification: typeof sendEmailVerification;
} }
const availableDurations = { const availableDurations = {
"1_month": {label: "1 Month", number: 1}, "1_month": { label: "1 Month", number: 1 },
"3_months": {label: "3 Months", number: 3}, "3_months": { label: "3 Months", number: 3 },
"6_months": {label: "6 Months", number: 6}, "6_months": { label: "6 Months", number: 6 },
"12_months": {label: "12 Months", number: 12}, "12_months": { label: "12 Months", number: 12 },
}; };
export default function RegisterCorporate({isLoading, setIsLoading, mutateUser, sendEmailVerification}: Props) { export default function RegisterCorporate({
const [name, setName] = useState(""); isLoading,
const [email, setEmail] = useState(""); setIsLoading,
const [password, setPassword] = useState(""); mutateUser,
const [confirmPassword, setConfirmPassword] = useState(""); sendEmailVerification,
const [referralAgent, setReferralAgent] = useState<string | undefined>(); }: Props) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [referralAgent, setReferralAgent] = useState<string | undefined>();
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 {users} = useUsers(); const { users } = useUsers();
const onSuccess = () => toast.success("An e-mail has been sent, please make sure to check your spam folder!"); const onSuccess = () =>
toast.success(
"An e-mail has been sent, please make sure to check your spam folder!",
);
const onError = (e: Error) => { const onError = (e: Error) => {
console.error(e); console.error(e);
toast.error("Something went wrong, please logout and re-login.", {toastId: "send-verify-error"}); toast.error("Something went wrong, please logout and re-login.", {
}; toastId: "send-verify-error",
});
};
const register = (e: any) => { const register = (e: any) => {
e.preventDefault(); e.preventDefault();
if (confirmPassword !== password) { if (confirmPassword !== password) {
toast.error("Your passwords do not match!", {toastId: "password-not-match"}); toast.error("Your passwords do not match!", {
return; toastId: "password-not-match",
} });
return;
}
setIsLoading(true); setIsLoading(true);
axios axios
.post("/api/register", { .post("/api/register", {
name, name,
email, email,
password, password,
type: "corporate", type: "corporate",
profilePicture: "/defaultAvatar.png", profilePicture: "/defaultAvatar.png",
subscriptionExpirationDate: moment().subtract(1, "days").toISOString(), subscriptionExpirationDate: moment().subtract(1, "days").toISOString(),
corporateInformation: { corporateInformation: {
companyInformation: { companyInformation: {
name: companyName, name: companyName,
userAmount: companyUsers, userAmount: companyUsers,
}, },
monthlyDuration: subscriptionDuration, monthlyDuration: subscriptionDuration,
referralAgent, referralAgent,
}, },
}) })
.then((response) => { .then((response) => {
mutateUser(response.data.user).then(() => sendEmailVerification(setIsLoading, onSuccess, onError)); mutateUser(response.data.user).then(() =>
}) sendEmailVerification(setIsLoading, onSuccess, onError),
.catch((error) => { );
console.log(error.response.data); })
.catch((error) => {
console.log(error.response.data);
if (error.response.status === 401) { if (error.response.status === 401) {
toast.error("There is already a user with that e-mail!"); toast.error("There is already a user with that e-mail!");
return; return;
} }
if (error.response.status === 400) { if (error.response.status === 400) {
toast.error("The provided code is invalid!"); toast.error("The provided code is invalid!");
return; return;
} }
toast.error("There was something wrong, please try again!"); toast.error("There was something wrong, please try again!");
}) })
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
return ( return (
<form className="flex flex-col items-center gap-4 w-full" onSubmit={register}> <form
<div className="w-full flex gap-4"> className="flex w-full flex-col items-center gap-4"
<Input type="text" name="name" onChange={(e) => setName(e)} placeholder="Enter your name" defaultValue={name} required /> onSubmit={register}
<Input type="email" name="email" onChange={(e) => setEmail(e)} placeholder="Enter email address" defaultValue={email} required /> >
</div> <div className="flex w-full gap-4">
<Input
type="text"
name="name"
onChange={(e) => setName(e)}
placeholder="Enter your name"
defaultValue={name}
required
/>
<Input
type="email"
name="email"
onChange={(e) => setEmail(e.toLowerCase())}
placeholder="Enter email address"
defaultValue={email}
required
/>
</div>
<div className="w-full flex gap-4"> <div className="flex w-full gap-4">
<Input <Input
type="password" type="password"
name="password" name="password"
onChange={(e) => setPassword(e)} onChange={(e) => setPassword(e)}
placeholder="Enter your password" placeholder="Enter your password"
defaultValue={password} defaultValue={password}
required required
/> />
<Input <Input
type="password" type="password"
name="confirmPassword" name="confirmPassword"
onChange={(e) => setConfirmPassword(e)} onChange={(e) => setConfirmPassword(e)}
placeholder="Confirm your password" placeholder="Confirm your password"
defaultValue={confirmPassword} defaultValue={confirmPassword}
required required
/> />
</div> </div>
<Divider className="w-full !my-2" /> <Divider className="!my-2 w-full" />
<div className="w-full flex gap-4"> <div className="flex w-full gap-4">
<Input <Input
type="text" type="text"
name="companyName" name="companyName"
onChange={(e) => setCompanyName(e)} onChange={(e) => setCompanyName(e)}
placeholder="Corporate name" placeholder="Corporate name"
label="Corporate name" label="Corporate name"
defaultValue={companyName} defaultValue={companyName}
required required
/> />
<Input <Input
type="number" type="number"
name="companyUsers" name="companyUsers"
onChange={(e) => setCompanyUsers(parseInt(e))} onChange={(e) => setCompanyUsers(parseInt(e))}
label="Number of users" label="Number of users"
defaultValue={companyUsers} defaultValue={companyUsers}
required required
/> />
</div> </div>
<div className="w-full flex gap-4"> <div className="flex w-full gap-4">
<div className="flex flex-col gap-3 w-full"> <div className="flex w-full flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Referral *</label> <label className="text-mti-gray-dim text-base font-normal">
<Select Referral *
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none" </label>
options={[ <Select
{value: "", label: "No referral"}, className="placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none disabled:cursor-not-allowed"
...users.filter((u) => u.type === "agent").map((x) => ({value: x.id, label: `${x.name} - ${x.email}`})), options={[
]} { value: "", label: "No referral" },
defaultValue={{value: "", label: "No referral"}} ...users
onChange={(value) => setReferralAgent(value?.value)} .filter((u) => u.type === "agent")
styles={{ .map((x) => ({ value: x.id, label: `${x.name} - ${x.email}` })),
control: (styles) => ({ ]}
...styles, defaultValue={{ value: "", label: "No referral" }}
paddingLeft: "4px", onChange={(value) => setReferralAgent(value?.value)}
border: "none", styles={{
outline: "none", control: (styles) => ({
":focus": { ...styles,
outline: "none", paddingLeft: "4px",
}, border: "none",
}), outline: "none",
option: (styles, state) => ({ ":focus": {
...styles, outline: "none",
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", },
color: state.isFocused ? "black" : styles.color, }),
}), option: (styles, state) => ({
}} ...styles,
/> backgroundColor: state.isFocused
</div> ? "#D5D9F0"
: state.isSelected
? "#7872BF"
: "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
</div>
<div className="flex flex-col gap-3 w-full"> <div className="flex w-full flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Subscription Duration *</label> <label className="text-mti-gray-dim text-base font-normal">
<Select Subscription Duration *
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none" </label>
options={Object.keys(availableDurations).map((value) => ({ <Select
value, className="placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none disabled:cursor-not-allowed"
label: availableDurations[value as keyof typeof availableDurations].label, options={Object.keys(availableDurations).map((value) => ({
}))} value,
defaultValue={{value: "1_month", label: availableDurations["1_month"].label}} label:
onChange={(value) => availableDurations[value as keyof typeof availableDurations]
setSubscriptionDuration(value ? availableDurations[value.value as keyof typeof availableDurations].number : 1) .label,
} }))}
styles={{ defaultValue={{
control: (styles) => ({ value: "1_month",
...styles, label: availableDurations["1_month"].label,
paddingLeft: "4px", }}
border: "none", onChange={(value) =>
outline: "none", setSubscriptionDuration(
":focus": { value
outline: "none", ? availableDurations[
}, value.value as keyof typeof availableDurations
}), ].number
option: (styles, state) => ({ : 1,
...styles, )
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", }
color: state.isFocused ? "black" : styles.color, styles={{
}), control: (styles) => ({
}} ...styles,
/> paddingLeft: "4px",
</div> border: "none",
</div> outline: "none",
":focus": {
outline: "none",
},
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused
? "#D5D9F0"
: state.isSelected
? "#7872BF"
: "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
</div>
</div>
<Button <Button
className="lg:mt-8 w-full" className="w-full lg:mt-8"
color="purple" color="purple"
disabled={ disabled={
isLoading || !email || !name || !password || !confirmPassword || password !== confirmPassword || !companyName || companyUsers <= 0 isLoading ||
}> !email ||
Create account !name ||
</Button> !password ||
</form> !confirmPassword ||
); password !== confirmPassword ||
!companyName ||
companyUsers <= 0
}
>
Create account
</Button>
</form>
);
} }

View File

@@ -1,132 +1,167 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import Input from "@/components/Low/Input"; 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 { useEffect, useState } from "react";
import {toast} from "react-toastify"; import { toast } from "react-toastify";
import {KeyedMutator} from "swr"; import { KeyedMutator } from "swr";
interface Props { interface Props {
queryCode?: string; queryCode?: string;
defaultInformation?: { defaultInformation?: {
email: string; email: string;
name: string; name: string;
passport_id?: string; passport_id?: string;
}; };
isLoading: boolean; isLoading: boolean;
setIsLoading: (isLoading: boolean) => void; setIsLoading: (isLoading: boolean) => void;
mutateUser: KeyedMutator<User>; mutateUser: KeyedMutator<User>;
sendEmailVerification: typeof sendEmailVerification; sendEmailVerification: typeof sendEmailVerification;
} }
export default function RegisterIndividual({queryCode, defaultInformation, isLoading, setIsLoading, mutateUser, sendEmailVerification}: Props) { export default function RegisterIndividual({
const [name, setName] = useState(defaultInformation?.name || ""); queryCode,
const [email, setEmail] = useState(defaultInformation?.email || ""); defaultInformation,
const [password, setPassword] = useState(""); isLoading,
const [confirmPassword, setConfirmPassword] = useState(""); setIsLoading,
const [code, setCode] = useState(queryCode || ""); mutateUser,
const [hasCode, setHasCode] = useState<boolean>(!!queryCode); sendEmailVerification,
}: Props) {
const [name, setName] = useState(defaultInformation?.name || "");
const [email, setEmail] = useState(defaultInformation?.email || "");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [code, setCode] = useState(queryCode || "");
const [hasCode, setHasCode] = useState<boolean>(!!queryCode);
const onSuccess = () => toast.success("An e-mail has been sent, please make sure to check your spam folder!"); const onSuccess = () =>
toast.success(
"An e-mail has been sent, please make sure to check your spam folder!",
);
const onError = (e: Error) => { const onError = (e: Error) => {
console.error(e); console.error(e);
toast.error("Something went wrong, please logout and re-login.", {toastId: "send-verify-error"}); toast.error("Something went wrong, please logout and re-login.", {
}; toastId: "send-verify-error",
});
};
const register = (e: any) => { const register = (e: any) => {
e.preventDefault(); e.preventDefault();
if (confirmPassword !== password) { if (confirmPassword !== password) {
toast.error("Your passwords do not match!", {toastId: "password-not-match"}); toast.error("Your passwords do not match!", {
return; toastId: "password-not-match",
} });
return;
}
setIsLoading(true); setIsLoading(true);
axios axios
.post("/api/register", { .post("/api/register", {
name, name,
email, email,
password, password,
type: "individual", type: "individual",
code, code,
passport_id: defaultInformation?.passport_id, passport_id: defaultInformation?.passport_id,
profilePicture: "/defaultAvatar.png", profilePicture: "/defaultAvatar.png",
}) })
.then((response) => { .then((response) => {
mutateUser(response.data.user).then(() => sendEmailVerification(setIsLoading, onSuccess, onError)); mutateUser(response.data.user).then(() =>
}) sendEmailVerification(setIsLoading, onSuccess, onError),
.catch((error) => { );
console.log(error.response.data); })
.catch((error) => {
console.log(error.response.data);
if (error.response.status === 401) { if (error.response.status === 401) {
toast.error("There is already a user with that e-mail!"); toast.error("There is already a user with that e-mail!");
return; return;
} }
if (error.response.status === 400) { if (error.response.status === 400) {
toast.error("The provided code is invalid!"); toast.error("The provided code is invalid!");
return; return;
} }
toast.error("There was something wrong, please try again!"); toast.error("There was something wrong, please try again!");
}) })
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
return ( return (
<form className="flex flex-col items-center gap-6 w-full" onSubmit={register}> <form
<Input type="text" name="name" onChange={(e) => setName(e)} placeholder="Enter your name" value={name} required /> className="flex w-full flex-col items-center gap-6"
<Input onSubmit={register}
type="email" >
name="email" <Input
onChange={(e) => setEmail(e)} type="text"
placeholder="Enter email address" name="name"
value={email} onChange={(e) => setName(e)}
disabled={!!defaultInformation?.email} placeholder="Enter your name"
required value={name}
/> required
<Input />
type="password" <Input
name="password" type="email"
onChange={(e) => setPassword(e)} name="email"
placeholder="Enter your password" onChange={(e) => setEmail(e.toLowerCase())}
defaultValue={password} placeholder="Enter email address"
required value={email}
/> disabled={!!defaultInformation?.email}
<Input required
type="password" />
name="confirmPassword" <Input
onChange={(e) => setConfirmPassword(e)} type="password"
placeholder="Confirm your password" name="password"
defaultValue={confirmPassword} onChange={(e) => setPassword(e)}
required placeholder="Enter your password"
/> defaultValue={password}
required
/>
<Input
type="password"
name="confirmPassword"
onChange={(e) => setConfirmPassword(e)}
placeholder="Confirm your password"
defaultValue={confirmPassword}
required
/>
<div className="flex flex-col gap-4 w-full items-start"> <div className="flex w-full flex-col items-start gap-4">
<Checkbox isChecked={hasCode} onChange={setHasCode}> <Checkbox isChecked={hasCode} onChange={setHasCode}>
I have a code I have a code
</Checkbox> </Checkbox>
{hasCode && ( {hasCode && (
<Input <Input
type="text" type="text"
name="code" name="code"
onChange={(e) => setCode(e)} onChange={(e) => setCode(e)}
placeholder="Enter your registration code (optional)" placeholder="Enter your registration code (optional)"
defaultValue={code} defaultValue={code}
required required
/> />
)} )}
</div> </div>
<Button <Button
className="lg:mt-8 w-full" className="w-full lg:mt-8"
color="purple" color="purple"
disabled={isLoading || !email || !name || !password || !confirmPassword || password !== confirmPassword || (hasCode ? !code : false)}> disabled={
Create account isLoading ||
</Button> !email ||
</form> !name ||
); !password ||
!confirmPassword ||
password !== confirmPassword ||
(hasCode ? !code : false)
}
>
Create account
</Button>
</form>
);
} }

View File

@@ -26,7 +26,7 @@ export function getServerSideProps({query, res}: {query: {oobCode: string; mode:
code: query.oobCode, code: query.oobCode,
mode: query.mode, mode: query.mode,
apiKey: query.apiKey, apiKey: query.apiKey,
continueUrl: query.continueUrl, ...query.continueUrl ? { continueUrl: query.continueUrl } : {},
}, },
}; };
} }

View File

@@ -113,10 +113,6 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
return; return;
} }
if (data.assigner !== req.session.user.id) {
res.status(401).json({ok: false});
return;
}
if (data.pdf) { if (data.pdf) {
// if it does, return the pdf url // if it does, return the pdf url
const fileRef = ref(storage, data.pdf); const fileRef = ref(storage, data.pdf);
@@ -239,6 +235,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const result = exams.length === 0 ? "N/A" : `${correct}/${total}`; const result = exams.length === 0 ? "N/A" : `${correct}/${total}`;
const userDemographicInformation = user?.demographicInformation as DemographicInformation;
return { return {
id, id,
name: user?.name || "N/A", name: user?.name || "N/A",
@@ -248,6 +246,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
result, result,
level: showLevel ? getLevelScoreForUserExams(bandScore) : undefined, level: showLevel ? getLevelScoreForUserExams(bandScore) : undefined,
bandScore, bandScore,
passportId: userDemographicInformation?.passport_id || ""
}; };
}); });
}; };

View File

@@ -1,156 +1,198 @@
// 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, query, where, setDoc, doc, getDoc} from "firebase/firestore"; import {
import {withIronSessionApiRoute} from "iron-session/next"; getFirestore,
import {sessionOptions} from "@/lib/session"; collection,
import {uuidv4} from "@firebase/util"; getDocs,
import {Module} from "@/interfaces"; query,
import {getExams} from "@/utils/exams.be"; where,
import {Exam, Variant} from "@/interfaces/exam"; setDoc,
import {capitalize, flatten} from "lodash"; doc,
import {User} from "@/interfaces/user"; getDoc,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { uuidv4 } from "@firebase/util";
import { Module } from "@/interfaces";
import { getExams } from "@/utils/exams.be";
import { Exam, Variant } from "@/interfaces/exam";
import { capitalize, flatten, uniqBy } from "lodash";
import { User } from "@/interfaces/user";
import moment from "moment"; import moment from "moment";
import {sendEmail} from "@/email"; import { sendEmail } from "@/email";
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.session.user) { if (!req.session.user) {
res.status(401).json({ok: false}); res.status(401).json({ ok: false });
return; return;
} }
if (req.method === "GET") return GET(req, res); if (req.method === "GET") return GET(req, res);
if (req.method === "POST") return POST(req, res); if (req.method === "POST") return POST(req, res);
res.status(404).json({ok: false}); res.status(404).json({ ok: false });
} }
async function GET(req: NextApiRequest, res: NextApiResponse) { async function GET(req: NextApiRequest, res: NextApiResponse) {
const q = query(collection(db, "assignments")); const q = query(collection(db, "assignments"));
const snapshot = await getDocs(q); const snapshot = await getDocs(q);
const docs = snapshot.docs.map((doc) => ({ const docs = snapshot.docs.map((doc) => ({
id: doc.id, id: doc.id,
...doc.data(), ...doc.data(),
})); }));
res.status(200).json(docs); res.status(200).json(docs);
} }
interface ExamWithUser { interface ExamWithUser {
module: Module; module: Module;
id: string; id: string;
assignee: string; assignee: string;
} }
function getRandomIndex(arr: any[]): number { function getRandomIndex(arr: any[]): number {
const randomIndex = Math.floor(Math.random() * arr.length); const randomIndex = Math.floor(Math.random() * arr.length);
return randomIndex; return randomIndex;
} }
const generateExams = async ( const generateExams = async (
generateMultiple: Boolean, generateMultiple: Boolean,
selectedModules: Module[], selectedModules: Module[],
assignees: string[], assignees: string[],
variant?: Variant, variant?: Variant,
): Promise<ExamWithUser[]> => { ): Promise<ExamWithUser[]> => {
if (generateMultiple) { if (generateMultiple) {
// for optimization purposes, it would be better to create a new endpoint that returned the answers for all users at once // for optimization purposes, it would be better to create a new endpoint that returned the answers for all users at once
const allExams = await assignees.map(async (assignee) => { const allExams = assignees.map(async (assignee) => {
const selectedModulePromises = await selectedModules.map(async (module: Module) => { const selectedModulePromises = selectedModules.map(
try { async (module: Module) => {
const exams: Exam[] = await getExams(db, module, "true", assignee, variant); try {
const exams: Exam[] = await getExams(
db,
module,
"true",
assignee,
variant,
);
const exam = exams[getRandomIndex(exams)]; const exam = exams[getRandomIndex(exams)];
if (exam) { if (exam) {
return {module: exam.module, id: exam.id, assignee}; return { module: exam.module, id: exam.id, assignee };
} }
return null; return null;
} catch (e) { } catch (e) {
console.error(e); console.error(e);
return null; return null;
} }
}, []); },
const newModules = await Promise.all(selectedModulePromises); [],
);
const newModules = await Promise.all(selectedModulePromises);
return newModules; return newModules;
}, []); }, []);
const exams = flatten(await Promise.all(allExams)).filter((x) => x !== null) as ExamWithUser[]; const exams = flatten(await Promise.all(allExams)).filter(
return exams; (x) => x !== null,
} ) as ExamWithUser[];
return exams;
}
const selectedModulePromises = await selectedModules.map(async (module: Module) => { const selectedModulePromises = selectedModules.map(async (module: Module) => {
const exams: Exam[] = await getExams(db, module, "false", undefined); const exams: Exam[] = await getExams(db, module, "false", undefined);
const exam = exams[getRandomIndex(exams)]; const exam = exams[getRandomIndex(exams)];
if (exam) { if (exam) {
return {module: exam.module, id: exam.id}; return { module: exam.module, id: exam.id };
} }
return null; return null;
}); });
const exams = await Promise.all(selectedModulePromises); const exams = await Promise.all(selectedModulePromises);
const examesFiltered = exams.filter((x) => x !== null) as ExamWithUser[]; const examesFiltered = exams.filter((x) => x !== null) as ExamWithUser[];
return flatten(assignees.map((assignee) => examesFiltered.map((exam) => ({...exam, assignee})))); return flatten(
assignees.map((assignee) =>
examesFiltered.map((exam) => ({ ...exam, assignee })),
),
);
}; };
async function POST(req: NextApiRequest, res: NextApiResponse) { async function POST(req: NextApiRequest, res: NextApiResponse) {
const { const {
selectedModules, selectedModules,
assignees, assignees,
// Generate multiple true would generate an unique exam for each user // Generate multiple true would generate an unique exam for each user
// false would generate the same exam for all users // false would generate the same exam for all users
generateMultiple = false, generateMultiple = false,
variant, variant,
...body ...body
} = req.body as { } = req.body as {
selectedModules: Module[]; selectedModules: Module[];
assignees: string[]; assignees: string[];
generateMultiple: Boolean; generateMultiple: Boolean;
name: string; name: string;
startDate: string; startDate: string;
endDate: string; endDate: string;
variant?: Variant; variant?: Variant;
}; };
const exams: ExamWithUser[] = await generateExams(generateMultiple, selectedModules, assignees, variant); const exams: ExamWithUser[] = await generateExams(
generateMultiple,
selectedModules,
assignees,
variant,
);
if (exams.length === 0) { if (exams.length === 0) {
res.status(400).json({ok: false, error: "No exams found for the selected modules"}); res
return; .status(400)
} .json({ ok: false, error: "No exams found for the selected modules" });
return;
}
await setDoc(doc(db, "assignments", uuidv4()), { await setDoc(doc(db, "assignments", uuidv4()), {
assigner: req.session.user?.id, assigner: req.session.user?.id,
assignees, assignees,
results: [], results: [],
exams, exams,
...body, ...body,
}); });
res.status(200).json({ok: true}); res.status(200).json({ ok: true });
for (const assigneeID of assignees) { for (const assigneeID of assignees) {
const assigneeSnapshot = await getDoc(doc(db, "users", assigneeID)); const assigneeSnapshot = await getDoc(doc(db, "users", assigneeID));
if (!assigneeSnapshot.exists()) continue; if (!assigneeSnapshot.exists()) continue;
const assignee = {id: assigneeID, ...assigneeSnapshot.data()} as User; const assignee = { id: assigneeID, ...assigneeSnapshot.data() } as User;
const name = body.name; const name = body.name;
const teacher = req.session.user!; const teacher = req.session.user!;
const examModulesLabel = exams.map((x) => capitalize(x.module)).join(", "); const examModulesLabel = uniqBy(exams, (x) => x.module)
const startDate = moment(body.startDate).format("DD/MM/YYYY - HH:mm"); .map((x) => capitalize(x.module))
const endDate = moment(body.endDate).format("DD/MM/YYYY - HH:mm"); .join(", ");
const startDate = moment(body.startDate).format("DD/MM/YYYY - HH:mm");
const endDate = moment(body.endDate).format("DD/MM/YYYY - HH:mm");
await sendEmail( await sendEmail(
"assignment", "assignment",
{user: {name: assignee.name}, assignment: {name, startDate, endDate, modules: examModulesLabel, assigner: teacher.name}}, {
[assignee.email], user: { name: assignee.name },
"EnCoach - New Assignment!", assignment: {
); name,
} startDate,
endDate,
modules: examModulesLabel,
assigner: teacher.name,
},
},
[assignee.email],
"EnCoach - New Assignment!",
);
}
} }

View File

@@ -1,109 +1,143 @@
// 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, setDoc, doc, query, collection, where, getDocs} from "firebase/firestore"; import {
import {withIronSessionApiRoute} from "iron-session/next"; getFirestore,
import {sessionOptions} from "@/lib/session"; setDoc,
import {Type} from "@/interfaces/user"; doc,
import {PERMISSIONS} from "@/constants/userPermissions"; query,
import {uuidv4} from "@firebase/util"; collection,
import {prepareMailer, prepareMailOptions} from "@/email"; where,
getDocs,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Type } from "@/interfaces/user";
import { PERMISSIONS } from "@/constants/userPermissions";
import { uuidv4 } from "@firebase/util";
import { prepareMailer, prepareMailOptions } from "@/email";
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 === "GET") return get(req, res); if (req.method === "GET") return get(req, res);
if (req.method === "POST") return post(req, res); if (req.method === "POST") return post(req, res);
return res.status(404).json({ok: false}); return res.status(404).json({ ok: false });
} }
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ok: false, reason: "You must be logged in to generate a code!"}); res
return; .status(401)
} .json({ ok: false, reason: "You must be logged in to generate a code!" });
return;
}
const {creator} = req.query as {creator?: string}; const { creator } = req.query as { creator?: string };
const q = query(collection(db, "codes"), where("creator", "==", creator)); const q = query(collection(db, "codes"), where("creator", "==", creator));
const snapshot = await getDocs(creator ? q : collection(db, "codes")); const snapshot = await getDocs(creator ? q : collection(db, "codes"));
res.status(200).json(snapshot.docs.map((doc) => doc.data())); res.status(200).json(snapshot.docs.map((doc) => doc.data()));
} }
async function post(req: NextApiRequest, res: NextApiResponse) { async function post(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ok: false, reason: "You must be logged in to generate a code!"}); res
return; .status(401)
} .json({ ok: false, reason: "You must be logged in to generate a code!" });
return;
}
const {type, codes, infos, expiryDate} = req.body as { const { type, codes, infos, expiryDate } = req.body as {
type: Type; type: Type;
codes: string[]; codes: string[];
infos?: {email: string; name: string; passport_id?: string}[]; infos?: { email: string; name: string; passport_id?: string }[];
expiryDate: null | Date; expiryDate: null | Date;
}; };
const permission = PERMISSIONS.generateCode[type]; const permission = PERMISSIONS.generateCode[type];
if (!permission.includes(req.session.user.type)) { if (!permission.includes(req.session.user.type)) {
res.status(403).json({ok: false, reason: "Your account type does not have permissions to generate a code for that type of user!"}); res
return; .status(403)
} .json({
ok: false,
reason:
"Your account type does not have permissions to generate a code for that type of user!",
});
return;
}
if (req.session.user.type === "corporate") { if (req.session.user.type === "corporate") {
const codesGeneratedByUserSnapshot = await getDocs(query(collection(db, "codes"), where("creator", "==", req.session.user.id))); const codesGeneratedByUserSnapshot = await getDocs(
const totalCodes = codesGeneratedByUserSnapshot.docs.length + codes.length; query(
const allowedCodes = req.session.user.corporateInformation?.companyInformation.userAmount || 0; collection(db, "codes"),
where("creator", "==", req.session.user.id),
),
);
const totalCodes = codesGeneratedByUserSnapshot.docs.length + codes.length;
const allowedCodes =
req.session.user.corporateInformation?.companyInformation.userAmount || 0;
if (totalCodes > allowedCodes) { if (totalCodes > allowedCodes) {
res.status(403).json({ res.status(403).json({
ok: false, ok: false,
reason: `You have or would have exceeded your amount of allowed codes, you currently are allowed to generate ${ reason: `You have or would have exceeded your amount of allowed codes, you currently are allowed to generate ${
allowedCodes - codesGeneratedByUserSnapshot.docs.length allowedCodes - codesGeneratedByUserSnapshot.docs.length
} codes.`, } codes.`,
}); });
return; return;
} }
} }
const codePromises = codes.map(async (code, index) => { const codePromises = codes.map(async (code, index) => {
const codeRef = doc(db, "codes", code); const codeRef = doc(db, "codes", code);
const codeInformation = {type, code, creator: req.session.user!.id, expiryDate}; const codeInformation = {
type,
code,
creator: req.session.user!.id,
expiryDate,
};
if (infos && infos.length > index) { if (infos && infos.length > index) {
const {email, name, passport_id} = infos[index]; const { email, name, passport_id } = infos[index];
const transport = prepareMailer(); const transport = prepareMailer();
const mailOptions = prepareMailOptions( const mailOptions = prepareMailOptions(
{ {
type, type,
code, code,
}, },
[email.trim()], [email.toLowerCase().trim()],
"EnCoach Registration", "EnCoach Registration",
"main", "main",
); );
try { try {
await transport.sendMail(mailOptions); await transport.sendMail(mailOptions);
await setDoc( await setDoc(
codeRef, codeRef,
{...codeInformation, email: email.trim(), name: name.trim(), ...(passport_id ? {passport_id: passport_id.trim()} : {})}, {
{merge: true}, ...codeInformation,
); email: email.trim().toLowerCase(),
name: name.trim(),
...(passport_id ? { passport_id: passport_id.trim() } : {}),
},
{ merge: true },
);
return true; return true;
} catch (e) { } catch (e) {
return false; return false;
} }
} else { } else {
await setDoc(codeRef, codeInformation); await setDoc(codeRef, codeInformation);
} }
}); });
Promise.all(codePromises).then((results) => { Promise.all(codePromises).then((results) => {
res.status(200).json({ok: true, valid: results.filter((x) => x).length}); res.status(200).json({ ok: true, valid: results.filter((x) => x).length });
}); });
} }

View File

@@ -1,80 +1,108 @@
// 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, getDoc, doc, deleteDoc, setDoc} from "firebase/firestore"; import {
import {withIronSessionApiRoute} from "iron-session/next"; getFirestore,
import {sessionOptions} from "@/lib/session"; collection,
import {Group} from "@/interfaces/user"; getDocs,
getDoc,
doc,
deleteDoc,
setDoc,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Group } from "@/interfaces/user";
import { updateExpiryDateOnGroup } from "@/utils/groups.be";
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 === "GET") return await get(req, res); if (req.method === "GET") return await get(req, res);
if (req.method === "DELETE") return await del(req, res); if (req.method === "DELETE") return await del(req, res);
if (req.method === "PATCH") return await patch(req, res); if (req.method === "PATCH") return await patch(req, res);
res.status(404).json(undefined); res.status(404).json(undefined);
} }
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ok: false}); res.status(401).json({ ok: false });
return; return;
} }
const {id} = req.query as {id: string}; const { id } = req.query as { id: string };
const snapshot = await getDoc(doc(db, "groups", id)); const snapshot = await getDoc(doc(db, "groups", id));
if (snapshot.exists()) { if (snapshot.exists()) {
res.status(200).json({...snapshot.data(), id: snapshot.id}); res.status(200).json({ ...snapshot.data(), id: snapshot.id });
} else { } else {
res.status(404).json(undefined); res.status(404).json(undefined);
} }
} }
async function del(req: NextApiRequest, res: NextApiResponse) { async function del(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ok: false}); res.status(401).json({ ok: false });
return; return;
} }
const {id} = req.query as {id: string}; const { id } = req.query as { id: string };
const snapshot = await getDoc(doc(db, "groups", id)); const snapshot = await getDoc(doc(db, "groups", id));
const group = {...snapshot.data(), id: snapshot.id} as Group; const group = { ...snapshot.data(), id: snapshot.id } as Group;
const user = req.session.user; const user = req.session.user;
if (user.type === "admin" || user.type === "developer" || user.id === group.admin) { if (
await deleteDoc(snapshot.ref); user.type === "admin" ||
user.type === "developer" ||
user.id === group.admin
) {
await deleteDoc(snapshot.ref);
res.status(200).json({ok: true}); res.status(200).json({ ok: true });
return; return;
} }
res.status(403).json({ok: false}); res.status(403).json({ ok: false });
} }
async function patch(req: NextApiRequest, res: NextApiResponse) { async function patch(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ok: false}); res.status(401).json({ ok: false });
return; return;
} }
const {id} = req.query as {id: string}; const { id } = req.query as { id: string };
const snapshot = await getDoc(doc(db, "groups", id)); const snapshot = await getDoc(doc(db, "groups", id));
const group = {...snapshot.data(), id: snapshot.id} as Group; const group = { ...snapshot.data(), id: snapshot.id } as Group;
const user = req.session.user; const user = req.session.user;
if (user.type === "admin" || user.type === "developer" || user.id === group.admin) { if (
await setDoc(snapshot.ref, req.body, {merge: true}); user.type === "admin" ||
user.type === "developer" ||
user.id === group.admin
) {
if ("participants" in req.body) {
const newParticipants = (req.body.participants as string[]).filter(
(x) => !group.participants.includes(x),
);
await Promise.all(
newParticipants.map(
async (p) => await updateExpiryDateOnGroup(p, group.admin),
),
);
}
res.status(200).json({ok: true}); await setDoc(snapshot.ref, req.body, { merge: true });
return;
}
res.status(403).json({ok: false}); res.status(200).json({ ok: true });
return;
}
res.status(403).json({ ok: false });
} }

View File

@@ -1,45 +1,73 @@
// 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, query, where} from "firebase/firestore"; import {
import {withIronSessionApiRoute} from "iron-session/next"; getFirestore,
import {sessionOptions} from "@/lib/session"; collection,
import {Group} from "@/interfaces/user"; getDocs,
import {v4} from "uuid"; setDoc,
doc,
query,
where,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Group } from "@/interfaces/user";
import { v4 } from "uuid";
import { updateExpiryDateOnGroup } from "@/utils/groups.be";
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.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") await get(req, res);
if (req.method === "POST") await post(req, res); if (req.method === "POST") await post(req, res);
} }
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
const {admin, participant} = req.query as {admin: string; participant: string}; const { admin, participant } = req.query as {
admin: string;
participant: string;
};
const queryConstraints = [ const queryConstraints = [
...(admin ? [where("admin", "==", admin)] : []), ...(admin ? [where("admin", "==", admin)] : []),
...(participant ? [where("participants", "array-contains", participant)] : []), ...(participant
]; ? [where("participants", "array-contains", participant)]
const snapshot = await getDocs(queryConstraints.length > 0 ? query(collection(db, "groups"), ...queryConstraints) : collection(db, "groups")); : []),
const groups = snapshot.docs.map((doc) => ({ ];
id: doc.id, const snapshot = await getDocs(
...doc.data(), queryConstraints.length > 0
})) as Group[]; ? query(collection(db, "groups"), ...queryConstraints)
: collection(db, "groups"),
);
const groups = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as Group[];
res.status(200).json(groups); res.status(200).json(groups);
} }
async function post(req: NextApiRequest, res: NextApiResponse) { async function post(req: NextApiRequest, res: NextApiResponse) {
const body = req.body as Group; const body = req.body as Group;
await setDoc(doc(db, "groups", v4()), {name: body.name, admin: body.admin, participants: body.participants}); await Promise.all(
res.status(200).json({ok: true}); body.participants.map(
async (p) => await updateExpiryDateOnGroup(p, body.admin),
),
);
await setDoc(doc(db, "groups", v4()), {
name: body.name,
admin: body.admin,
participants: body.participants,
});
res.status(200).json({ ok: true });
} }

View File

@@ -0,0 +1,82 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
import {
getFirestore,
getDoc,
doc,
deleteDoc,
setDoc,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Ticket } from "@/interfaces/ticket";
import { Invite } from "@/interfaces/invite";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return await get(req, res);
if (req.method === "DELETE") return await del(req, res);
if (req.method === "PATCH") return await patch(req, res);
res.status(404).json(undefined);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
const { id } = req.query as { id: string };
const snapshot = await getDoc(doc(db, "invites", id));
if (snapshot.exists()) {
res.status(200).json({ ...snapshot.data(), id: snapshot.id });
} else {
res.status(404).json(undefined);
}
}
async function del(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
const { id } = req.query as { id: string };
const snapshot = await getDoc(doc(db, "invites", id));
const data = snapshot.data() as Invite;
const user = req.session.user;
if (user.type === "admin" || user.type === "developer") {
await deleteDoc(snapshot.ref);
res.status(200).json({ ok: true });
return;
}
res.status(403).json({ ok: false });
}
async function patch(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
const { id } = req.query as { id: string };
const snapshot = await getDoc(doc(db, "invites", id));
const user = req.session.user;
if (user.type === "admin" || user.type === "developer") {
await setDoc(snapshot.ref, req.body, { merge: true });
return res.status(200).json({ ok: true });
}
res.status(403).json({ ok: false });
}

View File

@@ -0,0 +1,134 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
import {
getFirestore,
getDoc,
doc,
deleteDoc,
setDoc,
getDocs,
collection,
where,
query,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Ticket } from "@/interfaces/ticket";
import { Invite } from "@/interfaces/invite";
import { Group, User } from "@/interfaces/user";
import { v4 } from "uuid";
import { sendEmail } from "@/email";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return await get(req, res);
res.status(404).json(undefined);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
const { id } = req.query as { id: string };
const snapshot = await getDoc(doc(db, "invites", id));
if (snapshot.exists()) {
const invite = { ...snapshot.data(), id: snapshot.id } as Invite;
if (invite.to !== req.session.user.id)
return res.status(403).json({ ok: false });
await deleteDoc(snapshot.ref);
const invitedByRef = await getDoc(doc(db, "users", invite.from));
if (!invitedByRef.exists()) return res.status(404).json({ ok: false });
const invitedBy = { ...invitedByRef.data(), id: invitedByRef.id } as User;
const invitedByGroupsRef = await getDocs(
query(collection(db, "groups"), where("admin", "==", invitedBy.id)),
);
const invitedByGroups = invitedByGroupsRef.docs.map((g) => ({
...g.data(),
id: g.id,
})) as Group[];
const typeGroupName =
req.session.user.type === "student"
? "Students"
: req.session.user.type === "teacher"
? "Teachers"
: undefined;
if (typeGroupName) {
const typeGroup: Group = invitedByGroups.find(
(g) => g.name === typeGroupName,
) || {
id: v4(),
admin: invitedBy.id,
name: typeGroupName,
participants: [],
disableEditing: true,
};
await setDoc(
doc(db, "groups", typeGroup.id),
{
...typeGroup,
participants: [
...typeGroup.participants.filter((x) => x !== req.session.user!.id),
req.session.user.id,
],
},
{ merge: true },
);
}
const invitationsGroup: Group = invitedByGroups.find(
(g) => g.name === "Invited",
) || {
id: v4(),
admin: invitedBy.id,
name: "Invited",
participants: [],
disableEditing: true,
};
await setDoc(
doc(db, "groups", invitationsGroup.id),
{
...invitationsGroup,
participants: [
...invitationsGroup.participants.filter(
(x) => x !== req.session.user!.id,
),
req.session.user.id,
],
},
{
merge: true,
},
);
try {
await sendEmail(
"respondedInvite",
{
corporateName: invitedBy.name,
name: req.session.user.name,
decision: "accept",
},
[invitedBy.email],
`${req.session.user.name} has accepted your invite!`,
);
} catch (e) {
console.log(e);
}
res.status(200).json({ ok: true });
} else {
res.status(404).json(undefined);
}
}

View File

@@ -0,0 +1,72 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
import {
getFirestore,
getDoc,
doc,
deleteDoc,
setDoc,
getDocs,
collection,
where,
query,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Ticket } from "@/interfaces/ticket";
import { Invite } from "@/interfaces/invite";
import { Group, User } from "@/interfaces/user";
import { v4 } from "uuid";
import { sendEmail } from "@/email";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return await get(req, res);
res.status(404).json(undefined);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
const { id } = req.query as { id: string };
const snapshot = await getDoc(doc(db, "invites", id));
if (snapshot.exists()) {
const invite = { ...snapshot.data(), id: snapshot.id } as Invite;
if (invite.to !== req.session.user.id)
return res.status(403).json({ ok: false });
await deleteDoc(snapshot.ref);
const invitedByRef = await getDoc(doc(db, "users", invite.from));
if (!invitedByRef.exists()) return res.status(404).json({ ok: false });
const invitedBy = { ...invitedByRef.data(), id: invitedByRef.id } as User;
try {
await sendEmail(
"respondedInvite",
{
corporateName: invitedBy.name,
name: req.session.user.name,
decision: "decline",
},
[invitedBy.email],
`${req.session.user.name} has declined your invite!`,
);
} catch (e) {
console.log(e);
}
res.status(200).json({ ok: true });
} else {
res.status(404).json(undefined);
}
}

View File

@@ -0,0 +1,88 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { sendEmail } from "@/email";
import { app } from "@/firebase";
import { Invite } from "@/interfaces/invite";
import { Ticket } from "@/interfaces/ticket";
import { User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
import {
collection,
doc,
getDoc,
getDocs,
getFirestore,
setDoc,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import type { NextApiRequest, NextApiResponse } from "next";
import ShortUniqueId from "short-unique-id";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
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);
if (req.method === "POST") await post(req, res);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
const snapshot = await getDocs(collection(db, "invites"));
res.status(200).json(
snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})),
);
}
async function post(req: NextApiRequest, res: NextApiResponse) {
const body = req.body as Invite;
const existingInvites = (await getDocs(collection(db, "invites"))).docs.map(
(x) => ({ ...x.data(), id: x.id }),
) as Invite[];
const invitedRef = await getDoc(doc(db, "users", body.to));
if (!invitedRef.exists()) return res.status(404).json({ ok: false });
const invitedByRef = await getDoc(doc(db, "users", body.from));
if (!invitedByRef.exists()) return res.status(404).json({ ok: false });
const invited = { ...invitedRef.data(), id: invitedRef.id } as User;
const invitedBy = { ...invitedByRef.data(), id: invitedByRef.id } as User;
try {
await sendEmail(
"receivedInvite",
{
name: invited.name,
corporateName:
invitedBy.type === "corporate"
? invitedBy.corporateInformation?.companyInformation?.name ||
invitedBy.name
: invitedBy.name,
},
[invited.email],
"You have been invited to a group!",
);
} catch (e) {
console.log(e);
}
if (
existingInvites.filter((i) => i.to === body.to && i.from === body.from)
.length == 0
) {
const shortUID = new ShortUniqueId();
await setDoc(doc(db, "invites", body.id || shortUID.randomUUID(8)), body);
}
res.status(200).json({ ok: true });
}

View File

@@ -1,10 +1,10 @@
import {NextApiRequest, NextApiResponse} from "next"; import { NextApiRequest, NextApiResponse } from "next";
import {getAuth, signInWithEmailAndPassword} from "firebase/auth"; import { getAuth, signInWithEmailAndPassword } from "firebase/auth";
import {app} from "@/firebase"; import { app } from "@/firebase";
import {sessionOptions} from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import {withIronSessionApiRoute} from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import {User} from "@/interfaces/user"; import { User } from "@/interfaces/user";
import {getFirestore, getDoc, doc} from "firebase/firestore"; import { getFirestore, getDoc, doc } from "firebase/firestore";
const auth = getAuth(app); const auth = getAuth(app);
const db = getFirestore(app); const db = getFirestore(app);
@@ -12,27 +12,27 @@ const db = getFirestore(app);
export default withIronSessionApiRoute(login, sessionOptions); export default withIronSessionApiRoute(login, sessionOptions);
async function login(req: NextApiRequest, res: NextApiResponse) { async function login(req: NextApiRequest, res: NextApiResponse) {
const {email, password} = req.body as {email: string; password: string}; const { email, password } = req.body as { email: string; password: string };
signInWithEmailAndPassword(auth, email, password) signInWithEmailAndPassword(auth, email.toLowerCase(), password)
.then(async (userCredentials) => { .then(async (userCredentials) => {
const userId = userCredentials.user.uid; const userId = userCredentials.user.uid;
const docUser = await getDoc(doc(db, "users", userId)); const docUser = await getDoc(doc(db, "users", userId));
if (!docUser.exists()) { if (!docUser.exists()) {
res.status(401).json({error: 401, message: "User does not exist!"}); res.status(401).json({ error: 401, message: "User does not exist!" });
return; return;
} }
const user = docUser.data() as User; const user = docUser.data() as User;
req.session.user = {...user, id: userId}; req.session.user = { ...user, id: userId };
await req.session.save(); await req.session.save();
res.status(200).json({user: {...user, id: userId}}); res.status(200).json({ user: { ...user, id: userId } });
}) })
.catch((error) => { .catch((error) => {
console.log(error); console.log(error);
res.status(401).json({error}); res.status(401).json({ error });
}); });
} }

View File

@@ -1,11 +1,23 @@
import {NextApiRequest, NextApiResponse} from "next"; import { NextApiRequest, NextApiResponse } from "next";
import {createUserWithEmailAndPassword, getAuth} from "firebase/auth"; import { createUserWithEmailAndPassword, getAuth } from "firebase/auth";
import {app} from "@/firebase"; import { app } from "@/firebase";
import {sessionOptions} from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import {withIronSessionApiRoute} from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import {getFirestore, doc, setDoc, query, collection, where, getDocs} from "firebase/firestore"; import {
import {CorporateInformation, DemographicInformation, Type} from "@/interfaces/user"; getFirestore,
import {addUserToGroupOnCreation} from "@/utils/registration"; doc,
setDoc,
query,
collection,
where,
getDocs,
} from "firebase/firestore";
import {
CorporateInformation,
DemographicInformation,
Type,
} from "@/interfaces/user";
import { addUserToGroupOnCreation } from "@/utils/registration";
import moment from "moment"; import moment from "moment";
const auth = getAuth(app); const auth = getAuth(app);
@@ -14,117 +26,140 @@ const db = getFirestore(app);
export default withIronSessionApiRoute(register, sessionOptions); export default withIronSessionApiRoute(register, sessionOptions);
const DEFAULT_DESIRED_LEVELS = { const DEFAULT_DESIRED_LEVELS = {
reading: 9, reading: 9,
listening: 9, listening: 9,
writing: 9, writing: 9,
speaking: 9, speaking: 9,
}; };
const DEFAULT_LEVELS = { const DEFAULT_LEVELS = {
reading: 0, reading: 0,
listening: 0, listening: 0,
writing: 0, writing: 0,
speaking: 0, speaking: 0,
}; };
async function register(req: NextApiRequest, res: NextApiResponse) { async function register(req: NextApiRequest, res: NextApiResponse) {
const {type} = req.body as { const { type } = req.body as {
type: "individual" | "corporate"; type: "individual" | "corporate";
}; };
if (type === "individual") return registerIndividual(req, res); if (type === "individual") return registerIndividual(req, res);
if (type === "corporate") return registerCorporate(req, res); if (type === "corporate") return registerCorporate(req, res);
} }
async function registerIndividual(req: NextApiRequest, res: NextApiResponse) { async function registerIndividual(req: NextApiRequest, res: NextApiResponse) {
const {email, passport_id, password, code} = req.body as { const { email, passport_id, password, code } = req.body as {
email: string; email: string;
passport_id?: string; passport_id?: string;
password: string; password: string;
code?: string; code?: string;
}; };
const codeQuery = query(collection(db, "codes"), where("code", "==", code)); const codeQuery = query(collection(db, "codes"), where("code", "==", code));
const codeDocs = (await getDocs(codeQuery)).docs.filter((x) => !Object.keys(x.data()).includes("userId")); const codeDocs = (await getDocs(codeQuery)).docs.filter(
(x) => !Object.keys(x.data()).includes("userId"),
);
if (code && code.length > 0 && codeDocs.length === 0) { if (code && code.length > 0 && codeDocs.length === 0) {
res.status(400).json({error: "Invalid Code!"}); res.status(400).json({ error: "Invalid Code!" });
return; return;
} }
const codeData = codeDocs.length > 0 ? (codeDocs[0].data() as {code: string; type: Type; creator?: string; expiryDate: Date | null}) : undefined; const codeData =
codeDocs.length > 0
? (codeDocs[0].data() as {
code: string;
type: Type;
creator?: string;
expiryDate: Date | null;
})
: undefined;
createUserWithEmailAndPassword(auth, email, password) createUserWithEmailAndPassword(auth, email.toLowerCase(), password)
.then(async (userCredentials) => { .then(async (userCredentials) => {
const userId = userCredentials.user.uid; const userId = userCredentials.user.uid;
delete req.body.password; delete req.body.password;
const user = { const user = {
...req.body, ...req.body,
desiredLevels: DEFAULT_DESIRED_LEVELS, email: email.toLowerCase(),
levels: DEFAULT_LEVELS, desiredLevels: DEFAULT_DESIRED_LEVELS,
bio: "", levels: DEFAULT_LEVELS,
isFirstLogin: codeData ? codeData.type === "student" : true, bio: "",
focus: "academic", isFirstLogin: codeData ? codeData.type === "student" : true,
type: email.endsWith("@ecrop.dev") ? "developer" : codeData ? codeData.type : "student", focus: "academic",
subscriptionExpirationDate: codeData ? codeData.expiryDate : moment().subtract(1, "days").toISOString(), type: email.endsWith("@ecrop.dev")
...(passport_id ? {demographicInformation: {passport_id}} : {}), ? "developer"
registrationDate: new Date().toISOString(), : codeData
status: code ? "active" : "paymentDue", ? codeData.type
}; : "student",
subscriptionExpirationDate: codeData
? codeData.expiryDate
: moment().subtract(1, "days").toISOString(),
...(passport_id ? { demographicInformation: { passport_id } } : {}),
registrationDate: new Date().toISOString(),
status: code ? "active" : "paymentDue",
};
await setDoc(doc(db, "users", userId), user); await setDoc(doc(db, "users", userId), user);
if (codeDocs.length > 0 && codeData) { if (codeDocs.length > 0 && codeData) {
await setDoc(codeDocs[0].ref, {userId: userId}, {merge: true}); await setDoc(codeDocs[0].ref, { userId: userId }, { merge: true });
if (codeData.creator) await addUserToGroupOnCreation(userId, codeData.type, codeData.creator); if (codeData.creator)
} await addUserToGroupOnCreation(
userId,
codeData.type,
codeData.creator,
);
}
req.session.user = {...user, id: userId}; req.session.user = { ...user, id: userId };
await req.session.save(); await req.session.save();
res.status(200).json({user: {...user, id: userId}}); res.status(200).json({ user: { ...user, id: userId } });
}) })
.catch((error) => { .catch((error) => {
console.log(error); console.log(error);
res.status(401).json({error}); res.status(401).json({ error });
}); });
} }
async function registerCorporate(req: NextApiRequest, res: NextApiResponse) { async function registerCorporate(req: NextApiRequest, res: NextApiResponse) {
const {email, password} = req.body as { const { email, password } = req.body as {
email: string; email: string;
password: string; password: string;
corporateInformation: CorporateInformation; corporateInformation: CorporateInformation;
}; };
createUserWithEmailAndPassword(auth, email, password) createUserWithEmailAndPassword(auth, email.toLowerCase(), password)
.then(async (userCredentials) => { .then(async (userCredentials) => {
const userId = userCredentials.user.uid; const userId = userCredentials.user.uid;
delete req.body.password; delete req.body.password;
const user = { const user = {
...req.body, ...req.body,
desiredLevels: DEFAULT_DESIRED_LEVELS, email: email.toLowerCase(),
levels: DEFAULT_LEVELS, desiredLevels: DEFAULT_DESIRED_LEVELS,
bio: "", levels: DEFAULT_LEVELS,
isFirstLogin: false, bio: "",
focus: "academic", isFirstLogin: false,
type: "corporate", focus: "academic",
subscriptionExpirationDate: req.body.subscriptionExpirationDate || null, type: "corporate",
status: "paymentDue", subscriptionExpirationDate: req.body.subscriptionExpirationDate || null,
registrationDate: new Date().toISOString(), status: "paymentDue",
}; registrationDate: new Date().toISOString(),
};
await setDoc(doc(db, "users", userId), user); await setDoc(doc(db, "users", userId), user);
req.session.user = {...user, id: userId}; req.session.user = { ...user, id: userId };
await req.session.save(); await req.session.save();
res.status(200).json({user: {...user, id: userId}}); res.status(200).json({ user: { ...user, id: userId } });
}) })
.catch((error) => { .catch((error) => {
console.log(error); console.log(error);
res.status(401).json({error}); res.status(401).json({ error });
}); });
} }

View File

@@ -126,6 +126,15 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const stats = docsSnap.docs.map((d) => d.data()); const stats = docsSnap.docs.map((d) => d.data());
// verify if the stats already have a pdf generated // verify if the stats already have a pdf generated
const hasPDF = stats.find((s) => s.pdf); const hasPDF = stats.find((s) => s.pdf);
// find the user that generated the stats
const statIndex = stats.findIndex((s) => s.user);
if(statIndex === -1) {
res.status(401).json({ok: false});
return;
}
const userId = stats[statIndex].user;
if (hasPDF) { if (hasPDF) {
// if it does, return the pdf url // if it does, return the pdf url
@@ -138,7 +147,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
try { try {
// generate the pdf report // generate the pdf report
const docUser = await getDoc(doc(db, "users", req.session.user.id)); const docUser = await getDoc(doc(db, "users", userId));
if (docUser.exists()) { if (docUser.exists()) {
// we'll need the user in order to get the user data (name, email, focus, etc); // we'll need the user in order to get the user data (name, email, focus, etc);
@@ -269,7 +278,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
.format("ll HH:mm:ss")} .format("ll HH:mm:ss")}
name={user.name} name={user.name}
email={user.email} email={user.email}
id={user.id} id={userId}
gender={demographicInformation?.gender} gender={demographicInformation?.gender}
summary={performanceSummary} summary={performanceSummary}
testDetails={testDetails} testDetails={testDetails}

View File

@@ -0,0 +1,81 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
import {
getFirestore,
getDoc,
doc,
deleteDoc,
setDoc,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Ticket } from "@/interfaces/ticket";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return await get(req, res);
if (req.method === "DELETE") return await del(req, res);
if (req.method === "PATCH") return await patch(req, res);
res.status(404).json(undefined);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
const { id } = req.query as { id: string };
const snapshot = await getDoc(doc(db, "tickets", id));
if (snapshot.exists()) {
res.status(200).json({ ...snapshot.data(), id: snapshot.id });
} else {
res.status(404).json(undefined);
}
}
async function del(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
const { id } = req.query as { id: string };
const snapshot = await getDoc(doc(db, "tickets", id));
const data = snapshot.data() as Ticket;
const user = req.session.user;
if (user.type === "admin" || user.type === "developer") {
await deleteDoc(snapshot.ref);
res.status(200).json({ ok: true });
return;
}
res.status(403).json({ ok: false });
}
async function patch(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
const { id } = req.query as { id: string };
const snapshot = await getDoc(doc(db, "tickets", id));
const user = req.session.user;
if (user.type === "admin" || user.type === "developer") {
await setDoc(snapshot.ref, req.body, { merge: true });
return res.status(200).json({ ok: true });
}
res.status(403).json({ ok: false });
}

View File

@@ -0,0 +1,69 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { sendEmail } from "@/email";
import { app } from "@/firebase";
import { Ticket, TicketTypeLabel } from "@/interfaces/ticket";
import { sessionOptions } from "@/lib/session";
import {
collection,
doc,
getDocs,
getFirestore,
setDoc,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import moment from "moment";
import type { NextApiRequest, NextApiResponse } from "next";
import ShortUniqueId from "short-unique-id";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
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);
if (req.method === "POST") await post(req, res);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
const snapshot = await getDocs(collection(db, "tickets"));
res.status(200).json(
snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})),
);
}
async function post(req: NextApiRequest, res: NextApiResponse) {
const body = req.body as Ticket;
const shortUID = new ShortUniqueId();
const id = body.id || shortUID.randomUUID(8);
await setDoc(doc(db, "tickets", id), body);
res.status(200).json({ ok: true });
try {
await sendEmail(
"submittedFeedback",
{
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,
},
[body.reporter.email],
`Ticket ${id}: ${body.subject}`,
);
} catch (e) {
console.log(e);
}
}

View File

@@ -1,172 +1,232 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import {User} from "@/interfaces/user"; import { User } from "@/interfaces/user";
import {toast, ToastContainer} from "react-toastify"; import { toast, ToastContainer } from "react-toastify";
import axios from "axios"; import axios from "axios";
import {FormEvent, useEffect, useState} from "react"; import { FormEvent, useEffect, useState } from "react";
import Head from "next/head"; import Head from "next/head";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
import {Divider} from "primereact/divider"; import { Divider } from "primereact/divider";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import {BsArrowRepeat, BsCheck} from "react-icons/bs"; import { BsArrowRepeat, BsCheck } from "react-icons/bs";
import Link from "next/link"; import Link from "next/link";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import clsx from "clsx"; import clsx from "clsx";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import EmailVerification from "./(auth)/EmailVerification"; import EmailVerification from "./(auth)/EmailVerification";
import {withIronSessionSsr} from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import { sessionOptions } from "@/lib/session";
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/g); const EMAIL_REGEX = new RegExp(
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/g,
);
export const getServerSideProps = withIronSessionSsr(({req, res}) => { export const getServerSideProps = withIronSessionSsr(({ req, res }) => {
const user = req.session.user; const user = req.session.user;
const envVariables: {[key: string]: string} = {}; const envVariables: { [key: string]: string } = {};
Object.keys(process.env) Object.keys(process.env)
.filter((x) => x.startsWith("NEXT_PUBLIC")) .filter((x) => x.startsWith("NEXT_PUBLIC"))
.forEach((x: string) => { .forEach((x: string) => {
envVariables[x] = process.env[x]!; envVariables[x] = process.env[x]!;
}); });
if (user && user.isVerified) { if (user && user.isVerified) {
res.setHeader("location", "/"); res.setHeader("location", "/");
res.statusCode = 302; res.statusCode = 302;
res.end(); res.end();
return { return {
props: { props: {
user: null, user: null,
envVariables, envVariables,
}, },
}; };
} }
return { return {
props: {user: null, envVariables}, props: { user: null, envVariables },
}; };
}, sessionOptions); }, sessionOptions);
export default function Login() { export default function Login() {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [rememberPassword, setRememberPassword] = useState(false); const [rememberPassword, setRememberPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const router = useRouter(); const router = useRouter();
const {user, mutateUser} = useUser({ const { user, mutateUser } = useUser({
redirectTo: "/", redirectTo: "/",
redirectIfFound: true, redirectIfFound: true,
}); });
useEffect(() => { useEffect(() => {
if (user && user.isVerified) router.push("/"); if (user && user.isVerified) router.push("/");
}, [router, user]); }, [router, user]);
const forgotPassword = () => { const forgotPassword = () => {
if (!email || email.length < 0 || !EMAIL_REGEX.test(email)) { if (!email || email.length < 0 || !EMAIL_REGEX.test(email)) {
toast.error("Please enter your e-mail to reset your password!", {toastId: "forgot-invalid-email"}); toast.error("Please enter your e-mail to reset your password!", {
return; toastId: "forgot-invalid-email",
} });
return;
}
axios axios
.post<{ok: boolean}>("/api/reset", {email}) .post<{ ok: boolean }>("/api/reset", { email })
.then((response) => { .then((response) => {
if (response.data.ok) { if (response.data.ok) {
toast.success("You should receive an e-mail to reset your password!", {toastId: "forgot-success"}); toast.success(
return; "You should receive an e-mail to reset your password!",
} { toastId: "forgot-success" },
);
return;
}
toast.error("That e-mail address is not connected to an account!", {toastId: "forgot-error"}); toast.error("That e-mail address is not connected to an account!", {
}) toastId: "forgot-error",
.catch(() => toast.error("That e-mail address is not connected to an account!", {toastId: "forgot-error"})); });
}; })
.catch(() =>
toast.error("That e-mail address is not connected to an account!", {
toastId: "forgot-error",
}),
);
};
const login = (e: FormEvent<HTMLFormElement>) => { const login = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
setIsLoading(true); setIsLoading(true);
axios axios
.post<User>("/api/login", {email, password}) .post<User>("/api/login", { email, password })
.then((response) => { .then((response) => {
toast.success("You have been logged in!", {toastId: "login-successful"}); toast.success("You have been logged in!", {
mutateUser(response.data); toastId: "login-successful",
}) });
.catch((e) => { mutateUser(response.data);
if (e.response.status === 401) { })
toast.error("Wrong login credentials!", {toastId: "wrong-credentials"}); .catch((e) => {
} else { if (e.response.status === 401) {
toast.error("Something went wrong!", {toastId: "server-error"}); toast.error("Wrong login credentials!", {
} toastId: "wrong-credentials",
setIsLoading(false); });
}) } else {
.finally(() => setIsLoading(false)); toast.error("Something went wrong!", { toastId: "server-error" });
}; }
setIsLoading(false);
})
.finally(() => setIsLoading(false));
};
return ( return (
<> <>
<Head> <Head>
<title>Login | EnCoach</title> <title>Login | EnCoach</title>
<meta name="description" content="Generated by create next app" /> <meta name="description" content="Generated by create next app" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<main className="w-full h-[100vh] flex bg-white text-black"> <main className="flex h-[100vh] w-full bg-white text-black">
<ToastContainer /> <ToastContainer />
<section className="h-full w-fit min-w-fit relative hidden lg:flex"> <section className="relative hidden h-full w-fit min-w-fit lg:flex">
<div className="absolute h-full w-full bg-mti-rose-light z-10 bg-opacity-50" /> <div className="bg-mti-rose-light absolute z-10 h-full w-full bg-opacity-50" />
<img src="/people-talking-tablet.png" alt="People smiling looking at a tablet" className="h-full aspect-auto" /> <img
</section> src="/people-talking-tablet.png"
<section className="h-full w-full flex flex-col items-center justify-center gap-2"> alt="People smiling looking at a tablet"
<div className={clsx("flex flex-col items-center", !user && "mb-4")}> className="aspect-auto h-full"
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-36 lg:w-56" /> />
<h1 className="font-bold text-2xl lg:text-4xl">Login to your account</h1> </section>
<p className="self-start text-sm lg:text-base font-normal text-mti-gray-cool">with your registered Email Address</p> <section className="flex h-full w-full flex-col items-center justify-center gap-2">
</div> <div className={clsx("flex flex-col items-center", !user && "mb-4")}>
<Divider className="max-w-xs lg:max-w-md" /> <img
{!user && ( src="/logo_title.png"
<> alt="EnCoach's Logo"
<form className="flex flex-col items-center gap-6 w-full -lg:px-8 lg:w-1/2" onSubmit={login}> className="w-36 lg:w-56"
<Input type="email" name="email" onChange={(e) => setEmail(e)} placeholder="Enter email address" /> />
<Input type="password" name="password" onChange={(e) => setPassword(e)} placeholder="Password" /> <h1 className="text-2xl font-bold lg:text-4xl">
<div className="flex justify-between w-full px-4"> Login to your account
<div </h1>
className="flex gap-3 text-mti-gray-dim text-xs cursor-pointer" <p className="text-mti-gray-cool self-start text-sm font-normal lg:text-base">
onClick={() => setRememberPassword((prev) => !prev)}> with your registered Email Address
<input type="checkbox" className="hidden" /> </p>
<div </div>
className={clsx( <Divider className="max-w-xs lg:max-w-md" />
"w-4 h-4 rounded-sm flex items-center justify-center border border-mti-purple-light bg-white", {!user && (
"transition duration-300 ease-in-out", <>
rememberPassword && "!bg-mti-purple-light ", <form
)}> className="-lg:px-8 flex w-full flex-col items-center gap-6 lg:w-1/2"
<BsCheck color="white" className="w-full h-full" /> onSubmit={login}
</div> >
<span>Remember my password</span> <Input
</div> type="email"
<span className="text-mti-purple-light text-xs cursor-pointer hover:underline" onClick={forgotPassword}> name="email"
Forgot Password? onChange={(e) => setEmail(e.toLowerCase())}
</span> placeholder="Enter email address"
</div> />
<Button className="mt-8 w-full" color="purple" disabled={isLoading}> <Input
{!isLoading && "Login"} type="password"
{isLoading && ( name="password"
<div className="flex items-center justify-center"> onChange={(e) => setPassword(e)}
<BsArrowRepeat className="text-white animate-spin" size={25} /> placeholder="Password"
</div> />
)} <div className="flex w-full justify-between px-4">
</Button> <div
</form> className="text-mti-gray-dim flex cursor-pointer gap-3 text-xs"
<span className="text-mti-gray-cool text-sm font-normal mt-8"> onClick={() => setRememberPassword((prev) => !prev)}
Don&apos;t have an account?{" "} >
<Link className="text-mti-purple-light" href="/register"> <input type="checkbox" className="hidden" />
Sign up <div
</Link> className={clsx(
</span> "border-mti-purple-light flex h-4 w-4 items-center justify-center rounded-sm border bg-white",
</> "transition duration-300 ease-in-out",
)} rememberPassword && "!bg-mti-purple-light ",
{user && !user.isVerified && <EmailVerification user={user} isLoading={isLoading} setIsLoading={setIsLoading} />} )}
</section> >
</main> <BsCheck color="white" className="h-full w-full" />
</> </div>
); <span>Remember my password</span>
</div>
<span
className="text-mti-purple-light cursor-pointer text-xs hover:underline"
onClick={forgotPassword}
>
Forgot Password?
</span>
</div>
<Button
className="mt-8 w-full"
color="purple"
disabled={isLoading}
>
{!isLoading && "Login"}
{isLoading && (
<div className="flex items-center justify-center">
<BsArrowRepeat
className="animate-spin text-white"
size={25}
/>
</div>
)}
</Button>
</form>
<span className="text-mti-gray-cool mt-8 text-sm font-normal">
Don&apos;t have an account?{" "}
<Link className="text-mti-purple-light" href="/register">
Sign up
</Link>
</span>
</>
)}
{user && !user.isVerified && (
<EmailVerification
user={user}
isLoading={isLoading}
setIsLoading={setIsLoading}
/>
)}
</section>
</main>
</>
);
} }

356
src/pages/tickets.tsx Normal file
View File

@@ -0,0 +1,356 @@
import Layout from "@/components/High/Layout";
import TicketDisplay from "@/components/High/TicketDisplay";
import Select from "@/components/Low/Select";
import Modal from "@/components/Modal";
import useTickets from "@/hooks/useTickets";
import useUser from "@/hooks/useUser";
import useUsers from "@/hooks/useUsers";
import {
Ticket,
TicketStatus,
TicketStatusLabel,
TicketType,
TicketTypeLabel,
} from "@/interfaces/ticket";
import { sessionOptions } from "@/lib/session";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import {
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import clsx from "clsx";
import { withIronSessionSsr } from "iron-session/next";
import moment from "moment";
import Head from "next/head";
import { useEffect, useState } from "react";
import { BsArrowDown, BsArrowUp } from "react-icons/bs";
import { ToastContainer } from "react-toastify";
const columnHelper = createColumnHelper<Ticket>();
export const getServerSideProps = withIronSessionSsr(({ req, res }) => {
const user = req.session.user;
if (!user || !user.isVerified) {
res.setHeader("location", "/login");
res.statusCode = 302;
res.end();
return {
props: {
user: null,
},
};
}
if (
shouldRedirectHome(user) ||
["admin", "developer", "agent"].includes(user.type)
) {
res.setHeader("location", "/");
res.statusCode = 302;
res.end();
return {
props: {
user: null,
},
};
}
return {
props: { user: req.session.user },
};
}, sessionOptions);
const StatusClassNames: { [key in TicketStatus]: string } = {
submitted: "bg-mti-gray-dim",
"in-progress": "bg-mti-blue-dark",
completed: "bg-mti-green-dark",
};
const TypesClassNames: { [key in TicketType]: string } = {
feedback: "bg-mti-green-light",
bug: "bg-mti-red-dark",
help: "bg-mti-blue-light",
};
export default function Tickets() {
const [filteredTickets, setFilteredTickets] = useState<Ticket[]>([]);
const [selectedTicket, setSelectedTicket] = useState<Ticket>();
const [assigneeFilter, setAssigneeFilter] = useState<string>();
const [dateSorting, setDateSorting] = useState<"asc" | "desc">("desc");
const [typeFilter, setTypeFilter] = useState<TicketType>();
const [statusFilter, setStatusFilter] = useState<TicketStatus>();
const { user } = useUser({ redirectTo: "/login" });
const { users } = useUsers();
const { tickets, reload } = useTickets();
const sortByDate = (a: Ticket, b: Ticket) => {
return moment((dateSorting === "desc" ? b : a).date).diff(
moment((dateSorting === "desc" ? a : b).date),
);
};
useEffect(() => {
const filters = [];
if (user?.type === "agent")
filters.push((x: Ticket) => x.assignedTo === user.id);
if (typeFilter) filters.push((x: Ticket) => x.type === typeFilter);
if (statusFilter) filters.push((x: Ticket) => x.status === statusFilter);
if (assigneeFilter)
filters.push((x: Ticket) => x.assignedTo === assigneeFilter);
setFilteredTickets(
[...filters.reduce((d, f) => d.filter(f), tickets)].sort(sortByDate),
);
}, [tickets, typeFilter, statusFilter, assigneeFilter, dateSorting, user]);
const columns = [
columnHelper.accessor("id", {
header: "ID",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("type", {
header: "Type",
cell: (info) => (
<span
className={clsx(
"rounded-lg p-1 px-2 text-white",
TypesClassNames[info.getValue()],
)}
>
{TicketTypeLabel[info.getValue()]}
</span>
),
}),
columnHelper.accessor("reporter", {
header: "Reporter",
cell: (info) => info.getValue().email,
}),
columnHelper.accessor("reportedFrom", {
header: "Reported From",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("date", {
id: "date",
header: (
<button
className="flex items-center gap-2"
onClick={() =>
setDateSorting((prev) => (prev === "asc" ? "desc" : "asc"))
}
>
<span>Date</span>
{dateSorting === "desc" && <BsArrowDown />}
{dateSorting === "asc" && <BsArrowUp />}
</button>
) as any,
cell: (info) => moment(info.getValue()).format("DD/MM/YYYY - HH:mm"),
}),
columnHelper.accessor("subject", {
header: "Subject",
cell: (info) =>
info.getValue().substring(0, 12) +
(info.getValue().length > 12 ? "..." : ""),
}),
columnHelper.accessor("status", {
header: "Status",
cell: (info) => (
<span
className={clsx(
"rounded-lg p-1 px-2 text-white",
StatusClassNames[info.getValue()],
)}
>
{TicketStatusLabel[info.getValue()]}
</span>
),
}),
columnHelper.accessor("assignedTo", {
header: "Assignee",
cell: (info) => users.find((x) => x.id === info.getValue())?.name || "",
}),
];
const getAssigneeValue = () => {
if (user && user.type === "agent")
return { value: user.id, label: `${user.name} - ${user.email}` };
if (assigneeFilter) {
const assigneeUser = users.find((x) => x.id === assigneeFilter);
return assigneeUser
? {
value: assigneeFilter,
label: `${assigneeUser.name} - ${assigneeUser.email}`,
}
: null;
}
return null;
};
const table = useReactTable({
data: filteredTickets,
columns: columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<>
<Modal
isOpen={!!selectedTicket}
onClose={() => {
reload();
setSelectedTicket(undefined);
}}
>
{selectedTicket && (
<TicketDisplay
user={user!}
ticket={selectedTicket}
onClose={() => {
reload();
setSelectedTicket(undefined);
}}
/>
)}
</Modal>
<Head>
<title>Tickets Panel | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
{user && (
<Layout user={user} className="gap-6">
<h1 className="text-2xl font-semibold">Tickets</h1>
<div className="flex w-full items-center gap-4">
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">
Status
</label>
<Select
options={Object.keys(TicketStatusLabel).map((x) => ({
value: x,
label: TicketStatusLabel[x as keyof typeof TicketStatusLabel],
}))}
value={
statusFilter
? {
value: statusFilter,
label: TicketStatusLabel[statusFilter],
}
: undefined
}
onChange={(value) =>
setStatusFilter((value?.value as TicketStatus) ?? undefined)
}
isClearable
placeholder="Status..."
/>
</div>
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">
Type
</label>
<Select
options={Object.keys(TicketTypeLabel).map((x) => ({
value: x,
label: TicketTypeLabel[x as keyof typeof TicketTypeLabel],
}))}
value={
typeFilter
? { value: typeFilter, label: TicketTypeLabel[typeFilter] }
: undefined
}
onChange={(value) =>
setTypeFilter((value?.value as TicketType) ?? undefined)
}
isClearable
placeholder="Type..."
/>
</div>
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">
Assignee
</label>
<Select
options={[
{ value: "me", label: "Assigned to me" },
...users
.filter((x) =>
["admin", "developer", "agent"].includes(x.type),
)
.map((u) => ({
value: u.id,
label: `${u.name} - ${u.email}`,
})),
]}
disabled={user.type === "agent"}
value={getAssigneeValue()}
onChange={(value) =>
value
? setAssigneeFilter(
value.value === "me" ? user.id : value.value,
)
: setAssigneeFilter(undefined)
}
placeholder="Assignee..."
isClearable
/>
</div>
</div>
<table className="bg-mti-purple-ultralight/40 w-full rounded-xl">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="px-4 py-4 text-left" key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr
className={clsx(
"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",
)}
onClick={() => setSelectedTicket(row.original)}
key={row.id}
>
{row.getVisibleCells().map((cell) => (
<td className="w-fit items-center px-4 py-2" key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</Layout>
)}
</>
);
}

53
src/utils/groups.be.ts Normal file
View File

@@ -0,0 +1,53 @@
import { app } from "@/firebase";
import { CorporateUser, StudentUser, TeacherUser } from "@/interfaces/user";
import { doc, getDoc, getFirestore, setDoc } from "firebase/firestore";
import moment from "moment";
const db = getFirestore(app);
export const updateExpiryDateOnGroup = async (
participantID: string,
corporateID: string,
) => {
const corporateRef = await getDoc(doc(db, "users", corporateID));
const participantRef = await getDoc(doc(db, "users", participantID));
if (!corporateRef.exists() || !participantRef.exists()) return;
const corporate = {
...corporateRef.data(),
id: corporateRef.id,
} as CorporateUser;
const participant = { ...participantRef.data(), id: participantRef.id } as
| StudentUser
| TeacherUser;
if (
corporate.type !== "corporate" ||
(participant.type !== "student" && participant.type !== "teacher")
)
return;
if (
!corporate.subscriptionExpirationDate ||
!participant.subscriptionExpirationDate
) {
return await setDoc(
doc(db, "users", participant.id),
{ subscriptionExpirationDate: null },
{ merge: true },
);
}
const corporateDate = moment(corporate.subscriptionExpirationDate);
const participantDate = moment(participant.subscriptionExpirationDate);
if (corporateDate.isAfter(participantDate))
return await setDoc(
doc(db, "users", participant.id),
{ subscriptionExpirationDate: corporateDate.toISOString() },
{ merge: true },
);
return;
};

View File

@@ -1,18 +1,24 @@
import {CorporateUser, Group, User} from "@/interfaces/user"; import { CorporateUser, Group, User } from "@/interfaces/user";
import axios from "axios"; import axios from "axios";
export const isUserFromCorporate = async (userID: string) => { export const isUserFromCorporate = async (userID: string) => {
const groups = (await axios.get<Group[]>(`/api/groups?participant=${userID}`)).data; const groups = (await axios.get<Group[]>(`/api/groups?participant=${userID}`))
const users = (await axios.get<User[]>("/api/users/list")).data; .data;
const users = (await axios.get<User[]>("/api/users/list")).data;
const adminTypes = groups.map((g) => users.find((u) => u.id === g.admin)?.type); const adminTypes = groups.map(
return adminTypes.includes("corporate"); (g) => users.find((u) => u.id === g.admin)?.type,
);
return adminTypes.includes("corporate");
}; };
export const getUserCorporate = async (userID: string) => { export const getUserCorporate = async (userID: string) => {
const groups = (await axios.get<Group[]>(`/api/groups?participant=${userID}`)).data; const groups = (await axios.get<Group[]>(`/api/groups?participant=${userID}`))
const users = (await axios.get<User[]>("/api/users/list")).data; .data;
const users = (await axios.get<User[]>("/api/users/list")).data;
const admins = groups.map((g) => users.find((u) => u.id === g.admin)).filter((x) => x?.type === "corporate"); const admins = groups
return admins.length > 0 ? (admins[0] as CorporateUser) : undefined; .map((g) => users.find((u) => u.id === g.admin))
.filter((x) => x?.type === "corporate");
return admins.length > 0 ? (admins[0] as CorporateUser) : undefined;
}; };