Created a simple invite system that notifies users via e-mail when a corporate uploads an Excel file with already registered students
This commit is contained in:
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
28
src/email/templates/receivedInvite.handlebars
Normal file
28
src/email/templates/receivedInvite.handlebars
Normal 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>
|
||||||
25
src/email/templates/respondedInvite.handlebars
Normal file
25
src/email/templates/respondedInvite.handlebars
Normal 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>
|
||||||
35
src/hooks/useInvites.tsx
Normal file
35
src/hooks/useInvites.tsx
Normal 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 };
|
||||||
|
}
|
||||||
5
src/interfaces/invite.ts
Normal file
5
src/interfaces/invite.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export interface Invite {
|
||||||
|
id: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
);
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
82
src/pages/api/invites/[id].ts
Normal file
82
src/pages/api/invites/[id].ts
Normal 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 });
|
||||||
|
}
|
||||||
134
src/pages/api/invites/accept/[id].ts
Normal file
134
src/pages/api/invites/accept/[id].ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/pages/api/invites/decline/[id].ts
Normal file
72
src/pages/api/invites/decline/[id].ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/pages/api/invites/index.ts
Normal file
79
src/pages/api/invites/index.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
// 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 shortUID = new ShortUniqueId();
|
||||||
|
await setDoc(doc(db, "invites", body.id || shortUID.randomUUID(8)), body);
|
||||||
|
|
||||||
|
res.status(200).json({ ok: true });
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user