Merge remote-tracking branch 'origin/develop' into feature/training-content

This commit is contained in:
Carlos Mesquita
2024-08-27 17:10:57 +01:00
10 changed files with 143 additions and 98 deletions

View File

@@ -1,11 +1,11 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import { MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap } from "@/interfaces/exam"; import {MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import clsx from "clsx"; import clsx from "clsx";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
import { CommonProps } from "."; import {CommonProps} from ".";
import Button from "../Low/Button"; import Button from "../Low/Button";
import { v4 } from "uuid"; import {v4} from "uuid";
function Question({ function Question({
id, id,
@@ -14,12 +14,12 @@ function Question({
solution, solution,
options, options,
userSolution, userSolution,
}: MultipleChoiceQuestion & { userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean }) { }: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
const { userSolutions } = useExamStore((state) => state); const {userSolutions} = useExamStore((state) => state);
const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => { const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => {
if (foundMap) return foundMap; if (foundMap) return foundMap;
return userSolution.shuffleMaps?.find(map => map.questionID === id) || null; return userSolution.shuffleMaps?.find((map) => map.questionID === id) || null;
}, null as ShuffleMap | null); }, null as ShuffleMap | null);
const newSolution = questionShuffleMap ? questionShuffleMap?.map[solution] : solution; const newSolution = questionShuffleMap ? questionShuffleMap?.map[solution] : solution;
@@ -33,14 +33,14 @@ function Question({
const optionColor = (option: string) => { const optionColor = (option: string) => {
if (option === newSolution && !userSolution) { if (option === newSolution && !userSolution) {
return "!border-mti-gray-davy !text-mti-gray-davy"; return "!bg-mti-gray-davy !text-white";
} }
if (option === newSolution) { if (option === newSolution) {
return "!border-mti-purple-light !text-mti-purple-light"; return "!bg-mti-purple-light !text-white";
} }
return userSolution === option ? "!border-mti-rose-light !text-mti-rose-light" : ""; return userSolution === option ? "!bg-mti-rose-light !text-white" : "";
}; };
return ( return (
@@ -50,7 +50,10 @@ function Question({
) : ( ) : (
<span className="text-lg" key={v4()}> <span className="text-lg" key={v4()}>
<> <>
{id} - <span className="text-lg" key={v4()}>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span> {id} -{" "}
<span className="text-lg" key={v4()}>
{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}{" "}
</span>
</> </>
</span> </span>
)} )}
@@ -63,7 +66,9 @@ function Question({
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative select-none", "flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative select-none",
optionColor(option!.id), optionColor(option!.id),
)}> )}>
<span className={clsx("text-sm", newSolution !== option?.id && userSolution !== option?.id && "opacity-50")}>{option?.id}</span> <span className={clsx("text-sm", newSolution !== option?.id && userSolution !== option?.id && "opacity-50")}>
{option?.id}
</span>
{"src" in option && <img src={option?.src!} alt={`Option ${option?.id}`} />} {"src" in option && <img src={option?.src!} alt={`Option ${option?.id}`} />}
</div> </div>
))} ))}
@@ -71,7 +76,10 @@ function Question({
options.map((option) => ( options.map((option) => (
<div <div
key={option?.id} key={option?.id}
className={clsx("flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base select-none", optionColor(option!.id))}> className={clsx(
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base select-none",
optionColor(option!.id),
)}>
<span className="font-semibold">{option?.id}.</span> <span className="font-semibold">{option?.id}.</span>
<span>{option?.text}</span> <span>{option?.text}</span>
</div> </div>
@@ -81,32 +89,30 @@ function Question({
); );
} }
export default function MultipleChoice({ id, type, prompt, questions, userSolutions, onNext, onBack }: MultipleChoiceExercise & CommonProps) { export default function MultipleChoice({id, type, prompt, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
const { questionIndex, setQuestionIndex, partIndex, exam } = useExamStore((state) => state); const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
const stats = useExamStore((state) => state.userSolutions); const stats = useExamStore((state) => state.userSolutions);
const calculateScore = () => { const calculateScore = () => {
const total = questions.length; const total = questions.length;
const questionShuffleMap = stats.find((x) => x.exercise == id)?.shuffleMaps; const questionShuffleMap = stats.find((x) => x.exercise == id)?.shuffleMaps;
const correct = userSolutions.filter( const correct = userSolutions.filter((x) => {
(x) => { if (questionShuffleMap) {
if (questionShuffleMap) { const shuffleMap = questionShuffleMap.find((y) => y.questionID === x.question);
const shuffleMap = questionShuffleMap.find((y) => y.questionID === x.question) const originalSol = questions.find((y) => y.id.toString() === x.question.toString())?.solution!;
const originalSol = questions.find((y) => y.id.toString() === x.question.toString())?.solution!; return x.option == shuffleMap?.map[originalSol];
return x.option == shuffleMap?.map[originalSol] } else {
} else { return questions.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false;
return questions.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false }
} }).length;
}, const missing = total - userSolutions.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length;
).length; return {total, correct, missing};
const missing = total - userSolutions.filter((x) => questions.find((y) => x.question.toString() === y.id.toString())).length;
return { total, correct, missing };
}; };
const next = () => { const next = () => {
if (questionIndex === questions.length - 1) { if (questionIndex === questions.length - 1) {
onNext({ exercise: id, solutions: userSolutions, score: calculateScore(), type }); onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
} else { } else {
setQuestionIndex(questionIndex + 1); setQuestionIndex(questionIndex + 1);
} }
@@ -114,7 +120,7 @@ export default function MultipleChoice({ id, type, prompt, questions, userSoluti
const back = () => { const back = () => {
if (questionIndex === 0) { if (questionIndex === 0) {
onBack({ exercise: id, solutions: userSolutions, score: calculateScore(), type }); onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type});
} else { } else {
setQuestionIndex(questionIndex - 1); setQuestionIndex(questionIndex - 1);
} }
@@ -149,11 +155,19 @@ export default function MultipleChoice({ id, type, prompt, questions, userSoluti
</div> </div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8"> <div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full" <Button
color="purple"
variant="outline"
onClick={back}
className="max-w-[200px] w-full"
disabled={ disabled={
exam && typeof partIndex !== "undefined" && exam.module === "level" && exam &&
typeof exam.parts[0].intro === "string" && questionIndex === 0 && partIndex === 0} typeof partIndex !== "undefined" &&
> exam.module === "level" &&
typeof exam.parts[0].intro === "string" &&
questionIndex === 0 &&
partIndex === 0
}>
Back Back
</Button> </Button>

View File

@@ -55,7 +55,7 @@ const USER_TYPE_PERMISSIONS: {
}, },
}; };
export default function BatchCodeGenerator({user}: {user: User}) { export default function BatchCodeGenerator({user, onFinish}: {user: User; onFinish: () => void}) {
const [infos, setInfos] = useState<{email: string; name: string; passport_id: string}[]>([]); const [infos, setInfos] = useState<{email: string; name: string; passport_id: string}[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [expiryDate, setExpiryDate] = useState<Date | null>( const [expiryDate, setExpiryDate] = useState<Date | null>(
@@ -165,6 +165,8 @@ export default function BatchCodeGenerator({user}: {user: User}) {
)} codes and they have been notified by e-mail!`, )} codes and they have been notified by e-mail!`,
{toastId: "success"}, {toastId: "success"},
); );
onFinish();
return; return;
} }

View File

@@ -61,7 +61,7 @@ const USER_TYPE_PERMISSIONS: {
}, },
}; };
export default function BatchCreateUser({user}: {user: User}) { export default function BatchCreateUser({user, onFinish}: {user: User; onFinish: () => void}) {
const [infos, setInfos] = useState< const [infos, setInfos] = useState<
{ {
email: string; email: string;
@@ -159,6 +159,7 @@ export default function BatchCreateUser({user}: {user: User}) {
try { try {
for (const newUser of newUsers) await axios.post("/api/make_user", {...newUser, type, expiryDate}); for (const newUser of newUsers) await axios.post("/api/make_user", {...newUser, type, expiryDate});
toast.success(`Successfully added ${newUsers.length} user(s)!`); toast.success(`Successfully added ${newUsers.length} user(s)!`);
onFinish();
} catch { } catch {
toast.error("Something went wrong, please try again later!"); toast.error("Something went wrong, please try again later!");
} finally { } finally {

View File

@@ -48,7 +48,7 @@ const USER_TYPE_PERMISSIONS: {
}, },
}; };
export default function CodeGenerator({user}: {user: User}) { export default function CodeGenerator({user, onFinish}: {user: User; onFinish: () => void}) {
const [generatedCode, setGeneratedCode] = useState<string>(); const [generatedCode, setGeneratedCode] = useState<string>();
const [expiryDate, setExpiryDate] = useState<Date | null>( const [expiryDate, setExpiryDate] = useState<Date | null>(
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null, user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,

View File

@@ -54,7 +54,7 @@ const USER_TYPE_PERMISSIONS: {
}, },
}; };
export default function UserCreator({user}: {user: User}) { export default function UserCreator({user, onFinish}: {user: User; onFinish: () => void}) {
const [name, setName] = useState<string>(); const [name, setName] = useState<string>();
const [email, setEmail] = useState<string>(); const [email, setEmail] = useState<string>();
const [phone, setPhone] = useState<string>(); const [phone, setPhone] = useState<string>();
@@ -118,6 +118,7 @@ export default function UserCreator({user}: {user: User}) {
.post("/api/make_user", body) .post("/api/make_user", body)
.then(() => { .then(() => {
toast.success("That user has been created!"); toast.success("That user has been created!");
onFinish();
setName(""); setName("");
setEmail(""); setEmail("");

View File

@@ -160,49 +160,54 @@ export default function PaymentDue({user, hasExpired = false, reload}: Props) {
</div> </div>
</div> </div>
)} )}
{!isIndividual() && user.type === "corporate" && user?.corporateInformation.payment && ( {!isIndividual() &&
<div className="flex flex-col items-center"> (user?.type === "corporate" || user?.type === "mastercorporate") &&
<span className="max-w-lg"> user?.corporateInformation.payment && (
To add to your use of EnCoach and that of your students and teachers, please pay your designated package below: <div className="flex flex-col items-center">
</span> <span className="max-w-lg">
<div className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}> To add to your use of EnCoach and that of your students and teachers, please pay your designated package
<div className="mb-2 flex flex-col items-start"> below:
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" /> </span>
<span className="text-xl font-semibold">EnCoach - {user.corporateInformation?.monthlyDuration} Months</span> <div className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}>
</div> <div className="mb-2 flex flex-col items-start">
<div className="flex w-full flex-col items-start gap-2"> <img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" />
<span className="text-2xl"> <span className="text-xl font-semibold">
{user.corporateInformation.payment.value} {user.corporateInformation.payment.currency} EnCoach - {user.corporateInformation?.monthlyDuration} Months
</span> </span>
<PaymobPayment </div>
user={user} <div className="flex w-full flex-col items-start gap-2">
setIsPaymentLoading={setIsLoading} <span className="text-2xl">
currency={user.corporateInformation.payment.currency} {user.corporateInformation.payment.value} {user.corporateInformation.payment.currency}
price={user.corporateInformation.payment.value} </span>
duration={user.corporateInformation.monthlyDuration} <PaymobPayment
duration_unit="months" user={user}
onSuccess={() => { setIsPaymentLoading={setIsLoading}
setIsLoading(false); currency={user.corporateInformation.payment.currency}
setTimeout(reload, 500); price={user.corporateInformation.payment.value}
}} duration={user.corporateInformation.monthlyDuration}
/> duration_unit="months"
</div> onSuccess={() => {
<div className="flex flex-col items-start gap-1"> setIsLoading(false);
<span>This includes:</span> setTimeout(reload, 500);
<ul className="flex flex-col items-start text-sm"> }}
<li> />
- Allow a total of {user.corporateInformation.companyInformation.userAmount} students and teachers to </div>
use EnCoach <div className="flex flex-col items-start gap-1">
</li> <span>This includes:</span>
<li>- Train their abilities for the IELTS exam</li> <ul className="flex flex-col items-start text-sm">
<li>- Gain insights into your students&apos; weaknesses and strengths</li> <li>
<li>- Allow them to correctly prepare for the exam</li> - Allow a total of {user.corporateInformation.companyInformation.userAmount} students and teachers
</ul> to use EnCoach
</li>
<li>- Train their abilities for the IELTS exam</li>
<li>- Gain insights into your students&apos; weaknesses and strengths</li>
<li>- Allow them to correctly prepare for the exam</li>
</ul>
</div>
</div> </div>
</div> </div>
</div> )}
)} {!isIndividual() && !(user?.type === "corporate" || user?.type === "mastercorporate") && (
{!isIndividual() && user.type !== "corporate" && (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<span className="max-w-lg"> <span className="max-w-lg">
You are not the person in charge of your time credits, please contact your administrator about this situation. You are not the person in charge of your time credits, please contact your administrator about this situation.
@@ -213,17 +218,19 @@ export default function PaymentDue({user, hasExpired = false, reload}: Props) {
</span> </span>
</div> </div>
)} )}
{!isIndividual() && user.type === "corporate" && !user.corporateInformation.payment && ( {!isIndividual() &&
<div className="flex flex-col items-center"> (user?.type === "corporate" || user?.type === "mastercorporate") &&
<span className="max-w-lg"> !user.corporateInformation.payment && (
An admin nor your agent have yet set the price intended to your requirements in terms of the amount of users you <div className="flex flex-col items-center">
desire and your expected monthly duration. <span className="max-w-lg">
</span> An admin nor your agent have yet set the price intended to your requirements in terms of the amount of users
<span className="max-w-lg"> you desire and your expected monthly duration.
Please try again later or contact your agent or an admin, thank you for your patience. </span>
</span> <span className="max-w-lg">
</div> Please try again later or contact your agent or an admin, thank you for your patience.
)} </span>
</div>
)}
</div> </div>
</Layout> </Layout>
) : ( ) : (

View File

@@ -6,6 +6,7 @@ import {sessionOptions} from "@/lib/session";
import {v4} from "uuid"; import {v4} from "uuid";
import {CorporateUser, Group} from "@/interfaces/user"; import {CorporateUser, Group} from "@/interfaces/user";
import {createUserWithEmailAndPassword, getAuth} from "firebase/auth"; import {createUserWithEmailAndPassword, getAuth} from "firebase/auth";
import ShortUniqueId from "short-unique-id";
const DEFAULT_DESIRED_LEVELS = { const DEFAULT_DESIRED_LEVELS = {
reading: 9, reading: 9,
@@ -73,7 +74,22 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
subscriptionExpirationDate: expiryDate || null, subscriptionExpirationDate: expiryDate || null,
}; };
const uid = new ShortUniqueId();
const code = uid.randomUUID(6);
await setDoc(doc(db, "users", userId), user); await setDoc(doc(db, "users", userId), user);
await setDoc(doc(db, "codes", code), {
code,
creator: maker.id,
expiryDate,
type,
creationDate: new Date(),
userId,
email: email.toLowerCase(),
name: req.body.name,
passport_id,
});
if (type === "corporate") { if (type === "corporate") {
const defaultTeachersGroup: Group = { const defaultTeachersGroup: Group = {
admin: userId, admin: userId,
@@ -110,6 +126,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
if (!corporateSnapshot.empty) { if (!corporateSnapshot.empty) {
const corporateUser = corporateSnapshot.docs[0].data() as CorporateUser; const corporateUser = corporateSnapshot.docs[0].data() as CorporateUser;
await setDoc(doc(db, "codes", code), {creator: corporateUser.id}, {merge: true});
const q = query( const q = query(
collection(db, "groups"), collection(db, "groups"),
@@ -124,7 +141,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const participants: string[] = doc.get("participants"); const participants: string[] = doc.get("participants");
if (!participants.includes(userId)) { if (!participants.includes(userId)) {
updateDoc(doc.ref, { await updateDoc(doc.ref, {
participants: [...participants, userId], participants: [...participants, userId],
}); });
} }

View File

@@ -72,22 +72,25 @@ export default function Admin() {
{user && ( {user && (
<Layout user={user} className="gap-6"> <Layout user={user} className="gap-6">
<Modal isOpen={modalOpen === "batchCreateUser"} onClose={() => setModalOpen(undefined)}> <Modal isOpen={modalOpen === "batchCreateUser"} onClose={() => setModalOpen(undefined)}>
<BatchCreateUser user={user} /> <BatchCreateUser user={user} onFinish={() => setModalOpen(undefined)} />
</Modal> </Modal>
<Modal isOpen={modalOpen === "batchCreateCode"} onClose={() => setModalOpen(undefined)}> <Modal isOpen={modalOpen === "batchCreateCode"} onClose={() => setModalOpen(undefined)}>
<BatchCodeGenerator user={user} /> <BatchCodeGenerator user={user} onFinish={() => setModalOpen(undefined)} />
</Modal> </Modal>
<Modal isOpen={modalOpen === "createCode"} onClose={() => setModalOpen(undefined)}> <Modal isOpen={modalOpen === "createCode"} onClose={() => setModalOpen(undefined)}>
<CodeGenerator user={user} /> <CodeGenerator user={user} onFinish={() => setModalOpen(undefined)} />
</Modal> </Modal>
<Modal isOpen={modalOpen === "createUser"} onClose={() => setModalOpen(undefined)}> <Modal isOpen={modalOpen === "createUser"} onClose={() => setModalOpen(undefined)}>
<UserCreator user={user} /> <UserCreator user={user} onFinish={() => setModalOpen(undefined)} />
</Modal> </Modal>
<Modal isOpen={modalOpen === "gradingSystem"} onClose={() => setModalOpen(undefined)}> <Modal isOpen={modalOpen === "gradingSystem"} onClose={() => setModalOpen(undefined)}>
<CorporateGradingSystem <CorporateGradingSystem
user={user} user={user}
defaultSteps={gradingSystem?.steps || CEFR_STEPS} defaultSteps={gradingSystem?.steps || CEFR_STEPS}
mutate={(steps) => mutate({user: user.id, steps})} mutate={(steps) => {
mutate({user: user.id, steps});
setModalOpen(undefined);
}}
/> />
</Modal> </Modal>

View File

@@ -13,7 +13,7 @@ export const propagateStatusChange = (userId: string, status: UserStatus) =>
const user = docUser.data() as User; const user = docUser.data() as User;
// only update the status of the user's groups if the user is a corporate user // only update the status of the user's groups if the user is a corporate user
if (user.type === "corporate") { if (user.type === "corporate" || user.type === "mastercorporate") {
getDocs(query(collection(db, "groups"), where("admin", "==", userId))).then(async (userGroupsRef) => { getDocs(query(collection(db, "groups"), where("admin", "==", userId))).then(async (userGroupsRef) => {
const userGroups = userGroupsRef.docs.map((x) => x.data()); const userGroups = userGroupsRef.docs.map((x) => x.data());

View File

@@ -42,7 +42,7 @@ export async function getUserBalance(user: User) {
const groups = await getGroupsForUser(user.id); const groups = await getGroupsForUser(user.id);
const participants = uniq(groups.flatMap((x) => x.participants)); const participants = uniq(groups.flatMap((x) => x.participants));
if (user.type === "corporate") return participants.length + codes.length; if (user.type === "corporate") return participants.length + codes.filter((x) => !participants.includes(x.userId || "")).length;
const participantUsers = await Promise.all(participants.map(getUser)); const participantUsers = await Promise.all(participants.map(getUser));
const corporateUsers = participantUsers.filter((x) => x.type === "corporate") as CorporateUser[]; const corporateUsers = participantUsers.filter((x) => x.type === "corporate") as CorporateUser[];
@@ -50,6 +50,6 @@ export async function getUserBalance(user: User) {
return ( return (
corporateUsers.reduce((acc, curr) => acc + curr.corporateInformation?.companyInformation?.userAmount || 0, 0) + corporateUsers.reduce((acc, curr) => acc + curr.corporateInformation?.companyInformation?.userAmount || 0, 0) +
corporateUsers.length + corporateUsers.length +
codes.length codes.filter((x) => !participants.includes(x.userId || "") && !corporateUsers.map((u) => u.id).includes(x.userId || "")).length
); );
} }