Merge branch 'develop' into feature-paymentAssetManagement

This commit is contained in:
Joao Ramos
2023-12-13 23:43:52 +00:00
8 changed files with 264 additions and 153 deletions

View File

@@ -1,5 +1,4 @@
import {infoButtonStyle} from "@/constants/buttonStyles"; import {infoButtonStyle} from "@/constants/buttonStyles";
import {BAND_SCORES} from "@/constants/ielts";
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";

View File

@@ -60,7 +60,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
const [paymentValue, setPaymentValue] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.value : undefined); const [paymentValue, setPaymentValue] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.value : undefined);
const [paymentCurrency, setPaymentCurrency] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.currency : "EUR"); const [paymentCurrency, setPaymentCurrency] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.currency : "EUR");
const [monthlyDuration, setMonthlyDuration] = useState(user.type === "corporate" ? user.corporateInformation?.monthlyDuration : undefined); const [monthlyDuration, setMonthlyDuration] = useState(user.type === "corporate" ? user.corporateInformation?.monthlyDuration : undefined);
const [commissionValue, setCommission] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.commission : undefined);
const {stats} = useStats(user.id); const {stats} = useStats(user.id);
const {users} = useUsers(); const {users} = useUsers();
@@ -106,6 +106,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
payment: { payment: {
value: paymentValue, value: paymentValue,
currency: paymentCurrency, currency: paymentCurrency,
...referralAgent === '' ? {} : { commission: commissionValue }
}, },
} }
: undefined, : undefined,
@@ -194,41 +195,6 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
placeholder="Enter monthly duration" placeholder="Enter monthly duration"
defaultValue={monthlyDuration} defaultValue={monthlyDuration}
/> />
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Country Manager</label>
{referralAgentLabel && (
<Select
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
options={[
{value: "", label: "No referral"},
...users.filter((u) => u.type === "agent").map((x) => ({value: x.id, label: `${x.name} - ${x.email}`})),
]}
defaultValue={{
value: referralAgent,
label: referralAgentLabel,
}}
onChange={(value) => setReferralAgent(value?.value)}
styles={{
control: (styles) => ({
...styles,
paddingLeft: "4px",
border: "none",
outline: "none",
":focus": {
outline: "none",
},
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
)}
</div>
<div className="flex flex-col gap-3 w-full lg:col-span-2"> <div className="flex flex-col gap-3 w-full lg:col-span-2">
<label className="font-normal text-base text-mti-gray-dim">Pricing</label> <label className="font-normal text-base text-mti-gray-dim">Pricing</label>
<div className="w-full grid grid-cols-5 gap-2"> <div className="w-full grid grid-cols-5 gap-2">
@@ -252,6 +218,55 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
</div> </div>
</div> </div>
</div> </div>
<div className="flex gap-3 w-full">
<div className="flex flex-col gap-3 w-8/12">
<label className="font-normal text-base text-mti-gray-dim">Country Manager</label>
{referralAgentLabel && (
<Select
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
options={[
{value: "", label: "No referral"},
...users.filter((u) => u.type === "agent").map((x) => ({value: x.id, label: `${x.name} - ${x.email}`})),
]}
defaultValue={{
value: referralAgent,
label: referralAgentLabel,
}}
onChange={(value) => setReferralAgent(value?.value)}
styles={{
control: (styles) => ({
...styles,
paddingLeft: "4px",
border: "none",
outline: "none",
":focus": {
outline: "none",
},
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
)}
</div>
<div className="flex flex-col gap-3 w-4/12">
{referralAgent !== '' ? (
<>
<label className="font-normal text-base text-mti-gray-dim">Commission</label>
<Input
name="commissionValue"
onChange={(e) => setCommission(e ? parseInt(e) : undefined)}
type="number"
defaultValue={commissionValue || 0}
className="col-span-3"
/>
</>
) : <div />}
</div>
</div>
<Divider className="w-full !m-0" /> <Divider className="w-full !m-0" />
</> </>
)} )}

View File

@@ -2,96 +2,119 @@ import {Module} from "@/interfaces";
export const MODULES: Module[] = ["reading", "listening", "writing", "speaking"]; export const MODULES: Module[] = ["reading", "listening", "writing", "speaking"];
export const BAND_SCORES: {[key in Module]: number[]} = { // BAND SCORES is not in use anymore and level scoring is made based on thresholds
reading: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9], // export const BAND_SCORES: {[key in Module]: number[]} = {
listening: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9], // reading: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
writing: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], // listening: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
speaking: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], // writing: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
level: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], // speaking: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
}; // level: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
// };
export const moduleResultText = (level: number) => { export type LevelScore = "Advanced" | "Upper-Intermediate" | "Intermediate" | "Pre-Intermediate" | "Elementary" | "Beginner";
if (level === 9) {
return (
<>
Congratulations on your exam performance! You achieved an impressive <span className="font-bold">level {level}</span>, demonstrating
excellent mastery of the assessed knowledge.
<br />
<br />
If you disagree with the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of
the results.
<br />
<br />
Please contact us for further information. Congratulations again on your outstanding achievement! We are here to support you on your
academic journey.
</>
);
}
if (level >= 6) {
return (
<>
Congratulations on your exam performance! You achieved a commendable <span className="font-bold">level {level}</span>, demonstrating a
good understanding of the assessed knowledge.
<br />
<br />
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
transparency of the results.
<br />
<br />
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
journey.
</>
);
}
if (level >= 3) { const generateHighestScoreText = () : React.ReactNode => (
return ( <>
<> <br />
Congratulations on your exam performance! You achieved a <span className="font-bold">level of {level}</span>, demonstrating a <br />
satisfactory understanding of the assessed knowledge. If you disagree with the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of
<br /> the results.
<br /> <br />
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and <br />
transparency of the results. Please contact us for further information. Congratulations again on your outstanding achievement! We are here to support you on your
<br /> academic journey.
<br /> </>
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic );
journey.
</>
);
}
return ( const generateAverageScoreText = () : React.ReactNode => (
<> <>
Thank you for taking the exam. You achieved a <span className="font-bold">level {level}</span>, but unfortunately, it did not meet the <br />
required standards. <br />
<br /> If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
<br /> transparency of the results.
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and <br />
transparency of the results. <br />
<br /> Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
<br /> journey.
Please contact us for further information. We encourage you to continue your studies and wish you the best of luck in your future </>
endeavors. );
</>
); const generateLowestScoreText = () : React.ReactNode => (
}; <>
<br />
<br />
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
transparency of the results.
<br />
<br />
Please contact us for further information. We encourage you to continue your studies and wish you the best of luck in your future
endeavors.
</>
)
export const levelResultText = (level: number) => { export const levelResultText = (level: number) => {
if(level === 9) {
return (
<>
{"Outstanding! Your command of English is excellent. Focus on fine-tuning subtle language nuances and exploring sophisticated vocabulary. Keep up the excellent work!"}
{generateHighestScoreText()}
</>
);
}
if(level >= 8) {
return (
<>
{"Impressive! You're approaching fluency. Continue refining nuances in grammar and expanding your vocabulary to express ideas more precisely."}
{generateAverageScoreText()}
</>
);
}
if(level >= 6) {
return (
<>
{"Great job! You're navigating the complexities of English. Keep honing your grammar skills and exploring more advanced vocabulary."}
{generateAverageScoreText()}
</>
);
}
if(level >= 4) {
return (
<>
{"Well done! You're moving beyond the basics. Work on expanding your vocabulary and refining your understanding of grammar structures."}
{generateAverageScoreText()}
</>
);
}
if(level >= 2) {
return (
<>
{"Good effort! You're making progress. Continue studying and pay attention to common vocabulary and fundamental grammar rules."}
{generateAverageScoreText()}
</>
);
}
if(level >= 0) {
return (
<>
{"Keep practicing! You're just starting, and improvement takes time. Focus on building your vocabulary and basic grammar skills."}
{generateLowestScoreText()}
</>
);
}
return null;
};
export const moduleResultText = (module: Module, level: number) => {
if(module === 'level') return levelResultText(level);
if (level === 9) { if (level === 9) {
return ( return (
<> <>
Congratulations on your exam performance! You achieved an impressive <span className="font-bold">level {level}</span>, demonstrating Congratulations on your exam performance! You achieved an impressive <span className="font-bold">level {level}</span>, demonstrating
excellent mastery of the assessed knowledge. excellent mastery of the assessed knowledge.
<br /> {generateHighestScoreText()}
<br />
If you disagree with the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of
the results.
<br />
<br />
Please contact us for further information. Congratulations again on your outstanding achievement! We are here to support you on your
academic journey.
</> </>
); );
} }
@@ -101,14 +124,7 @@ export const levelResultText = (level: number) => {
<> <>
Congratulations on your exam performance! You achieved a commendable <span className="font-bold">level {level}</span>, demonstrating a Congratulations on your exam performance! You achieved a commendable <span className="font-bold">level {level}</span>, demonstrating a
good understanding of the assessed knowledge. good understanding of the assessed knowledge.
<br /> {generateAverageScoreText()}
<br />
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
transparency of the results.
<br />
<br />
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
journey.
</> </>
); );
} }
@@ -118,14 +134,7 @@ export const levelResultText = (level: number) => {
<> <>
Congratulations on your exam performance! You achieved a <span className="font-bold">level of {level}</span>, demonstrating a Congratulations on your exam performance! You achieved a <span className="font-bold">level of {level}</span>, demonstrating a
satisfactory understanding of the assessed knowledge. satisfactory understanding of the assessed knowledge.
<br /> {generateAverageScoreText()}
<br />
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
transparency of the results.
<br />
<br />
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
journey.
</> </>
); );
} }
@@ -134,14 +143,7 @@ export const levelResultText = (level: number) => {
<> <>
Thank you for taking the exam. You achieved a <span className="font-bold">level {level}</span>, but unfortunately, it did not meet the Thank you for taking the exam. You achieved a <span className="font-bold">level {level}</span>, but unfortunately, it did not meet the
required standards. required standards.
<br /> {generateLowestScoreText()}
<br />
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
transparency of the results.
<br />
<br />
Please contact us for further information. We encourage you to continue your studies and wish you the best of luck in your future
endeavors.
</> </>
); );
}; };

View File

@@ -10,6 +10,8 @@ import Link from "next/link";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {Fragment, useEffect, useState} from "react"; import {Fragment, useEffect, useState} from "react";
import {BsArrowCounterclockwise, BsBook, BsClipboard, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs"; import {BsArrowCounterclockwise, BsBook, BsClipboard, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs";
import { LevelScore } from "@/constants/ielts";
import { getLevelScore } from "@/utils/score";
interface Score { interface Score {
module: Module; module: Module;
@@ -66,6 +68,24 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
return exam.exercises.length; return exam.exercises.length;
}; };
const bandScore: number = calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus);
const showLevel = (level: number) => {
if(selectedModule === "level") {
const [levelStr, grade] = getLevelScore(level);
return (
<div className="flex flex-col items-center justify-center gap-1">
<span className="text-xl font-bold">{levelStr}</span>
<span className="text-xl">{grade}</span>
</div>
)
}
return <span className="text-3xl font-bold">{level}</span>;
}
return ( return (
<> <>
<div className="w-full min-h-full h-fit flex flex-col items-center justify-between gap-8"> <div className="w-full min-h-full h-fit flex flex-col items-center justify-between gap-8">
@@ -142,7 +162,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
{!isLoading && ( {!isLoading && (
<div className="w-full flex gap-9 mt-32 items-center justify-between mb-20"> <div className="w-full flex gap-9 mt-32 items-center justify-between mb-20">
<span className="max-w-3xl"> <span className="max-w-3xl">
{moduleResultText(calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus))} {moduleResultText(selectedModule, bandScore)}
</span> </span>
<div className="flex gap-9 px-16"> <div className="flex gap-9 px-16">
<div <div
@@ -156,9 +176,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
moduleColors[selectedModule].inner, moduleColors[selectedModule].inner,
)}> )}>
<span className="text-xl">Level</span> <span className="text-xl">Level</span>
<span className="text-3xl font-bold"> {showLevel(bandScore)}
{calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus)}
</span>
</div> </div>
</div> </div>
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">

View File

@@ -31,7 +31,7 @@ export interface Payment {
currency: string; currency: string;
value: number; value: number;
isPaid: boolean; isPaid: boolean;
date: Date; date: Date | string;
corporateTransfer?: string; corporateTransfer?: string;
commissionTransfer?: string; commissionTransfer?: string;
} }

View File

@@ -57,6 +57,7 @@ export interface CorporateInformation {
payment?: { payment?: {
value: number; value: number;
currency: string; currency: string;
commission: number;
}; };
referralAgent?: string; referralAgent?: string;
} }

View File

@@ -1,19 +1,72 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app, storage} from "@/firebase"; import {app, storage} from "@/firebase";
import {getFirestore, collection, getDocs, getDoc, doc, setDoc} from "firebase/firestore"; import {getFirestore, collection, getDocs, getDoc, doc, setDoc, query, where} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import {getDownloadURL, getStorage, ref, uploadBytes} from "firebase/storage"; import {getDownloadURL, getStorage, ref, uploadBytes} from "firebase/storage";
import {getAuth, signInWithEmailAndPassword, updateEmail, updatePassword} from "firebase/auth"; import {getAuth, signInWithEmailAndPassword, updateEmail, updatePassword} from "firebase/auth";
import {errorMessages} from "@/constants/errors"; import {errorMessages} from "@/constants/errors";
import moment from "moment";
import ShortUniqueId from "short-unique-id";
import {Payment} from "@/interfaces/paypal";
const db = getFirestore(app); const db = getFirestore(app);
const auth = getAuth(app); const auth = getAuth(app);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
// TODO: Data is set as any as data cannot be parsed to Payment
// because the id is not a par of the hash and payment expects date to be of type Date
// but if it is not inserted as a string, some UI components will not work (Invalid Date)
const addPaymentRecord = async (data: any) => {
await setDoc(doc(db, "payments", data.id), data);
}
const managePaymentRecords = async (user: User, userId: string | undefined): Promise<boolean> => {
try {
if(user.type === 'corporate' && userId) {
const shortUID = new ShortUniqueId();
const data: Payment = {
id: shortUID.randomUUID(8),
corporate: userId,
agent: user.corporateInformation.referralAgent,
agentCommission: user.corporateInformation.payment!.commission,
agentValue: (user.corporateInformation.payment!.commission / 100) * user.corporateInformation.payment!.value,
currency: user.corporateInformation.payment!.currency,
value: user.corporateInformation.payment!.value,
isPaid: false,
date: new Date().toISOString(),
};
const corporatePayments = await getDocs(query(collection(db, "payments"), where("corporate", "==", userId)));
if(corporatePayments.docs.length === 0) {
await addPaymentRecord(data);
return true;
}
const hasPaymentPaidAndExpiring = corporatePayments.docs.filter((doc) => {
const data = doc.data();
return data.isPaid
&& moment().isAfter(moment(user.subscriptionExpirationDate).subtract(30, "days"))
&& moment().isBefore(moment(user.subscriptionExpirationDate));
});
if(hasPaymentPaidAndExpiring.length > 0) {
await addPaymentRecord(data);
return true;
}
}
return false;
} catch(e) {
// if this process fails it should not stop the rest of the process
console.log(e);
return false;
}
}
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ok: false}); res.status(401).json({ok: false});
@@ -24,7 +77,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const updatedUser = req.body as User & {password?: string; newPassword?: string}; const updatedUser = req.body as User & {password?: string; newPassword?: string};
if (!!req.query.id) { if (!!req.query.id) {
await setDoc(userRef, updatedUser, {merge: true}); const user = await setDoc(userRef, updatedUser, {merge: true});
await managePaymentRecords(updatedUser, updatedUser.id);
res.status(200).json({ok: true}); res.status(200).json({ok: true});
return; return;
} }
@@ -73,6 +127,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
await req.session.save(); await req.session.save();
} }
await managePaymentRecords(user, req.query.id);
res.status(200).json({user}); res.status(200).json({user});
} }

View File

@@ -1,4 +1,5 @@
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import { LevelScore } from "@/constants/ielts";
type Type = "academic" | "general"; type Type = "academic" | "general";
@@ -94,11 +95,12 @@ const academicMarking: {[key: number]: number} = {
}; };
const levelMarking: {[key: number]: number} = { const levelMarking: {[key: number]: number} = {
88: 9, 88: 9, // Advanced
64: 8, 64: 8 , // Upper-Intermediate
52: 6, 52: 6, // Intermediate
32: 4, 32: 4, // Pre-Intermediate
16: 2, 16: 2, // Elementary
0: 0, // Beginner
}; };
const moduleMarkings: {[key in Module]: {[key in Type]: {[key: number]: number}}} = { const moduleMarkings: {[key in Module]: {[key in Type]: {[key: number]: number}}} = {
@@ -142,3 +144,21 @@ export const calculateBandScore = (correct: number, total: number, module: Modul
export const calculateAverageLevel = (levels: {[key in Module]: number}) => { 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) / 4;
}; };
export const getLevelScore = (level: number) => {
switch(level) {
case 0:
return ['Beginner', 'Low A1'];
case 2:
return ['Elementary', 'High A1/Low A2'];
case 4:
return ['Pre-Intermediate', 'High A2/Low B1'];
case 6:
return ['Intermediate', 'High B1/Low B2'];
case 8:
return ['Upper-Intermediate', 'High B2/Low C1'];
case 9:
return ['Advanced', 'C1'];
default: return [];
}
}