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 */
import { MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap } from "@/interfaces/exam";
import {MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import clsx from "clsx";
import reactStringReplace from "react-string-replace";
import { CommonProps } from ".";
import {CommonProps} from ".";
import Button from "../Low/Button";
import { v4 } from "uuid";
import {v4} from "uuid";
function Question({
id,
@@ -14,12 +14,12 @@ function Question({
solution,
options,
userSolution,
}: MultipleChoiceQuestion & { userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean }) {
const { userSolutions } = useExamStore((state) => state);
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
const {userSolutions} = useExamStore((state) => state);
const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => {
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);
const newSolution = questionShuffleMap ? questionShuffleMap?.map[solution] : solution;
@@ -33,14 +33,14 @@ function Question({
const optionColor = (option: string) => {
if (option === newSolution && !userSolution) {
return "!border-mti-gray-davy !text-mti-gray-davy";
return "!bg-mti-gray-davy !text-white";
}
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 (
@@ -50,7 +50,10 @@ function Question({
) : (
<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>
)}
@@ -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",
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}`} />}
</div>
))}
@@ -71,7 +76,10 @@ function Question({
options.map((option) => (
<div
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>{option?.text}</span>
</div>
@@ -81,32 +89,30 @@ function Question({
);
}
export default function MultipleChoice({ id, type, prompt, questions, userSolutions, onNext, onBack }: MultipleChoiceExercise & CommonProps) {
const { questionIndex, setQuestionIndex, partIndex, exam } = useExamStore((state) => state);
export default function MultipleChoice({id, type, prompt, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
const stats = useExamStore((state) => state.userSolutions);
const calculateScore = () => {
const total = questions.length;
const questionShuffleMap = stats.find((x) => x.exercise == id)?.shuffleMaps;
const correct = userSolutions.filter(
(x) => {
const correct = userSolutions.filter((x) => {
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!;
return x.option == shuffleMap?.map[originalSol]
return x.option == shuffleMap?.map[originalSol];
} 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) => x.question.toString() === y.id.toString())).length;
return { total, correct, missing };
}).length;
const missing = total - userSolutions.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length;
return {total, correct, missing};
};
const next = () => {
if (questionIndex === questions.length - 1) {
onNext({ exercise: id, solutions: userSolutions, score: calculateScore(), type });
onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
} else {
setQuestionIndex(questionIndex + 1);
}
@@ -114,7 +120,7 @@ export default function MultipleChoice({ id, type, prompt, questions, userSoluti
const back = () => {
if (questionIndex === 0) {
onBack({ exercise: id, solutions: userSolutions, score: calculateScore(), type });
onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type});
} else {
setQuestionIndex(questionIndex - 1);
}
@@ -149,11 +155,19 @@ export default function MultipleChoice({ id, type, prompt, questions, userSoluti
</div>
<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={
exam && typeof partIndex !== "undefined" && exam.module === "level" &&
typeof exam.parts[0].intro === "string" && questionIndex === 0 && partIndex === 0}
>
exam &&
typeof partIndex !== "undefined" &&
exam.module === "level" &&
typeof exam.parts[0].intro === "string" &&
questionIndex === 0 &&
partIndex === 0
}>
Back
</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 [isLoading, setIsLoading] = useState(false);
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!`,
{toastId: "success"},
);
onFinish();
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<
{
email: string;
@@ -159,6 +159,7 @@ export default function BatchCreateUser({user}: {user: User}) {
try {
for (const newUser of newUsers) await axios.post("/api/make_user", {...newUser, type, expiryDate});
toast.success(`Successfully added ${newUsers.length} user(s)!`);
onFinish();
} catch {
toast.error("Something went wrong, please try again later!");
} 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 [expiryDate, setExpiryDate] = useState<Date | 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 [email, setEmail] = useState<string>();
const [phone, setPhone] = useState<string>();
@@ -118,6 +118,7 @@ export default function UserCreator({user}: {user: User}) {
.post("/api/make_user", body)
.then(() => {
toast.success("That user has been created!");
onFinish();
setName("");
setEmail("");

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ export const propagateStatusChange = (userId: string, status: UserStatus) =>
const user = docUser.data() as 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) => {
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 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 corporateUsers = participantUsers.filter((x) => x.type === "corporate") as CorporateUser[];
@@ -50,6 +50,6 @@ export async function getUserBalance(user: User) {
return (
corporateUsers.reduce((acc, curr) => acc + curr.corporateInformation?.companyInformation?.userAmount || 0, 0) +
corporateUsers.length +
codes.length
codes.filter((x) => !participants.includes(x.userId || "") && !corporateUsers.map((u) => u.id).includes(x.userId || "")).length
);
}