Merged in feature/ticket-system (pull request #29)

Features: Ticket and Invite systems
This commit is contained in:
Tiago Ribeiro
2024-01-30 18:27:49 +00:00
24 changed files with 2928 additions and 876 deletions

View File

@@ -0,0 +1,253 @@
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}`,
})),
]}
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,189 @@
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 { BsShield, BsShieldFill, 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>
)}
<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"].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

@@ -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>

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;
}

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

@@ -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

@@ -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);
}
}

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

@@ -0,0 +1,338 @@
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) || user.type !== "developer") {
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 (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]);
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 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"].includes(x.type))
.map((u) => ({
value: u.id,
label: `${u.name} - ${u.email}`,
})),
]}
value={
assigneeFilter
? {
value: assigneeFilter,
label: `${users.find((u) => u.id === assigneeFilter)?.name} - ${users.find((u) => u.id === assigneeFilter)?.email}`,
}
: null
}
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>
)}
</>
);
}