Compare commits

..

14 Commits

Author SHA1 Message Date
Tiago Ribeiro
74dcccf089 Added a new type of e-mail to send to students when a new assignment is created 2024-01-17 15:55:33 +00:00
Tiago Ribeiro
63d2baf35f Improved the overall redirection of the login page 2024-01-17 14:25:37 +00:00
Tiago Ribeiro
c02a6a01f4 Merge branch 'develop' into feature/writing-diff-viewer 2024-01-17 12:58:44 +00:00
Tiago Ribeiro
a646955493 Solved a bug with calculations of the stats page 2024-01-17 11:59:40 +00:00
Tiago Ribeiro
7a577a7ca2 Solved another stupid bug 2024-01-17 11:50:50 +00:00
Tiago Ribeiro
c26ff48b60 Solved some issues with the Student Dashboard 2024-01-17 11:32:20 +00:00
Tiago Ribeiro
9ee09c8fda Added a diff viewer for the writing correction 2024-01-17 11:22:23 +00:00
João Ramos
d4867fd9a2 Merged in bug-fixing-16-jan-24 (pull request #26)
Report PDF improvements / bugs

Approved-by: Tiago Ribeiro
2024-01-16 23:20:00 +00:00
Tiago Ribeiro
13e52bfce6 Merge branch 'develop' into bug-fixing-16-jan-24 2024-01-16 23:14:05 +00:00
Tiago Ribeiro
5540e4a3e6 Updated the profile page a bit to accommodate recent changes 2024-01-16 23:11:16 +00:00
João Ramos
a18ee93909 Merged in feature-user-timezone-pdf (pull request #25)
Added Date export based on user timezone

Approved-by: Tiago Ribeiro
2024-01-16 23:07:07 +00:00
Tiago Ribeiro
0641d4250c Merged develop into feature-user-timezone-pdf 2024-01-16 23:03:46 +00:00
Tiago Ribeiro
f85a1f5601 Updated part of the correction display for Writing 2024-01-16 23:00:58 +00:00
Joao Ramos
01a9da3a5b Added Date export based on user timezone 2024-01-16 18:42:12 +00:00
24 changed files with 948 additions and 800 deletions

View File

@@ -42,6 +42,7 @@
"iron-session": "^6.3.1",
"lodash": "^4.17.21",
"moment": "^2.29.4",
"moment-timezone": "^0.5.44",
"next": "13.1.6",
"nodemailer": "^6.9.5",
"nodemailer-express-handlebars": "^6.1.0",
@@ -54,6 +55,7 @@
"react-csv": "^2.2.2",
"react-currency-input-field": "^3.6.12",
"react-datepicker": "^4.18.0",
"react-diff-viewer": "^3.1.1",
"react-dom": "18.2.0",
"react-firebase-hooks": "^5.1.1",
"react-icons": "^4.8.0",

View File

@@ -0,0 +1,64 @@
import { Fragment, useState } from "react";
import { Combobox, Transition } from "@headlessui/react";
import { BsChevronExpand } from "react-icons/bs";
import moment from "moment-timezone";
interface Props {
value?: string;
onChange?: (value: string) => void;
disabled?: boolean;
}
export default function TimezoneSelect({
value,
disabled = false,
onChange,
}: Props) {
const [query, setQuery] = useState("");
const timezones = moment.tz.names();
const filteredTimezones = query === "" ? timezones : timezones.filter((x) => x.toLowerCase().includes(query.toLowerCase()));
return (
<>
<Combobox value={value} onChange={onChange} disabled={disabled}>
<div className="relative mt-1">
<div className="relative w-full cursor-default overflow-hidden ">
<Combobox.Input
className="py-6 w-full px-8 text-sm font-normal placeholder:text-mti-gray-cool bg-white disabled:bg-mti-gray-platinum/40 rounded-full border border-mti-gray-platinum focus:outline-none"
onChange={(e) => setQuery(e.target.value)}
/>
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-8">
<BsChevronExpand />
</Combobox.Button>
</div>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
afterLeave={() => setQuery("")}
>
<Combobox.Options className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-xl bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
{filteredTimezones.map((timezone: string) => (
<Combobox.Option
key={timezone}
value={timezone}
className={({ active }) =>
`relative cursor-default select-none py-2 pl-10 pr-4 ${
active
? "bg-mti-purple-light text-white"
: "text-gray-900"
}`
}
>
{timezone}
</Combobox.Option>
))}
</Combobox.Options>
</Transition>
</div>
</Combobox>
</>
);
}

View File

@@ -6,30 +6,11 @@ import Button from "../Low/Button";
import {Dialog, Tab, Transition} from "@headlessui/react";
import {writingReverseMarking} from "@/utils/score";
import clsx from "clsx";
import reactStringReplace from "react-string-replace";
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const formatSolution = (solution: string, errors: {correction: string | null; misspelled: string}[]) => {
const errorRegex = new RegExp(errors.map((x) => `(${x.misspelled})`).join("|"));
return (
<>
{reactStringReplace(solution, errorRegex, (match) => {
const correction = errors.find((x) => x.misspelled === match)?.correction;
return (
<span
data-tip={correction ? correction : undefined}
className={clsx("text-mti-red-light font-medium underline underline-offset-2", correction && "tooltip")}>
{match}
</span>
);
})}
</>
);
};
const [showDiff, setShowDiff] = useState(false);
return (
<>
@@ -86,16 +67,51 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
<div className="w-full h-full flex flex-col gap-8">
{userSolutions && userSolutions.length > 0 && (
<div className="flex flex-col gap-4 w-full">
<div className="flex flex-col gap-4 w-full relative">
{!showDiff && (
<>
<span>Your answer:</span>
<div className="w-full h-full min-h-[320px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl whitespace-pre-wrap">
{userSolutions[0]!.evaluation && userSolutions[0]!.evaluation.misspelled_pairs
? formatSolution(
userSolutions[0]!.solution.replaceAll("\\n", "\n"),
userSolutions[0]!.evaluation.misspelled_pairs,
)
: userSolutions[0]!.solution.replaceAll("\\n", "\n")}
{userSolutions[0]!.solution.replaceAll("\\n", "\n")}
</div>
</>
)}
{showDiff && (
<>
<span>Correction:</span>
<div className="w-full h-full max-h-[320px] overflow-y-scroll scrollbar-hide cursor-text border-2 overflow-x-hidden border-mti-gray-platinum bg-white rounded-3xl">
<ReactDiffViewer
styles={{
contentText: {
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
padding: "32px 28px",
},
marker: {display: "none"},
diffRemoved: {padding: "32px 28px"},
diffAdded: {padding: "32px 28px"},
wordRemoved: {padding: "0px", display: "initial"},
wordAdded: {padding: "0px", display: "initial"},
wordDiff: {padding: "0px", display: "initial"},
}}
oldValue={userSolutions[0].solution.replaceAll("\\n", "\n")}
newValue={userSolutions[0].evaluation!.fixed_text!.replaceAll("\\n", "\n")}
splitView
hideLineNumbers
showDiffOnly={false}
/>
</div>
</>
)}
{userSolutions[0].solution && userSolutions[0].evaluation?.fixed_text && (
<Button
color="green"
variant="outline"
className="w-full max-w-[200px] self-end absolute -top-4 right-0 !py-2"
onClick={() => setShowDiff((prev) => !prev)}>
{showDiff ? "View answer" : "View correction"}
</Button>
)}
</div>
)}
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (

View File

@@ -30,20 +30,28 @@ export interface CommonProps {
export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: () => void, updateIndex?: (internalIndex: number) => void) => {
switch (exercise.type) {
case "fillBlanks":
return <FillBlanks {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
case "trueFalse":
return <TrueFalseSolution {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
return <TrueFalseSolution key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
case "matchSentences":
return <MatchSentences {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
case "multipleChoice":
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} updateIndex={updateIndex} onNext={onNext} onBack={onBack} />;
return (
<MultipleChoice
key={exercise.id}
{...(exercise as MultipleChoiceExercise)}
updateIndex={updateIndex}
onNext={onNext}
onBack={onBack}
/>
);
case "writeBlanks":
return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
case "writing":
return <Writing {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
case "speaking":
return <Speaking {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
case "interactiveSpeaking":
return <InteractiveSpeaking {...(exercise as InteractiveSpeakingExercise)} onNext={onNext} onBack={onBack} />;
return <InteractiveSpeaking key={exercise.id} {...(exercise as InteractiveSpeakingExercise)} onNext={onNext} onBack={onBack} />;
}
};

View File

@@ -34,8 +34,12 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
const [assignees, setAssignees] = useState<string[]>(assignment?.assignees || []);
const [name, setName] = useState(assignment?.name || generate({minLength: 6, maxLength: 8, min: 2, max: 3, join: " ", formatter: capitalize}));
const [isLoading, setIsLoading] = useState(false);
const [startDate, setStartDate] = useState<Date | null>(assignment ? moment(assignment.startDate).toDate() : moment().add(1, "day").toDate());
const [endDate, setEndDate] = useState<Date | null>(assignment ? moment(assignment.endDate).toDate() : moment().add(8, "day").toDate());
const [startDate, setStartDate] = useState<Date | null>(
assignment ? moment(assignment.startDate).toDate() : moment().hours(0).minutes(0).add(1, "day").toDate(),
);
const [endDate, setEndDate] = useState<Date | null>(
assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(),
);
// creates a new exam for each assignee or just one exam for all assignees
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
@@ -51,23 +55,16 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
const createAssignment = () => {
setIsLoading(true);
(assignment ? axios.patch : axios.post)(
`/api/assignments${assignment ? `/${assignment.id}` : ""}`,
{
(assignment ? axios.patch : axios.post)(`/api/assignments${assignment ? `/${assignment.id}` : ""}`, {
assignees,
name,
startDate,
endDate,
selectedModules,
generateMultiple,
}
)
})
.then(() => {
toast.success(
`The assignment "${name}" has been ${
assignment ? "updated" : "created"
} successfully!`
);
toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`);
cancelCreation();
})
.catch((e) => {
@@ -285,7 +282,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
</div>
</section>
<div className="flex gap-4 w-full justify-end">
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple(d => !d)}>
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}>
Generate different exams
</Checkbox>
</div>

View File

@@ -42,7 +42,7 @@ export default function StudentDashboard({user}: Props) {
const setAssignment = useExamStore((state) => state.setAssignment);
useEffect(() => {
getUserCorporate(user.id).then(setCorporateUserToShow);
getUserCorporate("IXdh9EQziAVXXh0jOiC5cPVlgS82").then(setCorporateUserToShow);
}, [user]);
const startAssignment = (assignment: Assignment) => {
@@ -70,7 +70,7 @@ export default function StudentDashboard({user}: Props) {
<>
{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>
Linked to: <b>{corporateUserToShow?.corporateInformation?.companyInformation.name || corporateUserToShow.name}</b>
</div>
)}
<ProfileSummary

View File

@@ -244,7 +244,7 @@ export default function TeacherDashboard({user}: Props) {
<>
{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>
Linked to: <b>{corporateUserToShow?.corporateInformation?.companyInformation.name || corporateUserToShow.name}</b>
</div>
)}
<section

View File

@@ -40,3 +40,15 @@ export function prepareMailOptions(context: object, to: string[], subject: strin
context,
};
}
export async function sendEmail(template: string, context: object, to: string[], subject: string): Promise<boolean> {
try {
const transport = prepareMailer(template);
const mailOptions = prepareMailOptions(context, to, subject, template);
await transport.sendMail(mailOptions);
return true;
} catch {
return false;
}
}

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>
<p>Hello {{user.name}},</p>
<br />
<p>You have just been given the assignment <b>"{{assignment.name}}"</b> by your teacher {{assignment.assigner}}!</p>
<br />
<p>It's start date will be on <b>{{assignment.startDate}}</b> and will only last until <b>{{assignment.endDate}}</b>
</p>
<br />
<p>For this assignment, you've been tasked with completing exams of the following modules:
<b>{{assignment.modules}}</b>.
</p>
<br />
<p>Don't forget to do it before its end date!</p>
<p>Click <b><a href="https://platform.encoach.com">here</a></b> to open the EnCoach Platform!</p>
<br />
<p>Thanks,</p>
<p>Your EnCoach team</p>
</div>
</html>

View File

@@ -0,0 +1,13 @@
{
"user": {
"name": "Tiago Ribeiro"
},
"assignment": {
"name": "Final Exam",
"assigner": "Teacher",
"assignees": [],
"modules": "Reading and Writing",
"startDate": "24/12/2023",
"endDate": "27/01/2024"
}
}

View File

@@ -15,10 +15,10 @@ export default function useUser({redirectTo = "", redirectIfFound = false} = {})
if (!redirectTo || !user) return;
if (
// If redirectTo is set, redirect if the user was not found.
(redirectTo && !redirectIfFound && (!user || (user && !user.isVerified))) ||
// If redirectIfFound is also set, redirect if the user was found
(redirectIfFound && user && user.isVerified)
(redirectIfFound && user && user.isVerified) ||
// If redirectTo is set, redirect if the user was not found.
(redirectTo && !redirectIfFound && (!user || (user && !user.isVerified)))
) {
Router.push(redirectTo);
}

View File

@@ -110,6 +110,7 @@ interface InteractiveSpeakingEvaluation extends Evaluation {
interface CommonEvaluation extends Evaluation {
perfect_answer?: string;
perfect_answer_1?: string;
fixed_text?: string;
}
export interface WritingExercise {

View File

@@ -78,6 +78,7 @@ export interface DemographicInformation {
gender: Gender;
employment: EmploymentStatus;
passport_id?: string;
timezone?: string;
}
export interface DemographicCorporateInformation {
@@ -85,6 +86,7 @@ export interface DemographicCorporateInformation {
phone: string;
gender: Gender;
position: string;
timezone?: string;
}
export type Gender = "male" | "female" | "other";

View File

@@ -65,7 +65,7 @@ export default function BatchCodeGenerator({user}: {user: User}) {
return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email)
? {
email: email.toString(),
name: `${firstName} ${lastName}`,
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
passport_id: passport_id.toString(),
}
: undefined;

View File

@@ -1,16 +1,6 @@
import type {NextApiRequest, NextApiResponse} from "next";
import {app, storage} from "@/firebase";
import {
getFirestore,
doc,
getDoc,
updateDoc,
getDocs,
query,
collection,
where,
documentId,
} from "firebase/firestore";
import {getFirestore, doc, getDoc, updateDoc, getDocs, query, collection, where, documentId} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import ReactPDF from "@react-pdf/renderer";
@@ -23,12 +13,9 @@ import { ModuleScore, StudentData } from "@/interfaces/module.scores";
import {SkillExamDetails} from "@/exams/pdf/details/skill.exam";
import {LevelExamDetails} from "@/exams/pdf/details/level.exam";
import {calculateBandScore, getLevelScore} from "@/utils/score";
import {
generateQRCode,
getRadialProgressPNG,
streamToBuffer,
} from "@/utils/pdf";
import {generateQRCode, getRadialProgressPNG, streamToBuffer} from "@/utils/pdf";
import {Group} from "@/interfaces/user";
import moment from "moment-timezone";
interface GroupScoreSummaryHelper {
score: [number, number];
@@ -98,7 +85,7 @@ const getScoreAndTotal = (stats: Stat[]) => {
total: acc.total + score.total,
};
},
{ correct: 0, total: 0 }
{correct: 0, total: 0},
);
};
@@ -146,29 +133,19 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const user = docUser.data() as User;
// generate the QR code for the report
const qrcode = await generateQRCode(
(req.headers.origin || "") + req.url
);
const qrcode = await generateQRCode((req.headers.origin || "") + req.url);
if (!qrcode) {
res.status(500).json({ok: false});
return;
}
const flattenResults = data.results.reduce(
(accm: Stat[], entry: any) => {
const flattenResults = data.results.reduce((accm: Stat[], entry: any) => {
const stats = entry.stats as Stat[];
return [...accm, ...stats];
},
[]
) as Stat[];
}, []) as Stat[];
const docsSnap = await getDocs(
query(
collection(db, "users"),
where(documentId(), "in", data.assignees)
)
);
const docsSnap = await getDocs(query(collection(db, "users"), where(documentId(), "in", data.assignees)));
const users = docsSnap.docs.map((d) => ({
...d.data(),
id: d.id,
@@ -176,24 +153,15 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const flattenResultsWithGrade = flattenResults.map((e) => {
const focus = users.find((u) => u.id === e.user)?.focus || "academic";
const bandScore = calculateBandScore(
e.score.correct,
e.score.total,
e.module,
focus
);
const bandScore = calculateBandScore(e.score.correct, e.score.total, e.module, focus);
return {...e, bandScore};
});
const moduleResults = data.exams.map(({module}) => {
const moduleResults = flattenResultsWithGrade.filter(
(e) => e.module === module
);
const moduleResults = flattenResultsWithGrade.filter((e) => e.module === module);
const baseBandScore =
moduleResults.reduce((accm, curr) => accm + curr.bandScore, 0) /
moduleResults.length;
const baseBandScore = moduleResults.reduce((accm, curr) => accm + curr.bandScore, 0) / moduleResults.length;
const bandScore = isNaN(baseBandScore) ? 0 : baseBandScore;
const {correct, total} = getScoreAndTotal(moduleResults);
const png = getRadialProgressPNG("azul", correct, total);
@@ -208,16 +176,11 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
};
}) as ModuleScore[];
const { correct: overallCorrect, total: overallTotal } =
getScoreAndTotal(flattenResults);
const {correct: overallCorrect, total: overallTotal} = getScoreAndTotal(flattenResults);
const baseOverallResult = overallCorrect / overallTotal;
const overallResult = isNaN(baseOverallResult) ? 0 : baseOverallResult;
const overallPNG = getRadialProgressPNG(
"laranja",
overallCorrect,
overallTotal
);
const overallPNG = getRadialProgressPNG("laranja", overallCorrect, overallTotal);
// generate the overall detail report
const overallDetail = {
module: "Overall",
@@ -234,7 +197,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
// or X modules, either way
// as long as I verify the first entry I should be fine
baseStat.module,
overallResult
overallResult,
);
const showLevel = baseStat.module === "level";
@@ -244,12 +207,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
if (showLevel) {
return {
title: "GROUP ENGLISH LEVEL TEST RESULT REPORT ",
details: (
<LevelExamDetails
detail={overallDetail}
title="Group Average CEFR"
/>
),
details: <LevelExamDetails detail={overallDetail} title="Group Average CEFR" />,
};
}
@@ -276,11 +234,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
day: "numeric",
});
const bandScore =
exams.length === 0
? 0
: exams.reduce((accm, curr) => accm + curr.bandScore, 0) /
exams.length;
const bandScore = exams.length === 0 ? 0 : exams.reduce((accm, curr) => accm + curr.bandScore, 0) / exams.length;
const {correct, total} = getScoreAndTotal(exams);
const result = exams.length === 0 ? "N/A" : `${correct}/${total}`;
@@ -292,9 +246,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
gender: user?.demographicInformation?.gender || "N/A",
date,
result,
level: showLevel
? getLevelScoreForUserExams(bandScore)
: undefined,
level: showLevel ? getLevelScoreForUserExams(bandScore) : undefined,
bandScore,
};
});
@@ -303,8 +255,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const studentsData = await getStudentsData();
const getGroupScoreSummary = () => {
const resultHelper = studentsData.reduce(
(accm: GroupScoreSummaryHelper[], curr) => {
const resultHelper = studentsData.reduce((accm: GroupScoreSummaryHelper[], curr) => {
const {bandScore, id} = curr;
const flooredScore = Math.floor(bandScore);
@@ -331,9 +282,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
sessions: [id],
},
];
},
[]
) as GroupScoreSummaryHelper[];
}, []) as GroupScoreSummaryHelper[];
const result = resultHelper.map(({score, label, sessions}) => {
const finalLabel = showLevel ? getLevelScore(score[0])[1] : label;
@@ -349,19 +298,14 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const getInstitution = async () => {
try {
// due to database inconsistencies, I'll be overprotective here
const assignerUserSnap = await getDoc(
doc(db, "users", data.assigner)
);
const assignerUserSnap = await getDoc(doc(db, "users", data.assigner));
if (assignerUserSnap.exists()) {
// we'll need the user in order to get the user data (name, email, focus, etc);
const assignerUser = assignerUserSnap.data() as User;
if (assignerUser.type === "teacher") {
// also search for groups where this user belongs
const queryGroups = query(
collection(db, "groups"),
where("participants", "array-contains", assignerUser.id)
);
const queryGroups = query(collection(db, "groups"), where("participants", "array-contains", assignerUser.id));
const groupSnapshot = await getDocs(queryGroups);
const groups = groupSnapshot.docs.map((doc) => ({
@@ -375,8 +319,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
where(
documentId(),
"in",
groups.map((g) => g.admin)
)
groups.map((g) => g.admin),
),
);
const adminUsersSnap = await getDocs(adminQuery);
@@ -385,22 +329,15 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
...doc.data(),
})) as CorporateUser[];
const adminData = admins.find(
(a) => a.corporateInformation?.companyInformation?.name
);
const adminData = admins.find((a) => a.corporateInformation?.companyInformation?.name);
if (adminData) {
return adminData.corporateInformation.companyInformation
.name;
return adminData.corporateInformation.companyInformation.name;
}
}
}
if (
assignerUser.type === "corporate" &&
assignerUser.corporateInformation?.companyInformation?.name
) {
return assignerUser.corporateInformation.companyInformation
.name;
if (assignerUser.type === "corporate" && assignerUser.corporateInformation?.companyInformation?.name) {
return assignerUser.corporateInformation.companyInformation.name;
}
}
} catch (err) {
@@ -411,12 +348,13 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const institution = await getInstitution();
const groupScoreSummary = getGroupScoreSummary();
const demographicInformation =
user.demographicInformation as DemographicInformation;
const demographicInformation = user.demographicInformation as DemographicInformation;
const pdfStream = await ReactPDF.renderToStream(
<GroupTestReport
title={title}
date={new Date(data.startDate).toLocaleString()}
date={moment(data.startDate)
.tz(user.demographicInformation?.timezone || "UTC")
.format("ll HH:mm:ss")}
name={user.name}
email={user.email}
id={user.id}
@@ -433,7 +371,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
summaryScore={`${(overallResult * 100).toFixed(0)}%`}
groupScoreSummary={groupScoreSummary}
passportId={demographicInformation?.passport_id || ""}
/>
/>,
);
// generate the file ref for storage

View File

@@ -1,14 +1,17 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, collection, getDocs, query, where, setDoc, doc} from "firebase/firestore";
import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {uuidv4} from "@firebase/util";
import {Module} from "@/interfaces";
import {getExams} from "@/utils/exams.be";
import {Exam} from "@/interfaces/exam";
import { flatten } from "lodash";
import {capitalize, flatten} from "lodash";
import {User} from "@/interfaces/user";
import moment from "moment";
import {sendEmail} from "@/email";
const db = getFirestore(app);
@@ -49,16 +52,11 @@ function getRandomIndex(arr: any[]): number {
return randomIndex;
}
const generateExams = async (
generateMultiple: Boolean,
selectedModules: Module[],
assignees: string[]
): Promise<ExamWithUser[]> => {
const generateExams = async (generateMultiple: Boolean, selectedModules: Module[], assignees: string[]): Promise<ExamWithUser[]> => {
if (generateMultiple) {
// for optimization purposes, it would be better to create a new endpoint that returned the answers for all users at once
const allExams = await assignees.map(async (assignee) => {
const selectedModulePromises = await selectedModules.map(
async (module: Module) => {
const selectedModulePromises = await selectedModules.map(async (module: Module) => {
try {
const exams: Exam[] = await getExams(db, module, "true", assignee);
@@ -71,22 +69,17 @@ const generateExams = async (
console.error(e);
return null;
}
},
[]
);
}, []);
const newModules = await Promise.all(selectedModulePromises);
return newModules;
}, []);
const exams = flatten(await Promise.all(allExams)).filter(
(x) => x !== null
) as ExamWithUser[];
const exams = flatten(await Promise.all(allExams)).filter((x) => x !== null) as ExamWithUser[];
return exams;
}
const selectedModulePromises = await selectedModules.map(
async (module: Module) => {
const selectedModulePromises = await selectedModules.map(async (module: Module) => {
const exams: Exam[] = await getExams(db, module, "false", undefined);
const exam = exams[getRandomIndex(exams)];
@@ -94,41 +87,34 @@ const generateExams = async (
return {module: exam.module, id: exam.id};
}
return null;
}
);
});
const exams = await Promise.all(selectedModulePromises);
const examesFiltered = exams.filter((x) => x !== null) as ExamWithUser[];
return flatten(
assignees.map((assignee) =>
examesFiltered.map((exam) => ({ ...exam, assignee }))
)
);
return flatten(assignees.map((assignee) => examesFiltered.map((exam) => ({...exam, assignee}))));
};
async function POST(req: NextApiRequest, res: NextApiResponse) {
const {
selectedModules,
assignees,
// Generarte multiple true would generate an unique exam for eacah user
// false would generate the same exam for all usersa
// Generate multiple true would generate an unique exam for each user
// false would generate the same exam for all users
generateMultiple = false,
...body
} = req.body as {
selectedModules: Module[];
assignees: string[];
generateMultiple: Boolean;
name: string;
startDate: string;
endDate: string;
};
const exams: ExamWithUser[] = await generateExams(
generateMultiple,
selectedModules,
assignees
);
const exams: ExamWithUser[] = await generateExams(generateMultiple, selectedModules, assignees);
if (exams.length === 0) {
res
.status(400)
.json({ ok: false, error: "No exams found for the selected modules" });
res.status(400).json({ok: false, error: "No exams found for the selected modules"});
return;
}
@@ -141,4 +127,23 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
});
res.status(200).json({ok: true});
for (const assigneeID of assignees) {
const assigneeSnapshot = await getDoc(doc(db, "users", assigneeID));
if (!assigneeSnapshot.exists()) continue;
const assignee = {id: assigneeID, ...assigneeSnapshot.data()} as User;
const name = body.name;
const teacher = req.session.user!;
const examModulesLabel = exams.map((x) => capitalize(x.module)).join(", ");
const startDate = moment(body.startDate).format("DD/MM/YYYY - HH:mm");
const endDate = moment(body.endDate).format("DD/MM/YYYY - HH:mm");
await sendEmail(
"assignment",
{user: {name: assignee.name}, assignment: {name, startDate, endDate, modules: examModulesLabel, assigner: teacher.name}},
[assignee.email],
"EnCoach - New Assignment!",
);
}
}

View File

@@ -4,7 +4,7 @@ import {getAuth as getAdminAuth, UserRecord} from "firebase-admin/auth";
import {app, adminApp} from "@/firebase";
import {sessionOptions} from "@/lib/session";
import {withIronSessionApiRoute} from "iron-session/next";
import {prepareMailer, prepareMailOptions} from "@/email";
import {prepareMailer, prepareMailOptions, sendEmail} from "@/email";
import ShortUniqueId from "short-unique-id";
export default withIronSessionApiRoute(sendVerification, sessionOptions);
@@ -13,8 +13,8 @@ async function sendVerification(req: NextApiRequest, res: NextApiResponse) {
const short = new ShortUniqueId();
if (req.session.user) {
const transport = prepareMailer("verification");
const mailOptions = prepareMailOptions(
const ok = await sendEmail(
"verification",
{
name: req.session.user.name,
code: short.randomUUID(6),
@@ -22,12 +22,9 @@ async function sendVerification(req: NextApiRequest, res: NextApiResponse) {
},
[req.session.user.email],
"EnCoach Verification",
"verification",
);
await transport.sendMail(mailOptions);
res.status(200).json({ok: true});
res.status(200).json({ok});
return;
}
res.status(404).json({ok: false});

View File

@@ -28,6 +28,7 @@ import {
getRadialProgressPNG,
streamToBuffer,
} from "@/utils/pdf";
import moment from "moment-timezone";
const db = getFirestore(app);
@@ -308,7 +309,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const pdfStream = await ReactPDF.renderToStream(
<TestReport
title={title}
date={new Date(stat.date).toLocaleString()}
date={moment(stat.date).tz(user.demographicInformation?.timezone || 'UTC').format('ll HH:mm:ss')}
name={user.name}
email={user.email}
id={user.id}

View File

@@ -2,7 +2,7 @@
import {User} from "@/interfaces/user";
import {toast, ToastContainer} from "react-toastify";
import axios from "axios";
import {FormEvent, useState} from "react";
import {FormEvent, useEffect, useState} from "react";
import Head from "next/head";
import useUser from "@/hooks/useUser";
import {Divider} from "primereact/divider";
@@ -13,9 +13,38 @@ import Input from "@/components/Low/Input";
import clsx from "clsx";
import {useRouter} from "next/router";
import EmailVerification from "./(auth)/EmailVerification";
import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/g);
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user;
const envVariables: {[key: string]: string} = {};
Object.keys(process.env)
.filter((x) => x.startsWith("NEXT_PUBLIC"))
.forEach((x: string) => {
envVariables[x] = process.env[x]!;
});
if (user && user.isVerified) {
res.setHeader("location", "/");
res.statusCode = 302;
res.end();
return {
props: {
user: null,
envVariables,
},
};
}
return {
props: {user: null, envVariables},
};
}, sessionOptions);
export default function Login() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@@ -29,6 +58,10 @@ export default function Login() {
redirectIfFound: true,
});
useEffect(() => {
if (user && user.isVerified) router.push("/");
}, [router, user]);
const forgotPassword = () => {
if (!email || email.length < 0 || !EMAIL_REGEX.test(email)) {
toast.error("Please enter your e-mail to reset your password!", {toastId: "forgot-invalid-email"});

View File

@@ -11,13 +11,12 @@ import Button from "@/components/Low/Button";
import Link from "next/link";
import axios from "axios";
import {ErrorMessage} from "@/constants/errors";
import {RadioGroup} from "@headlessui/react";
import clsx from "clsx";
import {CorporateUser, EmploymentStatus, EMPLOYMENT_STATUS, Gender, User} from "@/interfaces/user";
import CountrySelect from "@/components/Low/CountrySelect";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import moment from "moment";
import {BsCamera, BsCameraFill} from "react-icons/bs";
import {BsCamera} from "react-icons/bs";
import {USER_TYPE_LABELS} from "@/resources/user";
import useGroups from "@/hooks/useGroups";
import useUsers from "@/hooks/useUsers";
@@ -25,7 +24,7 @@ import {convertBase64} from "@/utils";
import {Divider} from "primereact/divider";
import GenderInput from "@/components/High/GenderInput";
import EmploymentStatusInput from "@/components/High/EmploymentStatusInput";
import TimezoneSelect from "@/components/Low/TImezoneSelect";
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user;
@@ -83,7 +82,7 @@ function UserProfile({user, mutateUser}: Props) {
const [commercialRegistration, setCommercialRegistration] = useState<string | undefined>(
user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined,
);
const [timezone, setTimezone] = useState<string>(user.demographicInformation?.timezone || "UTC");
const {groups} = useGroups();
const {users} = useUsers();
@@ -146,6 +145,7 @@ function UserProfile({user, mutateUser}: Props) {
position: user?.type === "corporate" ? position : undefined,
gender,
passport_id,
timezone,
},
...(user.type === "corporate" ? {corporateInformation} : {}),
});
@@ -247,6 +247,13 @@ function UserProfile({user, mutateUser}: Props) {
</div>
);
const TimezoneInput = () => (
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Timezone</label>
<TimezoneSelect value={timezone} onChange={setTimezone} />
</div>
);
return (
<Layout user={user}>
<section className="w-full flex flex-col gap-4 md:gap-8 px-4 py-8">
@@ -286,8 +293,15 @@ function UserProfile({user, mutateUser}: Props) {
/>
</DoubleColumnRow>
<PasswordInput />
{user.type === "agent" && <AgentInformationInput />}
{user.type === "student" && (
<DoubleColumnRow>
<CountryInput />
<PhoneInput />
</DoubleColumnRow>
{user.type === "student" ? (
<DoubleColumnRow>
<Input
type="text"
name="passport_id"
@@ -297,13 +311,11 @@ function UserProfile({user, mutateUser}: Props) {
value={passport_id}
required
/>
)}
{user.type === "agent" && <AgentInformationInput />}
<DoubleColumnRow>
<CountryInput />
<PhoneInput />
<TimezoneInput />
</DoubleColumnRow>
) : (
<TimezoneInput />
)}
<Divider />

View File

@@ -139,9 +139,9 @@ export default function Stats() {
}
}, [startDate, endDate]);
const calculateTotalScore = (stats: Stat[]) => {
const calculateTotalScore = (stats: Stat[], divisionFactor: number) => {
const moduleScores = calculateModuleScore(stats);
return moduleScores.reduce((acc, curr) => acc + curr.score, 0) / 4;
return moduleScores.reduce((acc, curr) => acc + curr.score, 0) / divisionFactor;
};
const calculateScorePerModule = (stats: Stat[], module: Module) => {
@@ -278,7 +278,10 @@ export default function Stats() {
</span>
<span className="px-2">
Level{" "}
{calculateTotalScore(stats.filter((s) => timestampToMoment(s).isBefore(date))).toFixed(1)}
{calculateTotalScore(
stats.filter((s) => timestampToMoment(s).isBefore(date)),
5,
).toFixed(1)}
</span>
</div>
) : null;
@@ -364,6 +367,7 @@ export default function Stats() {
return date.isValid()
? calculateTotalScore(
stats.filter((s) => timestampToMoment(s).isBefore(date)),
5,
).toFixed(1)
: undefined;
})
@@ -599,9 +603,12 @@ export default function Stats() {
}}
/>
<div className="flex -md:flex-col -md:items-center gap-4 flex-wrap">
{/* Reading Score Band in Interval */}
<div className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-fit md:h-96">
<span className="text-sm font-bold">Reading Score Band in Interval</span>
{/* Module Score Band in Interval */}
{MODULE_ARRAY.map((module, index) => (
<div
className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-fit md:h-96"
key={module}>
<span className="text-sm font-bold">{capitalize(module)} Score Band in Interval</span>
<Chart
options={{
scales: {
@@ -617,17 +624,18 @@ export default function Stats() {
datasets: [
{
type: "line",
label: "Reading",
label: capitalize(module),
fill: false,
borderColor: COLORS[0],
backgroundColor: COLORS[0],
borderColor: COLORS[index],
backgroundColor: COLORS[index],
borderWidth: 2,
spanGaps: true,
data: intervalDates.map((date) => {
return calculateTotalScore(
stats.filter(
(s) => timestampToMoment(s).isBefore(date) && s.module === "reading",
(s) => timestampToMoment(s).isBefore(date) && s.module === module,
),
1,
).toFixed(1);
}),
},
@@ -635,152 +643,7 @@ export default function Stats() {
}}
/>
</div>
{/* Listening Score Band in Interval */}
<div className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-fit md:h-96">
<span className="text-sm font-bold">Listening Score Band in Interval</span>
<Chart
options={{
scales: {
y: {
min: 0,
max: 9,
},
},
}}
type="line"
data={{
labels: intervalDates.map((date) => moment(date).format("DD/MM/YYYY")),
datasets: [
{
type: "line",
label: "Listening",
fill: false,
borderColor: COLORS[1],
backgroundColor: COLORS[1],
borderWidth: 2,
spanGaps: true,
data: intervalDates.map((date) => {
return calculateTotalScore(
stats.filter(
(s) => timestampToMoment(s).isBefore(date) && s.module === "listening",
),
).toFixed(1);
}),
},
],
}}
/>
</div>
{/* Writing Score Band in Interval */}
<div className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-fit md:h-96">
<span className="text-sm font-bold">Writing Score Band in Interval</span>
<Chart
options={{
scales: {
y: {
min: 0,
max: 9,
},
},
}}
type="line"
data={{
labels: intervalDates.map((date) => moment(date).format("DD/MM/YYYY")),
datasets: [
{
type: "line",
label: "Writing",
fill: false,
borderColor: COLORS[2],
backgroundColor: COLORS[2],
borderWidth: 2,
spanGaps: true,
data: intervalDates.map((date) => {
return calculateTotalScore(
stats.filter(
(s) => timestampToMoment(s).isBefore(date) && s.module === "writing",
),
).toFixed(1);
}),
},
],
}}
/>
</div>
{/* Speaking Score Band in Interval */}
<div className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-fit md:h-96">
<span className="text-sm font-bold">Speaking Score Band in Interval</span>
<Chart
options={{
scales: {
y: {
min: 0,
max: 9,
},
},
}}
type="line"
data={{
labels: intervalDates.map((date) => moment(date).format("DD/MM/YYYY")),
datasets: [
{
type: "line",
label: "Speaking",
fill: false,
borderColor: COLORS[3],
backgroundColor: COLORS[3],
borderWidth: 2,
spanGaps: true,
data: intervalDates.map((date) => {
return calculateTotalScore(
stats.filter(
(s) => timestampToMoment(s).isBefore(date) && s.module === "speaking",
),
).toFixed(1);
}),
},
],
}}
/>
</div>
{/* Level Score Band in Interval */}
<div className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-fit md:h-96">
<span className="text-sm font-bold">Level Score Band in Interval</span>
<Chart
options={{
scales: {
y: {
min: 0,
max: 9,
},
},
}}
type="line"
data={{
labels: intervalDates.map((date) => moment(date).format("DD/MM/YYYY")),
datasets: [
{
type: "line",
label: "Level",
fill: false,
borderColor: COLORS[4],
backgroundColor: COLORS[4],
borderWidth: 2,
spanGaps: true,
data: intervalDates.map((date) => {
return calculateTotalScore(
stats.filter((s) => timestampToMoment(s).isBefore(date) && s.module === "level"),
).toFixed(1);
}),
},
],
}}
/>
</div>
))}
</div>
</div>
</>

View File

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

View File

@@ -142,23 +142,24 @@ export const calculateBandScore = (correct: number, total: number, module: Modul
};
export const calculateAverageLevel = (levels: {[key in Module]: number}) => {
return Object.keys(levels).reduce((accumulator, current) => levels[current as Module] + accumulator, 0) / 4;
return Object.keys(levels).reduce((accumulator, current) => levels[current as Module] + accumulator, 0) / 5;
};
export const getLevelScore = (level: number) => {
switch (level) {
case 0:
return ['Beginner', 'Low A1'];
return ["Beginner", "Low A1"];
case 2:
return ['Elementary', 'High A1/Low A2'];
return ["Elementary", "High A1/Low A2"];
case 4:
return ['Pre-Intermediate', 'High A2/Low B1'];
return ["Pre-Intermediate", "High A2/Low B1"];
case 6:
return ['Intermediate', 'High B1/Low B2'];
return ["Intermediate", "High B1/Low B2"];
case 8:
return ['Upper-Intermediate', 'High B2/Low C1'];
return ["Upper-Intermediate", "High B2/Low C1"];
case 9:
return ['Advanced', 'C1'];
default: return [];
}
return ["Advanced", "C1"];
default:
return [];
}
};

163
yarn.lock
View File

@@ -10,7 +10,7 @@
"@babel/highlight" "^7.22.13"
chalk "^2.4.2"
"@babel/helper-module-imports@^7.16.7":
"@babel/helper-module-imports@^7.0.0", "@babel/helper-module-imports@^7.16.7":
version "7.22.15"
resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz#16146307acdc40cc00c3b2c647713076464bdbf0"
integrity sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==
@@ -62,6 +62,13 @@
dependencies:
regenerator-runtime "^0.13.11"
"@babel/runtime@^7.7.2":
version "7.23.8"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.8.tgz#8ee6fe1ac47add7122902f257b8ddf55c898f650"
integrity sha512-Y7KbAP984rn1VGMbGqKmBLio9V7y5Je9GvU4rQPCPinCyNfUcToxIXl06d59URp/F3LwinvODxab5N/G6qggkw==
dependencies:
regenerator-runtime "^0.14.0"
"@babel/types@^7.22.15":
version "7.23.0"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.23.0.tgz#8c1f020c9df0e737e4e247c0619f58c68458aaeb"
@@ -98,6 +105,16 @@
source-map "^0.5.7"
stylis "4.2.0"
"@emotion/cache@^10.0.27":
version "10.0.29"
resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-10.0.29.tgz#87e7e64f412c060102d589fe7c6dc042e6f9d1e0"
integrity sha512-fU2VtSVlHiF27empSbxi1O2JFdNWZO+2NFHfwO0pxgTep6Xa3uGb+3pVKfLww2l/IBGLNEZl5Xf/++A4wAYDYQ==
dependencies:
"@emotion/sheet" "0.9.4"
"@emotion/stylis" "0.8.5"
"@emotion/utils" "0.11.3"
"@emotion/weak-memoize" "0.2.5"
"@emotion/cache@^11.11.0", "@emotion/cache@^11.4.0":
version "11.11.0"
resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-11.11.0.tgz#809b33ee6b1cb1a625fef7a45bc568ccd9b8f3ff"
@@ -109,6 +126,11 @@
"@emotion/weak-memoize" "^0.3.1"
stylis "4.2.0"
"@emotion/hash@0.8.0":
version "0.8.0"
resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.8.0.tgz#bbbff68978fefdbe68ccb533bc8cbe1d1afb5413"
integrity sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==
"@emotion/hash@^0.9.1":
version "0.9.1"
resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.9.1.tgz#4ffb0055f7ef676ebc3a5a91fb621393294e2f43"
@@ -145,6 +167,17 @@
"@emotion/weak-memoize" "^0.3.1"
hoist-non-react-statics "^3.3.1"
"@emotion/serialize@^0.11.15", "@emotion/serialize@^0.11.16":
version "0.11.16"
resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-0.11.16.tgz#dee05f9e96ad2fb25a5206b6d759b2d1ed3379ad"
integrity sha512-G3J4o8by0VRrO+PFeSc3js2myYNOXVJ3Ya+RGVxnshRYgsvErfAOglKAiy1Eo1vhzxqtUvjCyS5gtewzkmvSSg==
dependencies:
"@emotion/hash" "0.8.0"
"@emotion/memoize" "0.7.4"
"@emotion/unitless" "0.7.5"
"@emotion/utils" "0.11.3"
csstype "^2.5.7"
"@emotion/serialize@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-1.1.2.tgz#017a6e4c9b8a803bd576ff3d52a0ea6fa5a62b51"
@@ -156,11 +189,26 @@
"@emotion/utils" "^1.2.1"
csstype "^3.0.2"
"@emotion/sheet@0.9.4":
version "0.9.4"
resolved "https://registry.yarnpkg.com/@emotion/sheet/-/sheet-0.9.4.tgz#894374bea39ec30f489bbfc3438192b9774d32e5"
integrity sha512-zM9PFmgVSqBw4zL101Q0HrBVTGmpAxFZH/pYx/cjJT5advXguvcgjHFTCaIO3enL/xr89vK2bh0Mfyj9aa0ANA==
"@emotion/sheet@^1.2.2":
version "1.2.2"
resolved "https://registry.yarnpkg.com/@emotion/sheet/-/sheet-1.2.2.tgz#d58e788ee27267a14342303e1abb3d508b6d0fec"
integrity sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA==
"@emotion/stylis@0.8.5":
version "0.8.5"
resolved "https://registry.yarnpkg.com/@emotion/stylis/-/stylis-0.8.5.tgz#deacb389bd6ee77d1e7fcaccce9e16c5c7e78e04"
integrity sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ==
"@emotion/unitless@0.7.5":
version "0.7.5"
resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.7.5.tgz#77211291c1900a700b8a78cfafda3160d76949ed"
integrity sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==
"@emotion/unitless@^0.8.1":
version "0.8.1"
resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.8.1.tgz#182b5a4704ef8ad91bde93f7a860a88fd92c79a3"
@@ -171,11 +219,21 @@
resolved "https://registry.yarnpkg.com/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz#08de79f54eb3406f9daaf77c76e35313da963963"
integrity sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==
"@emotion/utils@0.11.3":
version "0.11.3"
resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-0.11.3.tgz#a759863867befa7e583400d322652a3f44820924"
integrity sha512-0o4l6pZC+hI88+bzuaX/6BgOvQVhbt2PfmxauVaYOGgbsAw14wdKyvMCZXnsnsHys94iadcF+RG/wZyx6+ZZBw==
"@emotion/utils@^1.2.1":
version "1.2.1"
resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-1.2.1.tgz#bbab58465738d31ae4cb3dbb6fc00a5991f755e4"
integrity sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg==
"@emotion/weak-memoize@0.2.5":
version "0.2.5"
resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz#8eed982e2ee6f7f4e44c253e12962980791efd46"
integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==
"@emotion/weak-memoize@^0.3.1":
version "0.3.1"
resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz#d0fce5d07b0620caa282b5131c297bb60f9d87e6"
@@ -1837,6 +1895,31 @@ axobject-query@^3.1.1:
dependencies:
deep-equal "^2.0.5"
babel-plugin-emotion@^10.0.27:
version "10.2.2"
resolved "https://registry.yarnpkg.com/babel-plugin-emotion/-/babel-plugin-emotion-10.2.2.tgz#a1fe3503cff80abfd0bdda14abd2e8e57a79d17d"
integrity sha512-SMSkGoqTbTyUTDeuVuPIWifPdUGkTk1Kf9BWRiXIOIcuyMfsdp2EjeiiFvOzX8NOBvEh/ypKYvUh2rkgAJMCLA==
dependencies:
"@babel/helper-module-imports" "^7.0.0"
"@emotion/hash" "0.8.0"
"@emotion/memoize" "0.7.4"
"@emotion/serialize" "^0.11.16"
babel-plugin-macros "^2.0.0"
babel-plugin-syntax-jsx "^6.18.0"
convert-source-map "^1.5.0"
escape-string-regexp "^1.0.5"
find-root "^1.1.0"
source-map "^0.5.7"
babel-plugin-macros@^2.0.0:
version "2.8.0"
resolved "https://registry.yarnpkg.com/babel-plugin-macros/-/babel-plugin-macros-2.8.0.tgz#0f958a7cc6556b1e65344465d99111a1e5e10138"
integrity sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg==
dependencies:
"@babel/runtime" "^7.7.2"
cosmiconfig "^6.0.0"
resolve "^1.12.0"
babel-plugin-macros@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz#9ef6dc74deb934b4db344dc973ee851d148c50c1"
@@ -1846,6 +1929,11 @@ babel-plugin-macros@^3.1.0:
cosmiconfig "^7.0.0"
resolve "^1.19.0"
babel-plugin-syntax-jsx@^6.18.0:
version "6.18.0"
resolved "https://registry.yarnpkg.com/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz#0af32a9a6e13ca7a3fd5069e62d7b0f58d0d8946"
integrity sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw==
balanced-match@^1.0.0:
version "1.0.2"
resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz"
@@ -2176,6 +2264,17 @@ core-util-is@~1.0.0:
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85"
integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==
cosmiconfig@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-6.0.0.tgz#da4fee853c52f6b1e6935f41c1a2fc50bd4a9982"
integrity sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==
dependencies:
"@types/parse-json" "^4.0.0"
import-fresh "^3.1.0"
parse-json "^5.0.0"
path-type "^4.0.0"
yaml "^1.7.2"
cosmiconfig@^7.0.0:
version "7.1.0"
resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.1.0.tgz#1443b9afa596b670082ea46cbd8f6a62b84635f6"
@@ -2202,6 +2301,16 @@ country-flag-icons@^1.5.4:
resolved "https://registry.yarnpkg.com/country-flag-icons/-/country-flag-icons-1.5.7.tgz#f1f2ddf14f3cbf01cba6746374aeba94db35d4b4"
integrity sha512-AdvXhMcmSp7nBSkpGfW4qR/luAdRUutJqya9PuwRbsBzuoknThfultbv7Ib6fWsHXC43Es/4QJ8gzQQdBNm75A==
create-emotion@^10.0.14, create-emotion@^10.0.27:
version "10.0.27"
resolved "https://registry.yarnpkg.com/create-emotion/-/create-emotion-10.0.27.tgz#cb4fa2db750f6ca6f9a001a33fbf1f6c46789503"
integrity sha512-fIK73w82HPPn/RsAij7+Zt8eCE8SptcJ3WoRMfxMtjteYxud8GDTKKld7MYwAX2TVhrw29uR1N/bVGxeStHILg==
dependencies:
"@emotion/cache" "^10.0.27"
"@emotion/serialize" "^0.11.15"
"@emotion/sheet" "0.9.4"
"@emotion/utils" "0.11.3"
cross-fetch@^3.1.5:
version "3.1.8"
resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.8.tgz#0327eba65fd68a7d119f8fb2bf9334a1a7956f82"
@@ -2247,6 +2356,11 @@ cssesc@^3.0.0:
resolved "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz"
integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
csstype@^2.5.7:
version "2.6.21"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.21.tgz#2efb85b7cc55c80017c66a5ad7cbd931fda3a90e"
integrity sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==
csstype@^3.0.2:
version "3.1.1"
resolved "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz"
@@ -2378,6 +2492,11 @@ didyoumean@^1.2.2:
resolved "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz"
integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==
diff@^4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
dijkstrajs@^1.0.1:
version "1.0.3"
resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz#4c8dbdea1f0f6478bff94d9c49c784d623e4fc23"
@@ -2476,6 +2595,14 @@ emoji-regex@^9.2.2:
resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz"
integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==
emotion@^10.0.14:
version "10.0.27"
resolved "https://registry.yarnpkg.com/emotion/-/emotion-10.0.27.tgz#f9ca5df98630980a23c819a56262560562e5d75e"
integrity sha512-2xdDzdWWzue8R8lu4G76uWX5WhyQuzATon9LmNeCy/2BHVC6dsEpfhN1a0qhELgtDVdjyEA6J8Y/VlI5ZnaH0g==
dependencies:
babel-plugin-emotion "^10.0.27"
create-emotion "^10.0.27"
encode-utf8@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/encode-utf8/-/encode-utf8-1.0.3.tgz#f30fdd31da07fb596f281beb2f6b027851994cda"
@@ -3570,7 +3697,7 @@ ignore@^5.2.0:
resolved "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz"
integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==
import-fresh@^3.0.0, import-fresh@^3.2.1:
import-fresh@^3.0.0, import-fresh@^3.1.0, import-fresh@^3.2.1:
version "3.3.0"
resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz"
integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==
@@ -4252,7 +4379,7 @@ media-engine@^1.0.3:
resolved "https://registry.yarnpkg.com/media-engine/-/media-engine-1.0.3.tgz#be3188f6cd243ea2a40804a35de5a5b032f58dad"
integrity sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==
memoize-one@^5.1.1:
memoize-one@^5.0.4, memoize-one@^5.1.1:
version "5.2.1"
resolved "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz"
integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
@@ -4360,6 +4487,13 @@ mkdirp@^1.0.3, mkdirp@^1.0.4:
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
moment-timezone@^0.5.44:
version "0.5.44"
resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.44.tgz#a64a4e47b68a43deeab5ae4eb4f82da77cdf595f"
integrity sha512-nv3YpzI/8lkQn0U6RkLd+f0W/zy/JnoR5/EyPz/dNkPTBjA2jNLCVxaiQ8QpeLymhSZvX0wCL5s27NQWdOPwAw==
dependencies:
moment "^2.29.4"
moment@^2.29.4:
version "2.29.4"
resolved "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz"
@@ -5024,6 +5158,18 @@ react-datepicker@^4.18.0:
react-onclickoutside "^6.13.0"
react-popper "^2.3.0"
react-diff-viewer@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/react-diff-viewer/-/react-diff-viewer-3.1.1.tgz#21ac9c891193d05a3734bfd6bd54b107ee6d46cc"
integrity sha512-rmvwNdcClp6ZWdS11m1m01UnBA4OwYaLG/li0dB781e/bQEzsGyj+qewVd6W5ztBwseQ72pO7nwaCcq5jnlzcw==
dependencies:
classnames "^2.2.6"
create-emotion "^10.0.14"
diff "^4.0.1"
emotion "^10.0.14"
memoize-one "^5.0.4"
prop-types "^15.6.2"
react-dom@18.2.0:
version "18.2.0"
resolved "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz"
@@ -5261,6 +5407,15 @@ resolve@^1.1.7, resolve@^1.22.1:
path-parse "^1.0.7"
supports-preserve-symlinks-flag "^1.0.0"
resolve@^1.12.0:
version "1.22.8"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d"
integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==
dependencies:
is-core-module "^2.13.0"
path-parse "^1.0.7"
supports-preserve-symlinks-flag "^1.0.0"
resolve@^1.19.0:
version "1.22.6"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.6.tgz#dd209739eca3aef739c626fea1b4f3c506195362"
@@ -6200,7 +6355,7 @@ yallist@^4.0.0:
resolved "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz"
integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
yaml@^1.10.0, yaml@^1.10.2:
yaml@^1.10.0, yaml@^1.10.2, yaml@^1.7.2:
version "1.10.2"
resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz"
integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==