Merge branch 'develop' into feature-report-export

This commit is contained in:
Joao Ramos
2024-01-09 23:33:57 +00:00
41 changed files with 1490 additions and 899 deletions

View File

@@ -2,7 +2,7 @@ import Button from "@/components/Low/Button";
import {PERMISSIONS} from "@/constants/userPermissions";
import useGroups from "@/hooks/useGroups";
import useUsers from "@/hooks/useUsers";
import {Type, User, userTypes} from "@/interfaces/user";
import {Type, User, userTypes, CorporateUser} from "@/interfaces/user";
import {Popover, Transition} from "@headlessui/react";
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
import axios from "axios";
@@ -19,9 +19,15 @@ import UserCard from "@/components/UserCard";
import {USER_TYPE_LABELS} from "@/resources/user";
import useFilterStore from "@/stores/listFilterStore";
import {useRouter} from "next/router";
import {isCorporateUser} from '@/resources/user';
import { useListSearch } from "@/hooks/useListSearch";
const columnHelper = createColumnHelper<User>();
const searchFields = [
['name'],
['email'],
['corporateInformation', 'companyInformation', 'name'],
];
export default function UserList({user, filters = []}: {user: User; filters?: ((user: User) => boolean)[]}) {
const [showDemographicInformation, setShowDemographicInformation] = useState(false);
const [sorter, setSorter] = useState<string>();
@@ -325,6 +331,15 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
) as any,
cell: (info) => USER_TYPE_LABELS[info.getValue()],
}),
columnHelper.accessor('corporateInformation.companyInformation.name', {
header: (
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "companyName"))}>
<span>Company Name</span>
<SorterArrow name="companyName" />
</button>
) as any,
cell: (info) => getCorporateName(info.row.original),
}),
columnHelper.accessor("subscriptionExpirationDate", {
header: (
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "expiryDate"))}>
@@ -378,6 +393,14 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
return undefined;
};
const getCorporateName = (user: User) => {
if(isCorporateUser(user)) {
return user.corporateInformation?.companyInformation?.name
}
return '';
}
const sortFunction = (a: User, b: User) => {
if (sorter === "name" || sorter === reverseString("name"))
return sorter === "name" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name);
@@ -445,11 +468,28 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
: b.demographicInformation!.gender.localeCompare(a.demographicInformation!.gender);
}
if(sorter === 'companyName' || sorter === reverseString('companyName')) {
const aCorporateName = getCorporateName(a);
const bCorporateName = getCorporateName(b);
if (!aCorporateName && bCorporateName) return sorter === "companyName" ? -1 : 1;
if (aCorporateName && !bCorporateName) return sorter === "companyName" ? 1 : -1;
if (!aCorporateName && !bCorporateName) return 0;
return sorter === "companyName"
? aCorporateName.localeCompare(bCorporateName)
: bCorporateName.localeCompare(aCorporateName);
}
return a.id.localeCompare(b.id);
};
const { rows: filteredRows, renderSearch } = useListSearch(
searchFields,
displayUsers,
)
const table = useReactTable({
data: displayUsers,
data: filteredRows,
columns: (!showDemographicInformation ? defaultColumns : demographicColumns) as any,
getCoreRowModel: getCoreRowModel(),
});
@@ -532,30 +572,33 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
)}
</>
</Modal>
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="py-4 px-4 text-left" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2 items-center w-fit" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
<div className="w-full flex flex-col gap-2">
{renderSearch()}
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="py-4 px-4 text-left" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2 items-center w-fit" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -37,6 +37,7 @@ export default function ExamPage({page}: Props) {
const [showAbandonPopup, setShowAbandonPopup] = useState(false);
const [avoidRepeated, setAvoidRepeated] = useState(false);
const [timeSpent, setTimeSpent] = useState(0);
const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<string[]>([]);
const [exams, setExams] = useExamStore((state) => [state.exams, state.setExams]);
const [userSolutions, setUserSolutions] = useExamStore((state) => [state.userSolutions, state.setUserSolutions]);
@@ -94,6 +95,7 @@ export default function ExamPage({page}: Props) {
if (selectedModules.length > 0 && exams.length !== 0 && moduleIndex >= selectedModules.length && !hasBeenUploaded && !showSolutions) {
const newStats: Stat[] = userSolutions.map((solution) => ({
...solution,
id: solution.id || uuidv4(),
timeSpent,
session: sessionId,
exam: solution.exam!,
@@ -111,6 +113,41 @@ export default function ExamPage({page}: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules, moduleIndex, hasBeenUploaded]);
useEffect(() => {
if (statsAwaitingEvaluation.length === 0) return setIsEvaluationLoading(false);
return setIsEvaluationLoading(true);
}, [statsAwaitingEvaluation]);
useEffect(() => {
if (statsAwaitingEvaluation.length > 0) {
statsAwaitingEvaluation.forEach(checkIfStatHasBeenEvaluated);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [statsAwaitingEvaluation]);
const checkIfStatHasBeenEvaluated = (id: string) => {
setTimeout(async () => {
const statRequest = await axios.get<Stat>(`/api/stats/${id}`);
const stat = statRequest.data;
if (stat.solutions.every((x) => x.evaluation !== null)) {
const userSolution: UserSolution = {
id,
exercise: stat.exercise,
score: stat.score,
solutions: stat.solutions,
type: stat.type,
exam: stat.exam,
module: stat.module,
};
setUserSolutions(userSolutions.map((x) => (x.exercise === userSolution.exercise ? userSolution : x)));
return setStatsAwaitingEvaluation((prev) => prev.filter((x) => x !== id));
}
return checkIfStatHasBeenEvaluated(id);
}, 5 * 1000);
};
const updateExamWithUserSolutions = (exam: Exam): Exam => {
if (exam.module === "reading" || exam.module === "listening") {
const parts = exam.parts.map((p) =>
@@ -137,20 +174,19 @@ export default function ExamPage({page}: Props) {
Promise.all(
exam.exercises.map(async (exercise) => {
if (exercise.type === "writing") {
return await evaluateWritingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!);
}
const evaluationID = uuidv4();
if (exercise.type === "writing")
return await evaluateWritingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!, evaluationID);
if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking") {
return await evaluateSpeakingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!);
}
if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking")
return await evaluateSpeakingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!, evaluationID);
}),
)
.then((responses) => {
setStatsAwaitingEvaluation((prev) => [...prev, ...responses.filter((x) => !!x).map((r) => (r as any).id)]);
setUserSolutions([...userSolutions, ...responses.filter((x) => !!x)] as any);
})
.finally(() => {
setIsEvaluationLoading(false);
setHasBeenUploaded(false);
});
}

View File

@@ -4,21 +4,22 @@ import Input from "@/components/Low/Input";
import {User} from "@/interfaces/user";
import {sendEmailVerification} from "@/utils/email";
import axios from "axios";
import {useState} from "react";
import {useEffect, useState} from "react";
import {toast} from "react-toastify";
import {KeyedMutator} from "swr";
interface Props {
queryCode?: string;
defaultEmail?: string;
isLoading: boolean;
setIsLoading: (isLoading: boolean) => void;
mutateUser: KeyedMutator<User>;
sendEmailVerification: typeof sendEmailVerification;
}
export default function RegisterIndividual({queryCode, isLoading, setIsLoading, mutateUser, sendEmailVerification}: Props) {
export default function RegisterIndividual({queryCode, defaultEmail, isLoading, setIsLoading, mutateUser, sendEmailVerification}: Props) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [email, setEmail] = useState(defaultEmail || "");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [code, setCode] = useState(queryCode || "");
@@ -73,7 +74,15 @@ export default function RegisterIndividual({queryCode, isLoading, setIsLoading,
return (
<form className="flex flex-col items-center gap-6 w-full" onSubmit={register}>
<Input type="text" name="name" onChange={(e) => setName(e)} placeholder="Enter your name" defaultValue={name} required />
<Input type="email" name="email" onChange={(e) => setEmail(e)} placeholder="Enter email address" defaultValue={email} required />
<Input
type="email"
name="email"
onChange={(e) => setEmail(e)}
placeholder="Enter email address"
value={email}
disabled={!!defaultEmail}
required
/>
<Input
type="password"
name="password"

View File

@@ -0,0 +1,23 @@
// 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, getDoc, deleteDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {uuidv4} from "@firebase/util";
const db = getFirestore(app);
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return GET(req, res);
res.status(404).json({ok: false});
}
async function GET(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query;
const snapshot = await getDoc(doc(db, "codes", id as string));
res.status(200).json({...snapshot.data(), id: snapshot.id});
}

View File

@@ -48,6 +48,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
await setDoc(codeRef, {type, code, creator: req.session.user!.id, expiryDate});
if (emails && emails.length > index) {
await setDoc(codeRef, {email: emails[index]}, {merge: true});
const transport = prepareMailer();
const mailOptions = prepareMailOptions(
{

View File

@@ -2,12 +2,16 @@
import type {NextApiRequest, NextApiResponse} from "next";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import axios from "axios";
import axios, {AxiosResponse} from "axios";
import formidable from "formidable-serverless";
import {ref, uploadBytes} from "firebase/storage";
import fs from "fs";
import {storage} from "@/firebase";
import {app, storage} from "@/firebase";
import {doc, getDoc, getFirestore, setDoc} from "firebase/firestore";
import {Stat} from "@/interfaces/user";
import {speakingReverseMarking} from "@/utils/score";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
@@ -36,20 +40,41 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
}),
);
const backendRequest = await axios.post(
`${process.env.BACKEND_URL}/speaking_task_3`,
{answers: uploadingAudios},
res.status(200).json(null);
console.log("🌱 - Still processing");
const backendRequest = await evaluate({answers: uploadingAudios});
console.log("🌱 - Process complete");
const correspondingStat = (await getDoc(doc(db, "stats", fields.id))).data() as Stat;
const solutions = correspondingStat.solutions.map((x) => ({...x, evaluation: backendRequest.data, solution: uploadingAudios}));
await setDoc(
doc(db, "stats", fields.id),
{
headers: {
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
solutions,
score: {
correct: speakingReverseMarking[backendRequest.data.overall],
missing: 0,
total: 100,
},
},
{merge: true},
);
res.status(200).json({...backendRequest.data, answer: uploadingAudios});
console.log("🌱 - Updated the DB");
});
}
async function evaluate(body: {answers: object[]}): Promise<AxiosResponse> {
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/speaking_task_3`, body, {
headers: {
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
},
});
if (typeof backendRequest.data === "string") return evaluate(body);
return backendRequest;
}
export const config = {
api: {
bodyParser: false,

View File

@@ -2,12 +2,16 @@
import type {NextApiRequest, NextApiResponse} from "next";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import axios from "axios";
import axios, {AxiosResponse} from "axios";
import formidable from "formidable-serverless";
import {ref, uploadBytes} from "firebase/storage";
import fs from "fs";
import {storage} from "@/firebase";
import {app, storage} from "@/firebase";
import {doc, getDoc, getFirestore, setDoc} from "firebase/firestore";
import {Stat} from "@/interfaces/user";
import {speakingReverseMarking} from "@/utils/score";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
@@ -26,21 +30,46 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const binary = fs.readFileSync((audioFile as any).path).buffer;
const snapshot = await uploadBytes(audioFileRef, binary);
const backendRequest = await axios.post(
`${process.env.BACKEND_URL}/speaking_task_3`,
{answers: [{question: fields.question, answer: snapshot.metadata.fullPath}]},
res.status(200).json(null);
console.log("🌱 - Still processing");
const backendRequest = await evaluate({answers: [{question: fields.question, answer: snapshot.metadata.fullPath}]});
fs.rmSync((audioFile as any).path);
console.log("🌱 - Process complete");
const correspondingStat = (await getDoc(doc(db, "stats", fields.id))).data() as Stat;
const solutions = correspondingStat.solutions.map((x) => ({
...x,
evaluation: backendRequest.data,
solution: snapshot.metadata.fullPath,
}));
await setDoc(
doc(db, "stats", fields.id),
{
headers: {
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
solutions,
score: {
correct: speakingReverseMarking[backendRequest.data.overall],
total: 100,
missing: 0,
},
},
{merge: true},
);
fs.rmSync((audioFile as any).path);
res.status(200).json({...backendRequest.data, fullPath: snapshot.metadata.fullPath});
console.log("🌱 - Updated the DB");
});
}
async function evaluate(body: {answers: object[]}): Promise<AxiosResponse> {
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/speaking_task_3`, body, {
headers: {
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
},
});
if (typeof backendRequest.data === "string") return evaluate(body);
return backendRequest;
}
export const config = {
api: {
bodyParser: false,

View File

@@ -1,15 +1,20 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {getFirestore, doc, getDoc} from "firebase/firestore";
import {getFirestore, doc, getDoc, setDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import axios from "axios";
import axios, {AxiosResponse} from "axios";
import {app} from "@/firebase";
import {Stat} from "@/interfaces/user";
import {writingReverseMarking} from "@/utils/score";
interface Body {
question: string;
answer: string;
id: string;
}
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
@@ -18,11 +23,36 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
return;
}
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/writing_task2`, req.body as Body, {
res.status(200).json(null);
console.log("🌱 - Still processing");
const backendRequest = await evaluate(req.body as Body);
console.log("🌱 - Process complete");
const correspondingStat = (await getDoc(doc(db, "stats", req.body.id))).data() as Stat;
const solutions = correspondingStat.solutions.map((x) => ({...x, evaluation: backendRequest.data}));
await setDoc(
doc(db, "stats", (req.body as Body).id),
{
solutions,
score: {
correct: writingReverseMarking[backendRequest.data.overall],
total: 100,
missing: 0,
},
},
{merge: true},
);
console.log("🌱 - Updated the DB");
}
async function evaluate(body: Body): Promise<AxiosResponse> {
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/writing_task2`, body as Body, {
headers: {
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
},
});
res.status(backendRequest.status).json(backendRequest.data);
if (typeof backendRequest.data === "string") return evaluate(body);
return backendRequest;
}

View File

@@ -1,10 +1,12 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {app, storage} from "@/firebase";
import {getFirestore, collection, getDocs, getDoc, doc, deleteDoc, setDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {Group} from "@/interfaces/user";
import {Payment} from "@/interfaces/paypal";
import {deleteObject, ref} from "firebase/storage";
const db = getFirestore(app);
@@ -44,11 +46,14 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query as {id: string};
const snapshot = await getDoc(doc(db, "payments", id));
const data = snapshot.data() as Payment;
const user = req.session.user;
if (user.type === "admin" || user.type === "developer") {
await deleteDoc(snapshot.ref);
if (data.commissionTransfer) await deleteObject(ref(storage, data.commissionTransfer));
if (data.corporateTransfer) await deleteObject(ref(storage, data.corporateTransfer));
await deleteDoc(snapshot.ref);
res.status(200).json({ok: true});
return;
}

View File

@@ -1,194 +1,180 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { app, storage } from "@/firebase";
import {
getFirestore,
getDoc,
doc,
updateDoc,
deleteField,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { FilesStorage } from "@/interfaces/storage.files";
import type {NextApiRequest, NextApiResponse} from "next";
import {app, storage} from "@/firebase";
import {getFirestore, getDoc, doc, updateDoc, deleteField, setDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {FilesStorage} from "@/interfaces/storage.files";
import { Payment } from "@/interfaces/paypal";
import {Payment} from "@/interfaces/paypal";
import fs from "fs";
import {
ref,
uploadBytes,
deleteObject,
getDownloadURL,
} from "firebase/storage";
import {ref, uploadBytes, deleteObject, getDownloadURL} from "firebase/storage";
import formidable from "formidable-serverless";
const db = getFirestore(app);
const getPaymentField = (type: FilesStorage) => {
switch (type) {
case "commission":
return "commissionTransfer";
case "corporate":
return "corporateTransfer";
default:
return null;
}
switch (type) {
case "commission":
return "commissionTransfer";
case "corporate":
return "corporateTransfer";
default:
return null;
}
};
const handleDelete = async (
paymentId: string,
paymentField: "commissionTransfer" | "corporateTransfer"
) => {
const paymentRef = doc(db, "payments", paymentId);
const paymentDoc = await getDoc(paymentRef);
const { [paymentField]: paymentFieldPath } = paymentDoc.data() as Payment;
// Create a reference to the file to delete
const documentRef = ref(storage, paymentFieldPath);
await deleteObject(documentRef);
await updateDoc(paymentRef, {
[paymentField]: deleteField(),
});
const handleDelete = async (paymentId: string, paymentField: "commissionTransfer" | "corporateTransfer") => {
const paymentRef = doc(db, "payments", paymentId);
const paymentDoc = await getDoc(paymentRef);
const {[paymentField]: paymentFieldPath} = paymentDoc.data() as Payment;
// Create a reference to the file to delete
const documentRef = ref(storage, paymentFieldPath);
await deleteObject(documentRef);
await updateDoc(paymentRef, {
[paymentField]: deleteField(),
isPaid: false,
});
};
const handleUpload = async (
req: NextApiRequest,
paymentId: string,
paymentField: "commissionTransfer" | "corporateTransfer"
) =>
new Promise((resolve, reject) => {
const form = formidable({ keepExtensions: true });
form.parse(req, async (err: any, fields: any, files: any) => {
if (err) {
reject(err);
return;
}
try {
const { file } = files;
const fileName = Date.now() + "-" + file.name;
const fileRef = ref(storage, fileName);
const handleUpload = async (req: NextApiRequest, paymentId: string, paymentField: "commissionTransfer" | "corporateTransfer") =>
new Promise((resolve, reject) => {
const form = formidable({keepExtensions: true});
form.parse(req, async (err: any, fields: any, files: any) => {
if (err) {
reject(err);
return;
}
try {
const {file} = files;
const fileName = Date.now() + "-" + file.name;
const fileRef = ref(storage, fileName);
const binary = fs.readFileSync(file.path).buffer;
const snapshot = await uploadBytes(fileRef, binary);
fs.rmSync(file.path);
const binary = fs.readFileSync(file.path).buffer;
const snapshot = await uploadBytes(fileRef, binary);
fs.rmSync(file.path);
const paymentRef = doc(db, "payments", paymentId);
const paymentRef = doc(db, "payments", paymentId);
await updateDoc(paymentRef, {
[paymentField]: snapshot.ref.fullPath,
});
resolve(snapshot.ref.fullPath);
} catch (err) {
reject(err);
}
});
});
await updateDoc(paymentRef, {
[paymentField]: snapshot.ref.fullPath,
});
resolve(snapshot.ref.fullPath);
} catch (err) {
reject(err);
}
});
});
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
if (req.method === "GET") return await get(req, res);
if (req.method === "POST") return await post(req, res);
if (req.method === "DELETE") return await del(req, res);
if (req.method === "PATCH") return await patch(req, res);
if (req.method === "GET") return await get(req, res);
if (req.method === "POST") return await post(req, res);
if (req.method === "DELETE") return await del(req, res);
if (req.method === "PATCH") return await patch(req, res);
res.status(404).json(undefined);
res.status(404).json(undefined);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
const { type, paymentId } = req.query as {
type: FilesStorage;
paymentId: string;
};
const paymentField = getPaymentField(type);
const {type, paymentId} = req.query as {
type: FilesStorage;
paymentId: string;
};
const paymentField = getPaymentField(type);
if (paymentField === null) {
res.status(500).json({ error: "Failed to identify payment field" });
return;
}
const paymentRef = doc(db, "payments", paymentId);
const { [paymentField]: paymentFieldPath } = (
await getDoc(paymentRef)
).data() as Payment;
if (paymentField === null) {
res.status(500).json({error: "Failed to identify payment field"});
return;
}
const paymentRef = doc(db, "payments", paymentId);
const {[paymentField]: paymentFieldPath} = (await getDoc(paymentRef)).data() as Payment;
// Create a reference to the file to delete
const documentRef = ref(storage, paymentFieldPath);
const url = await getDownloadURL(documentRef);
res.status(200).json({ url, name: documentRef.name });
// Create a reference to the file to delete
const documentRef = ref(storage, paymentFieldPath);
const url = await getDownloadURL(documentRef);
res.status(200).json({url, name: documentRef.name});
}
async function post(req: NextApiRequest, res: NextApiResponse) {
const { type, paymentId } = req.query as {
type: FilesStorage;
paymentId: string;
};
const paymentField = getPaymentField(type);
const {type, paymentId} = req.query as {
type: FilesStorage;
paymentId: string;
};
const paymentField = getPaymentField(type);
if (paymentField === null) {
res.status(500).json({ error: "Failed to identify payment field" });
return;
}
if (paymentField === null) {
res.status(500).json({error: "Failed to identify payment field"});
return;
}
try {
const ref = await handleUpload(req, paymentId, paymentField);
res.status(200).json({ ref });
} catch (error) {
res.status(500).json({ error });
}
try {
const ref = await handleUpload(req, paymentId, paymentField);
const updatedDoc = (await getDoc(doc(db, "payments", paymentId))).data() as Payment;
if (updatedDoc.commissionTransfer && updatedDoc.corporateTransfer) {
await setDoc(doc(db, "payments", paymentId), {isPaid: true}, {merge: true});
}
res.status(200).json({ref});
} catch (error) {
res.status(500).json({error});
}
}
async function del(req: NextApiRequest, res: NextApiResponse) {
const { type, paymentId } = req.query as {
type: FilesStorage;
paymentId: string;
};
const paymentField = getPaymentField(type);
if (paymentField === null) {
res.status(500).json({ error: "Failed to identify payment field" });
return;
}
const {type, paymentId} = req.query as {
type: FilesStorage;
paymentId: string;
};
const paymentField = getPaymentField(type);
if (paymentField === null) {
res.status(500).json({error: "Failed to identify payment field"});
return;
}
try {
await handleDelete(paymentId, paymentField);
res.status(200).json({ ok: true });
} catch (err) {
console.error(err);
res.status(500).json({ error: "Failed to delete file" });
}
try {
await handleDelete(paymentId, paymentField);
res.status(200).json({ok: true});
} catch (err) {
console.error(err);
res.status(500).json({error: "Failed to delete file"});
}
}
async function patch(req: NextApiRequest, res: NextApiResponse) {
const { type, paymentId } = req.query as {
type: FilesStorage;
paymentId: string;
};
const paymentField = getPaymentField(type);
if (paymentField === null) {
res.status(500).json({ error: "Failed to identify payment field" });
return;
}
const {type, paymentId} = req.query as {
type: FilesStorage;
paymentId: string;
};
const paymentField = getPaymentField(type);
if (paymentField === null) {
res.status(500).json({error: "Failed to identify payment field"});
return;
}
try {
await handleDelete(paymentId, paymentField);
} catch (err) {
console.error(err);
res.status(500).json({ error: "Failed to delete file" });
return;
}
try {
await handleDelete(paymentId, paymentField);
} catch (err) {
console.error(err);
res.status(500).json({error: "Failed to delete file"});
return;
}
try {
const ref = await handleUpload(req, paymentId, paymentField);
res.status(200).json({ ref });
} catch (err) {
res.status(500).json({ error: "Failed to upload file" });
}
try {
const ref = await handleUpload(req, paymentId, paymentField);
res.status(200).json({ref});
} catch (err) {
res.status(500).json({error: "Failed to upload file"});
}
}
export const config = {
api: {
bodyParser: false,
},
api: {
bodyParser: false,
},
};

View File

@@ -0,0 +1,23 @@
// 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, getDoc, deleteDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {uuidv4} from "@firebase/util";
const db = getFirestore(app);
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return GET(req, res);
res.status(404).json({ok: false});
}
async function GET(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query;
const snapshot = await getDoc(doc(db, "stats", id as string));
res.status(200).json({...snapshot.data(), id: snapshot.id});
}

View File

@@ -1,29 +1,23 @@
// 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, doc, setDoc, addDoc} from "firebase/firestore";
import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc, deleteDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {uuidv4} from "@firebase/util";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return GET(req, res);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const {id: user} = req.query;
const q = query(collection(db, "stats"), where("user", "==", user));
const snapshot = await getDocs(q);
res.status(200).json(
snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})),
);
res.status(404).json({ok: false});
}
async function GET(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query;
const snapshot = await getDoc(doc(db, "stats", id as string));
res.status(200).json({...snapshot.data(), id: snapshot.id});
}

View File

@@ -42,7 +42,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
}
const stats = req.body as Stat[];
await stats.forEach(async (stat) => await addDoc(collection(db, "stats"), stat));
await stats.forEach(async (stat) => await setDoc(doc(db, "stats", stat.id), stat));
const groupedStatsByAssignment = groupBy(
stats.filter((x) => !!x.assignment),

View File

@@ -25,8 +25,8 @@ async function update(req: NextApiRequest, res: NextApiResponse) {
const q = query(collection(db, "stats"), where("user", "==", req.session.user.id));
const stats = (await getDocs(q)).docs.map((doc) => ({
id: doc.id,
...(doc.data() as Stat),
id: doc.id,
})) as Stat[];
const groupedStats = groupBySession(stats);

View File

@@ -0,0 +1,29 @@
// 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, doc, setDoc, addDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const {user} = req.query;
const q = query(collection(db, "stats"), where("user", "==", user));
const snapshot = await getDocs(q);
res.status(200).json(
snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})),
);
}

View File

@@ -4,14 +4,14 @@ import {app, storage} from "@/firebase";
import {getFirestore, collection, getDocs, getDoc, doc, setDoc, query, where} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {User} from "@/interfaces/user";
import {Group, User} from "@/interfaces/user";
import {getDownloadURL, getStorage, ref, uploadBytes} from "firebase/storage";
import {getAuth, signInWithEmailAndPassword, updateEmail, updatePassword} from "firebase/auth";
import {errorMessages} from "@/constants/errors";
import moment from "moment";
import ShortUniqueId from "short-unique-id";
import {Payment} from "@/interfaces/paypal";
import { toFixedNumber } from "@/utils/number";
import {toFixedNumber} from "@/utils/number";
const db = getFirestore(app);
const auth = getAuth(app);
@@ -22,10 +22,10 @@ export default withIronSessionApiRoute(handler, sessionOptions);
// 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) {
if (user.type === "corporate" && userId) {
const shortUID = new ShortUniqueId();
const data: Payment = {
id: shortUID.randomUUID(8),
@@ -38,34 +38,35 @@ const managePaymentRecords = async (user: User, userId: string | undefined): Pro
isPaid: false,
date: new Date().toISOString(),
};
const corporatePayments = await getDocs(query(collection(db, "payments"), where("corporate", "==", userId)));
if(corporatePayments.docs.length === 0) {
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));
return (
data.isPaid &&
moment().isAfter(moment(user.subscriptionExpirationDate).subtract(30, "days")) &&
moment().isBefore(moment(user.subscriptionExpirationDate))
);
});
if(hasPaymentPaidAndExpiring.length > 0) {
if (hasPaymentPaidAndExpiring.length > 0) {
await addPaymentRecord(data);
return true;
}
}
return false;
} catch(e) {
} 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) {
if (!req.session.user) {
@@ -108,6 +109,23 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const credential = await signInWithEmailAndPassword(auth, req.session.user.email, updatedUser.password);
await updateEmail(credential.user, updatedUser.email);
if (req.session.user.type === "student") {
const corporateAdmins = ((await getDocs(collection(db, "users"))).docs.map((x) => ({...x.data(), id: x.id})) as User[])
.filter((x) => x.type === "corporate")
.map((x) => x.id);
const groups = ((await getDocs(collection(db, "groups"))).docs.map((x) => ({...x.data(), id: x.id})) as Group[]).filter(
(x) => x.participants.includes(req.session.user!.id) && corporateAdmins.includes(x.admin),
);
groups.forEach(async (group) => {
await setDoc(
doc(db, "groups", group.id),
{participants: group.participants.filter((x) => x !== req.session.user!.id)},
{merge: true},
);
});
}
} catch {
res.status(400).json({error: "E002", message: errorMessages.E002});
return;

View File

@@ -8,7 +8,7 @@ import Layout from "@/components/High/Layout";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import usePayments from "@/hooks/usePayments";
import {Payment} from "@/interfaces/paypal";
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
import {CellContext, createColumnHelper, flexRender, getCoreRowModel, HeaderGroup, useReactTable} from "@tanstack/react-table";
import {CURRENCIES} from "@/resources/paypal";
import {BsTrash} from "react-icons/bs";
import axios from "axios";
@@ -62,28 +62,21 @@ const columnHelper = createColumnHelper<Payment>();
const PaymentCreator = ({onClose, reload, showComission = false}: {onClose: () => void; reload: () => void; showComission: boolean}) => {
const [corporate, setCorporate] = useState<CorporateUser>();
const [price, setPrice] = useState<number>(0);
const [currency, setCurrency] = useState<string>("EUR");
const [commission, setCommission] = useState<number>(0);
const [referralAgent, setReferralAgent] = useState<AgentUser>();
const [date, setDate] = useState<Date>(new Date());
const {users} = useUsers();
useEffect(() => {
if (!corporate) return setReferralAgent(undefined);
if (!corporate.corporateInformation?.referralAgent) return setReferralAgent(undefined);
const price = corporate?.corporateInformation?.payment?.value || 0;
const commission = corporate?.corporateInformation?.payment?.commission || 0;
const currency = corporate?.corporateInformation?.payment?.currency || "EUR";
const referralAgent = users.find((u) => u.id === corporate.corporateInformation.referralAgent);
setReferralAgent(referralAgent as AgentUser | undefined);
}, [corporate, users]);
const referralAgent = useMemo(() => {
if (corporate?.corporateInformation?.referralAgent) {
return users.find((u) => u.id === corporate.corporateInformation.referralAgent);
}
useEffect(() => {
const payment = corporate?.corporateInformation?.payment;
setPrice(payment?.value || 0);
setCurrency(payment?.currency || "EUR");
}, [corporate]);
return undefined;
}, [corporate?.corporateInformation?.referralAgent, users]);
const submit = () => {
axios
@@ -91,7 +84,7 @@ const PaymentCreator = ({onClose, reload, showComission = false}: {onClose: () =
corporate: corporate?.id,
agent: referralAgent?.id,
agentCommission: commission,
agentValue: toFixedNumber((commission / 100) * price, 2),
agentValue: toFixedNumber((commission! / 100) * price!, 2),
currency,
value: price,
isPaid: false,
@@ -144,18 +137,12 @@ const PaymentCreator = ({onClose, reload, showComission = false}: {onClose: () =
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Price *</label>
<div className="w-full grid grid-cols-5 gap-2">
<Input
name="paymentValue"
onChange={(e) => setPrice(e ? parseInt(e) : 0)}
type="number"
value={price}
className="col-span-3"
/>
<Input name="paymentValue" onChange={() => {}} type="number" value={price} defaultValue={0} className="col-span-3" disabled />
<Select
className="px-4 col-span-2 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"
className="px-4 col-span-2 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-mti-gray-platinum/40 text-mti-gray-dim cursor-not-allowed rounded-full border border-mti-gray-platinum focus:outline-none"
options={CURRENCIES.map(({label, currency}) => ({value: currency, label}))}
defaultValue={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
onChange={(value) => setCurrency(value?.value || "EUR")}
onChange={() => {}}
value={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
styles={{
control: (styles) => ({
@@ -173,6 +160,7 @@ const PaymentCreator = ({onClose, reload, showComission = false}: {onClose: () =
color: state.isFocused ? "black" : styles.color,
}),
}}
isDisabled
/>
</div>
</div>
@@ -180,13 +168,13 @@ const PaymentCreator = ({onClose, reload, showComission = false}: {onClose: () =
<div className="flex gap-4 w-full">
<div className="flex flex-col w-full gap-3">
<label className="font-normal text-base text-mti-gray-dim">Commission *</label>
<Input name="commission" onChange={(e) => setCommission(e ? parseInt(e) : 0)} type="number" defaultValue={0} />
<Input name="commission" onChange={() => {}} type="number" defaultValue={0} value={commission} disabled />
</div>
<div className="flex flex-col w-full gap-3">
<label className="font-normal text-base text-mti-gray-dim">Commission Value*</label>
<Input
name="commissionValue"
value={`${(commission / 100) * price} ${CURRENCIES.find((c) => c.currency === currency)?.label}`}
value={`${(commission! / 100) * price!} ${CURRENCIES.find((c) => c.currency === currency)?.label}`}
onChange={() => null}
type="text"
defaultValue={0}
@@ -237,17 +225,43 @@ const PaymentCreator = ({onClose, reload, showComission = false}: {onClose: () =
};
const IS_PAID_OPTIONS = [
{
value: null,
label: 'All',
}, {
value: false,
label: 'Unpaid',
}, {
value: true,
label: 'Paid',
},
{
value: null,
label: "All",
},
{
value: false,
label: "Unpaid",
},
{
value: true,
label: "Paid",
},
];
const IS_FILE_SUBMITTED_OPTIONS = [
{
value: null,
label: "All",
},
{
value: false,
label: "Submitted",
},
{
value: true,
label: "Not Submitted",
},
];
const CSV_WHITELISTED_KEYS = ["corporateId", "corporate", "date", "amount", "agent", "agentCommission", "agentValue", "isPaid"];
interface SimpleCSVColumn {
key: string;
label: string;
index: number;
}
export default function PaymentRecord() {
const [selectedCorporateUser, setSelectedCorporateUser] = useState<User>();
const [selectedAgentUser, setSelectedAgentUser] = useState<User>();
@@ -262,8 +276,10 @@ export default function PaymentRecord() {
const {users, reload: reloadUsers} = useUsers();
const {payments: originalPayments, reload: reloadPayment} = usePayments();
const [startDate, setStartDate] = useState<Date | null>(moment("01/01/2023").toDate());
const [endDate, setEndDate] = useState<Date | null>(moment().endOf('day').toDate());
const [endDate, setEndDate] = useState<Date | null>(moment().endOf("day").toDate());
const [paid, setPaid] = useState<Boolean | null>(IS_PAID_OPTIONS[0].value);
const [commissionTransfer, setCommissionTransfer] = useState<Boolean | null>(IS_FILE_SUBMITTED_OPTIONS[0].value);
const [corporateTransfer, setCorporateTransfer] = useState<Boolean | null>(IS_FILE_SUBMITTED_OPTIONS[0].value);
const reload = () => {
reloadUsers();
reloadPayment();
@@ -298,8 +314,6 @@ export default function PaymentRecord() {
]);
}, [agent]);
useEffect(() => console.log(filters), [filters]);
useEffect(() => {
setFilters((prev) => [
...prev.filter((x) => x.id !== "corporate-filter"),
@@ -310,10 +324,33 @@ export default function PaymentRecord() {
useEffect(() => {
setFilters((prev) => [
...prev.filter((x) => x.id !== "paid"),
...(typeof paid !== 'boolean' ? [] : [{id: "paid", filter: (p: Payment) => p.isPaid === paid}]),
])
...(typeof paid !== "boolean" ? [] : [{id: "paid", filter: (p: Payment) => p.isPaid === paid}]),
]);
}, [paid]);
useEffect(() => {
setFilters((prev) => [
...prev.filter((x) => x.id !== "commissionTransfer"),
...(typeof commissionTransfer !== "boolean"
? []
: [{id: "commissionTransfer", filter: (p: Payment) => !p.commissionTransfer === commissionTransfer}]),
]);
}, [commissionTransfer]);
useEffect(() => {
setFilters((prev) => [
...prev.filter((x) => x.id !== "corporateTransfer"),
...(typeof corporateTransfer !== "boolean"
? []
: [{id: "corporateTransfer", filter: (p: Payment) => !p.corporateTransfer === corporateTransfer}]),
]);
}, [corporateTransfer]);
useEffect(() => {
if (user && user.type === "corporate") return setCorporate(user);
if (user && user.type === "agent") return setAgent(user);
}, [user]);
const updatePayment = (payment: Payment, key: string, value: any) => {
axios
.patch(`api/payments/${payment.id}`, {...payment, [key]: value})
@@ -355,6 +392,7 @@ export default function PaymentRecord() {
cell: (info) => (
<div className={containerClassName}>
<PaymentAssetManager
reload={reload}
permissions={info.row.original.isPaid ? "read" : "write"}
asset={info.row.original.corporateTransfer}
paymentId={info.row.original.id}
@@ -372,6 +410,7 @@ export default function PaymentRecord() {
cell: (info) => (
<div className={containerClassName}>
<PaymentAssetManager
reload={reload}
permissions="read"
asset={info.row.original.commissionTransfer}
paymentId={info.row.original.id}
@@ -389,6 +428,7 @@ export default function PaymentRecord() {
cell: (info) => (
<div className={containerClassName}>
<PaymentAssetManager
reload={reload}
permissions="read"
asset={info.row.original.corporateTransfer}
paymentId={info.row.original.id}
@@ -403,6 +443,7 @@ export default function PaymentRecord() {
cell: (info) => (
<div className={containerClassName}>
<PaymentAssetManager
reload={reload}
permissions={info.row.original.isPaid ? "read" : "write"}
asset={info.row.original.commissionTransfer}
paymentId={info.row.original.id}
@@ -420,6 +461,7 @@ export default function PaymentRecord() {
cell: (info) => (
<div className={containerClassName}>
<PaymentAssetManager
reload={reload}
permissions="write"
asset={info.row.original.corporateTransfer}
paymentId={info.row.original.id}
@@ -434,6 +476,7 @@ export default function PaymentRecord() {
cell: (info) => (
<div className={containerClassName}>
<PaymentAssetManager
reload={reload}
permissions="write"
asset={info.row.original.commissionTransfer}
paymentId={info.row.original.id}
@@ -450,24 +493,114 @@ export default function PaymentRecord() {
return [];
};
const columHelperValue = (key: string, info: any) => {
switch (key) {
case "agentCommission": {
const value = info.getValue();
return {value: `${value}%`};
}
case "agent": {
const user = users.find((x) => x.id === info.row.original.agent) as AgentUser;
return {
value: user?.name,
user,
};
}
case "agentValue":
case "amount": {
const value = info.getValue();
const numberValue = toFixedNumber(value, 2);
return {value: numberValue};
}
case "date": {
const value = info.getValue();
return {value: moment(value).format("DD/MM/YYYY")};
}
case "corporate": {
const specificValue = info.row.original.corporate;
const user = users.find((x) => x.id === specificValue) as CorporateUser;
return {
user,
value: user?.corporateInformation.companyInformation.name || user?.name,
};
}
case "currency": {
return {
value: info.row.original.currency,
};
}
case "isPaid":
case "corporateId":
default: {
const value = info.getValue();
return {value};
}
}
};
const hiddenToCorporateColumns = () => {
if (user && user.type !== "corporate")
return [
columnHelper.accessor("agent", {
header: "Country Manager",
id: "agent",
cell: (info) => {
const {user, value} = columHelperValue(info.column.id, info);
return (
<div
className={clsx(
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
)}
onClick={() => setSelectedAgentUser(user)}>
{value}
</div>
);
},
}),
columnHelper.accessor("agentCommission", {
header: "Commission",
id: "agentCommission",
cell: (info) => {
const {value} = columHelperValue(info.column.id, info);
return <>{value}</>;
},
}),
columnHelper.accessor("agentValue", {
header: "Commission Value",
id: "agentValue",
cell: (info) => {
const {value} = columHelperValue(info.column.id, info);
const currency = CURRENCIES.find((x) => x.currency === info.row.original.currency)?.label;
const finalValue = `${value} ${currency}`;
return <span>{finalValue}</span>;
},
}),
];
return [];
};
const defaultColumns = [
columnHelper.accessor("id", {
header: "ID",
id: "id",
cell: (info) => info.getValue(),
columnHelper.accessor("corporate", {
header: "Corporate ID",
id: "corporateId",
cell: (info) => {
const {value} = columHelperValue(info.column.id, info);
return value;
},
}),
columnHelper.accessor("corporate", {
header: "Corporate",
id: "corporate",
cell: (info) => {
const user = users.find((x) => x.id === info.row.original.corporate) as CorporateUser;
const {user, value} = columHelperValue(info.column.id, info);
return (
<div
className={clsx(
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
)}
onClick={() => setSelectedCorporateUser(user)}>
{user?.corporateInformation.companyInformation.name || user?.name}
{value}
</div>
);
},
@@ -475,52 +608,43 @@ export default function PaymentRecord() {
columnHelper.accessor("date", {
header: "Date",
id: "date",
cell: (info) => <span>{moment(info.getValue()).format("DD/MM/YYYY")}</span>,
cell: (info) => {
const {value} = columHelperValue(info.column.id, info);
return <span>{value}</span>;
},
}),
columnHelper.accessor("value", {
header: "Amount",
id: "amount",
cell: (info) => (
<span>
{toFixedNumber(info.getValue(), 2)} {CURRENCIES.find((x) => x.currency === info.row.original.currency)?.label}
</span>
),
}),
columnHelper.accessor("agent", {
header: "Country Manager",
id: "agent",
cell: (info) => (
<div
className={clsx("underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer")}
onClick={() => setSelectedAgentUser(users.find((x) => x.id === info.row.original.agent))}>
{(users.find((x) => x.id === info.row.original.agent) as AgentUser)?.name}
</div>
),
}),
columnHelper.accessor("agentCommission", {
header: "Commission",
id: "agentCommission",
cell: (info) => <>{info.getValue()}%</>,
}),
columnHelper.accessor("agentValue", {
header: "Commission Value",
id: "agentValue",
cell: (info) => (
<span>
{toFixedNumber(info.getValue(), 2)} {CURRENCIES.find((x) => x.currency === info.row.original.currency)?.label}
</span>
),
cell: (info) => {
const {value} = columHelperValue(info.column.id, info);
const currency = CURRENCIES.find((x) => x.currency === info.row.original.currency)?.label;
const finalValue = `${value} ${currency}`;
return <span>{finalValue}</span>;
},
}),
...hiddenToCorporateColumns(),
columnHelper.accessor("isPaid", {
header: "Paid",
id: "isPaid",
cell: (info) => (
<Checkbox
isChecked={info.getValue()}
onChange={(e) => (user?.type !== "agent" ? updatePayment(info.row.original, "isPaid", e) : null)}>
<span></span>
</Checkbox>
),
cell: (info) => {
const {value} = columHelperValue(info.column.id, info);
return (
<Checkbox
isChecked={value}
onChange={(e) => {
if (user?.type === agent || user?.type === "corporate" || value) return null;
if (!info.row.original.commissionTransfer || !info.row.original.corporateTransfer)
return alert("All files need to be uploaded to consider it paid!");
if (!confirm(`Are you sure you want to consider this payment paid?`)) return null;
return updatePayment(info.row.original, "isPaid", e);
}}>
<span></span>
</Checkbox>
);
},
}),
...getFileAssetsColumns(),
{
@@ -547,8 +671,8 @@ export default function PaymentRecord() {
});
const getUserModal = () => {
if(user) {
if(selectedCorporateUser) {
if (user) {
if (selectedCorporateUser) {
return (
<Modal isOpen={!!selectedCorporateUser} onClose={() => setSelectedCorporateUser(undefined)}>
<>
@@ -570,7 +694,7 @@ export default function PaymentRecord() {
);
}
if(selectedAgentUser) {
if (selectedAgentUser) {
return (
<Modal isOpen={!!selectedAgentUser} onClose={() => setSelectedAgentUser(undefined)}>
<>
@@ -593,8 +717,48 @@ export default function PaymentRecord() {
}
return null;
}
};
const getCSVData = () => {
const columns = table.getHeaderGroups().reduce((accm: SimpleCSVColumn[], group: HeaderGroup<Payment>) => {
const whitelistedColumns = group.headers.filter((header) => CSV_WHITELISTED_KEYS.includes(header.id));
const data = whitelistedColumns.map((data) => ({
key: data.column.columnDef.id,
label: data.column.columnDef.header,
})) as SimpleCSVColumn[];
return [...accm, ...data];
}, []);
const {rows} = table.getRowModel();
const finalColumns = [
...columns,
{
key: "currency",
label: "Currency",
},
];
return {
columns: finalColumns,
rows: rows.map((row) => {
return finalColumns.reduce((accm, {key}) => {
const {value} = columHelperValue(key, {
row,
getValue: () => row.getValue(key),
});
return {
...accm,
[key]: value,
};
}, {});
}),
};
};
const {rows: csvRows, columns: csvColumns} = getCSVData();
return (
<>
<Head>
@@ -623,15 +787,7 @@ export default function PaymentRecord() {
{(user.type === "developer" || user.type === "admin") && (
<div className="flex justify-end gap-2">
<Button className="max-w-[200px]" variant="outline">
<CSVLink
data={displayPayments}
headers={defaultColumns
.filter((e) => e.header)
.map((e) => ({
label: e.header?.toString() || "",
key: e.id || "",
}))}
filename="payment-records.csv">
<CSVLink data={csvRows} headers={csvColumns} filename="payment-records.csv">
Download CSV
</CSVLink>
</Button>
@@ -641,17 +797,30 @@ export default function PaymentRecord() {
</div>
)}
</div>
<div className="flex gap-8 w-full">
<div className={clsx("grid grid-cols-1 md:grid-cols-2 gap-8 w-full", user.type !== "corporate" && "lg:grid-cols-3")}>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Corporate account *</label>
<Select
isClearable
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"
isClearable={user.type !== "corporate"}
className={clsx(
"px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
user.type === "corporate" && "!bg-mti-gray-platinum/40 !text-mti-gray-dim !cursor-not-allowed",
)}
options={(users.filter((u) => u.type === "corporate") as CorporateUser[]).map((user) => ({
value: user.id,
meta: user,
label: `${user.corporateInformation.companyInformation.name || user.name} - ${user.email}`,
}))}
defaultValue={
user.type === "corporate"
? {
value: user.id,
meta: user,
label: `${user.corporateInformation.companyInformation.name || user.name} - ${user.email}`,
}
: undefined
}
isDisabled={user.type === "corporate"}
onChange={(value) => setCorporate((value as any)?.meta ?? undefined)}
styles={{
control: (styles) => ({
@@ -671,40 +840,42 @@ export default function PaymentRecord() {
}}
/>
</div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Country manager *</label>
<Select
isClearable
isDisabled={user.type === "agent"}
className={clsx(
"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 rounded-full border border-mti-gray-platinum focus:outline-none",
user.type === "agent" ? "bg-mti-gray-platinum/40" : "bg-white",
)}
options={(users.filter((u) => u.type === "agent") as AgentUser[]).map((user) => ({
value: user.id,
meta: user,
label: `${user.name} - ${user.email}`,
}))}
value={agent ? {value: agent?.id, label: `${agent.name} - ${agent.email}`} : undefined}
onChange={(value) => setAgent(value !== null ? (value as any).meta : undefined)}
styles={{
control: (styles) => ({
...styles,
paddingLeft: "4px",
border: "none",
outline: "none",
":focus": {
{user.type !== "corporate" && (
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Country manager *</label>
<Select
isClearable
isDisabled={user.type === "agent"}
className={clsx(
"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 rounded-full border border-mti-gray-platinum focus:outline-none",
user.type === "agent" ? "bg-mti-gray-platinum/40" : "bg-white",
)}
options={(users.filter((u) => u.type === "agent") as AgentUser[]).map((user) => ({
value: user.id,
meta: user,
label: `${user.name} - ${user.email}`,
}))}
value={agent ? {value: agent?.id, label: `${agent.name} - ${agent.email}`} : undefined}
onChange={(value) => setAgent(value !== null ? (value as any).meta : undefined)}
styles={{
control: (styles) => ({
...styles,
paddingLeft: "4px",
border: "none",
outline: "none",
},
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
</div>
":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">
<label className="font-normal text-base text-mti-gray-dim">Paid</label>
<Select
@@ -716,9 +887,8 @@ export default function PaymentRecord() {
options={IS_PAID_OPTIONS}
value={IS_PAID_OPTIONS.find((e) => e.value === paid)}
onChange={(value) => {
if(value) {
setPaid(value.value);
}
if (value) return setPaid(value.value);
setPaid(null);
}}
styles={{
control: (styles) => ({
@@ -742,7 +912,7 @@ export default function PaymentRecord() {
<label className="font-normal text-base text-mti-gray-dim">Date</label>
<ReactDatePicker
dateFormat="dd/MM/yyyy"
className="border border-mti-gray-dim/40 px-4 py-1.5 rounded-lg text-center w-[256px]"
className="px-4 py-6 w-full text-sm text-center font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed rounded-full border border-mti-gray-platinum focus:outline-none"
selected={startDate}
startDate={startDate}
endDate={endDate}
@@ -751,16 +921,80 @@ export default function PaymentRecord() {
filterDate={(date: Date) => moment(date).isSameOrBefore(moment(new Date()))}
onChange={([initialDate, finalDate]: [Date, Date]) => {
setStartDate(initialDate ?? moment("01/01/2023").toDate());
if(finalDate) {
if (finalDate) {
// basicly selecting a final day works as if I'm selecting the first
// minute of that day. this way it covers the whole day
setEndDate(moment(finalDate).endOf('day').toDate());
setEndDate(moment(finalDate).endOf("day").toDate());
return;
}
setEndDate(null);
}}
/>
</div>
</div>
{user.type !== "corporate" && (
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Commission transfer</label>
<Select
isClearable
className={clsx(
"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 rounded-full border border-mti-gray-platinum focus:outline-none",
)}
options={IS_FILE_SUBMITTED_OPTIONS}
value={IS_FILE_SUBMITTED_OPTIONS.find((e) => e.value === commissionTransfer)}
onChange={(value) => {
if (value) return setCommissionTransfer(value.value);
setCommissionTransfer(null);
}}
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">
<label className="font-normal text-base text-mti-gray-dim">Corporate transfer</label>
<Select
isClearable
className={clsx(
"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 rounded-full border border-mti-gray-platinum focus:outline-none",
)}
options={IS_FILE_SUBMITTED_OPTIONS}
value={IS_FILE_SUBMITTED_OPTIONS.find((e) => e.value === corporateTransfer)}
onChange={(value) => {
if (value) return setCorporateTransfer(value.value);
setCorporateTransfer(null);
}}
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>
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>

View File

@@ -19,6 +19,8 @@ import {shouldRedirectHome} from "@/utils/navigation.disabled";
import moment from "moment";
import {BsCamera, BsCameraFill} from "react-icons/bs";
import {USER_TYPE_LABELS} from "@/resources/user";
import useGroups from "@/hooks/useGroups";
import useUsers from "@/hooks/useUsers";
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user;
@@ -50,27 +52,36 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => {
};
}, sessionOptions);
export default function Home() {
const [bio, setBio] = useState("");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
interface Props {
user: User;
mutateUser: Function;
}
function UserProfile({user, mutateUser}: Props) {
const [bio, setBio] = useState(user.bio || "");
const [name, setName] = useState(user.name || "");
const [email, setEmail] = useState(user.email || "");
const [password, setPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [profilePicture, setProfilePicture] = useState("");
const [profilePicture, setProfilePicture] = useState(user.profilePicture);
const [country, setCountry] = useState<string>();
const [phone, setPhone] = useState<string>();
const [gender, setGender] = useState<Gender>();
const [employment, setEmployment] = useState<EmploymentStatus>();
const [position, setPosition] = useState<string>();
const [companyName, setCompanyName] = useState<string>("");
const [commercialRegistration, setCommercialRegistration] = useState<string>("");
const [country, setCountry] = useState<string>(user.demographicInformation?.country || "");
const [phone, setPhone] = useState<string>(user.demographicInformation?.phone || "");
const [gender, setGender] = useState<Gender | undefined>(user.demographicInformation?.gender || undefined);
const [employment, setEmployment] = useState<EmploymentStatus | undefined>(
user.type === "corporate" ? undefined : user.demographicInformation?.employment,
);
const [position, setPosition] = useState<string | undefined>(user.type === "corporate" ? user.demographicInformation?.position : undefined);
const [companyName, setCompanyName] = useState<string | undefined>(user.type === "agent" ? user.agentInformation?.companyName : undefined);
const [commercialRegistration, setCommercialRegistration] = useState<string | undefined>(
user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined,
);
const {groups} = useGroups();
const {users} = useUsers();
const profilePictureInput = useRef(null);
const {user, mutateUser} = useUser({redirectTo: "/login"});
const expirationDateColor = (date: Date) => {
const momentDate = moment(date);
const today = moment(new Date());
@@ -80,24 +91,6 @@ export default function Home() {
if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light";
};
useEffect(() => {
if (user) {
setName(user.name);
setEmail(user.email);
setBio(user.bio);
setProfilePicture(user.profilePicture);
setCountry(user.demographicInformation?.country);
setPhone(user.demographicInformation?.phone);
setGender(user.demographicInformation?.gender);
setEmployment(user.type === "corporate" ? undefined : user.demographicInformation?.employment);
setPosition(user.type === "corporate" ? user.demographicInformation?.position : undefined);
if(user.type === 'agent') {
setCompanyName(user.agentInformation?.companyName)
setCommercialRegistration(user.agentInformation?.commercialRegistration)
}
}
}, [user]);
const convertBase64 = (file: File) => {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
@@ -133,6 +126,19 @@ export default function Home() {
return;
}
if (email !== user?.email) {
const userAdmins = groups.filter((x) => x.participants.includes(user.id)).map((x) => x.admin);
const message =
users.filter((x) => userAdmins.includes(x.id) && x.type === "corporate").length > 0
? "If you change your e-mail address, you will lose all benefits from your university/institute. Are you sure you want to continue?"
: "Are you sure you want to update your e-mail address?";
if (!confirm(message)) {
setIsLoading(false);
return;
}
}
const request = await axios.post("/api/users/update", {
bio,
name,
@@ -159,6 +165,257 @@ export default function Home() {
setIsLoading(false);
};
return (
<Layout user={user}>
<section className="w-full flex flex-col gap-4 md:gap-8 px-4 py-8">
<h1 className="text-4xl font-bold mb-6 md:hidden">Edit Profile</h1>
<div className="flex -md:flex-col-reverse -md:items-center w-full justify-between">
<div className="flex flex-col gap-8 w-full md:w-2/3">
<h1 className="text-4xl font-bold mb-6 -md:hidden">Edit Profile</h1>
<form className="flex flex-col items-center gap-6 w-full">
<div className="flex flex-col md:flex-row gap-8 w-full">
<Input
label="Name"
type="text"
name="name"
onChange={(e) => setName(e)}
placeholder="Enter your name"
defaultValue={name}
required
/>
<Input
label="E-mail Address"
type="email"
name="email"
onChange={(e) => setEmail(e)}
placeholder="Enter email address"
defaultValue={email}
required
/>
</div>
<div className="flex flex-col md:flex-row gap-8 w-full">
<Input
label="Current Password"
type="password"
name="password"
onChange={(e) => setPassword(e)}
placeholder="Enter your password"
required
/>
<Input
label="New Password"
type="password"
name="newPassword"
onChange={(e) => setNewPassword(e)}
placeholder="Enter your new password (optional)"
/>
</div>
{user.type === "agent" && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full">
<Input
label="Corporate Name"
type="text"
name="companyName"
onChange={() => null}
placeholder="Enter corporate name"
defaultValue={companyName}
disabled
/>
<Input
label="Commercial Registration"
type="text"
name="commercialRegistration"
onChange={() => null}
placeholder="Enter commercial registration"
defaultValue={commercialRegistration}
disabled
/>
</div>
)}
<div className="flex flex-col md:flex-row gap-8 w-full">
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
<CountrySelect value={country} onChange={setCountry} />
</div>
<Input
type="tel"
name="phone"
label="Phone number"
onChange={(e) => setPhone(e)}
placeholder="Enter phone number"
defaultValue={phone}
required
/>
</div>
<div className="flex flex-col md:flex-row gap-8 w-full">
{user.type === "corporate" && (
<Input
name="position"
onChange={setPosition}
defaultValue={position}
type="text"
label="Position"
placeholder="CEO, Head of Marketing..."
required
/>
)}
{user.type !== "corporate" && (
<div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Employment Status *</label>
<RadioGroup
value={employment}
onChange={setEmployment}
className="grid grid-cols-2 items-center gap-4 place-items-center">
{EMPLOYMENT_STATUS.map(({status, label}) => (
<RadioGroup.Option value={status} key={status}>
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-40 md:w-48 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked
? "bg-white border-mti-gray-platinum"
: "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
{label}
</span>
)}
</RadioGroup.Option>
))}
</RadioGroup>
</div>
)}
<div className="flex flex-col gap-8 w-full">
<div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Gender *</label>
<RadioGroup value={gender} onChange={setGender} className="flex flex-row gap-4 justify-between">
<RadioGroup.Option value="male">
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked
? "bg-white border-mti-gray-platinum"
: "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
Male
</span>
)}
</RadioGroup.Option>
<RadioGroup.Option value="female">
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked
? "bg-white border-mti-gray-platinum"
: "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
Female
</span>
)}
</RadioGroup.Option>
<RadioGroup.Option value="other">
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked
? "bg-white border-mti-gray-platinum"
: "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
Other
</span>
)}
</RadioGroup.Option>
</RadioGroup>
</div>
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Expiry Date (click to purchase)</label>
<Link
href="/payment"
className={clsx(
"p-6 w-full flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!user.subscriptionExpirationDate
? "!bg-mti-green-ultralight !border-mti-green-light"
: expirationDateColor(user.subscriptionExpirationDate),
"bg-white border-mti-gray-platinum",
)}>
{!user.subscriptionExpirationDate && "Unlimited"}
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
</Link>
</div>
</div>
</div>
</form>
</div>
<div className="flex flex-col gap-6 w-48">
<div
className="flex flex-col gap-3 items-center h-fit cursor-pointer group"
onClick={() => (profilePictureInput.current as any)?.click()}>
<div className="relative overflow-hidden h-48 w-48 rounded-full">
<div
className={clsx(
"absolute top-0 left-0 bg-mti-purple-light/60 w-full h-full z-20 flex items-center justify-center opacity-0 group-hover:opacity-100",
"transition ease-in-out duration-300",
)}>
<BsCamera className="text-6xl text-mti-purple-ultralight/80" />
</div>
<img src={profilePicture} alt={user.name} className="aspect-square drop-shadow-xl self-end object-cover" />
</div>
<input type="file" className="hidden" onChange={uploadProfilePicture} accept="image/*" ref={profilePictureInput} />
<span
onClick={() => (profilePictureInput.current as any)?.click()}
className="cursor-pointer text-mti-purple-light text-sm">
Change picture
</span>
<h6 className="font-normal text-base text-mti-gray-taupe">{USER_TYPE_LABELS[user.type]}</h6>
</div>
{user.type === "agent" && (
<div className="flag items-center h-fit">
<img
alt={user.demographicInformation?.country.toLowerCase() + "_flag"}
src={`https://flagcdn.com/w320/${user.demographicInformation?.country.toLowerCase()}.png`}
width="320"
/>
</div>
)}
</div>
</div>
<div className="flex flex-col gap-4 mt-8 mb-20">
<span className="text-lg font-bold">Bio</span>
<textarea
className="w-full h-full min-h-[148px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
onChange={(e) => setBio(e.target.value)}
defaultValue={bio}
placeholder="Write your text here..."
/>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Link href="/" className="max-w-[200px] self-end w-full">
<Button color="purple" variant="outline" className="max-w-[200px] self-end w-full">
Back
</Button>
</Link>
<Button color="purple" className="max-w-[200px] self-end w-full" onClick={updateUser} disabled={isLoading}>
Save Changes
</Button>
</div>
</section>
</Layout>
);
}
export default function Home() {
const {user, mutateUser} = useUser({redirectTo: "/login"});
return (
<>
<Head>
@@ -171,252 +428,7 @@ export default function Home() {
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
{user && (
<Layout user={user}>
<section className="w-full flex flex-col gap-4 md:gap-8 px-4 py-8">
<h1 className="text-4xl font-bold mb-6 md:hidden">Edit Profile</h1>
<div className="flex -md:flex-col-reverse -md:items-center w-full justify-between">
<div className="flex flex-col gap-8 w-full md:w-2/3">
<h1 className="text-4xl font-bold mb-6 -md:hidden">Edit Profile</h1>
<form className="flex flex-col items-center gap-6 w-full">
<div className="flex flex-col md:flex-row gap-8 w-full">
<Input
label="Name"
type="text"
name="name"
onChange={(e) => setName(e)}
placeholder="Enter your name"
defaultValue={name}
required
/>
<Input
label="E-mail Address"
type="email"
name="email"
onChange={(e) => setEmail(e)}
placeholder="Enter email address"
defaultValue={email}
required
/>
</div>
<div className="flex flex-col md:flex-row gap-8 w-full">
<Input
label="Old Password"
type="password"
name="password"
onChange={(e) => setPassword(e)}
placeholder="Enter your password"
required
/>
<Input
label="New Password"
type="password"
name="newPassword"
onChange={(e) => setNewPassword(e)}
placeholder="Enter your new password (optional)"
/>
</div>
{user.type === "agent" && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full">
<Input
label="Corporate Name"
type="text"
name="companyName"
onChange={() => null}
placeholder="Enter corporate name"
defaultValue={companyName}
disabled
/>
<Input
label="Commercial Registration"
type="text"
name="commercialRegistration"
onChange={() => null}
placeholder="Enter commercial registration"
defaultValue={commercialRegistration}
disabled
/>
</div>
)}
<div className="flex flex-col md:flex-row gap-8 w-full">
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
<CountrySelect value={country} onChange={setCountry} />
</div>
<Input
type="tel"
name="phone"
label="Phone number"
onChange={(e) => setPhone(e)}
placeholder="Enter phone number"
defaultValue={phone}
required
/>
</div>
<div className="flex flex-col md:flex-row gap-8 w-full">
{user.type === "corporate" && (
<Input
name="position"
onChange={setPosition}
defaultValue={position}
type="text"
label="Position"
placeholder="CEO, Head of Marketing..."
required
/>
)}
{user.type !== "corporate" && (
<div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Employment Status *</label>
<RadioGroup
value={employment}
onChange={setEmployment}
className="grid grid-cols-2 items-center gap-4 place-items-center">
{EMPLOYMENT_STATUS.map(({status, label}) => (
<RadioGroup.Option value={status} key={status}>
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-40 md:w-48 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked
? "bg-white border-mti-gray-platinum"
: "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
{label}
</span>
)}
</RadioGroup.Option>
))}
</RadioGroup>
</div>
)}
<div className="flex flex-col gap-8 w-full">
<div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Gender *</label>
<RadioGroup value={gender} onChange={setGender} className="flex flex-row gap-4 justify-between">
<RadioGroup.Option value="male">
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked
? "bg-white border-mti-gray-platinum"
: "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
Male
</span>
)}
</RadioGroup.Option>
<RadioGroup.Option value="female">
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked
? "bg-white border-mti-gray-platinum"
: "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
Female
</span>
)}
</RadioGroup.Option>
<RadioGroup.Option value="other">
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked
? "bg-white border-mti-gray-platinum"
: "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
Other
</span>
)}
</RadioGroup.Option>
</RadioGroup>
</div>
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Expiry Date (click to purchase)</label>
<Link
href="/payment"
className={clsx(
"p-6 w-full flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!user.subscriptionExpirationDate
? "!bg-mti-green-ultralight !border-mti-green-light"
: expirationDateColor(user.subscriptionExpirationDate),
"bg-white border-mti-gray-platinum",
)}>
{!user.subscriptionExpirationDate && "Unlimited"}
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
</Link>
</div>
</div>
</div>
</form>
</div>
<div className="flex flex-col gap-6 w-48">
<div
className="flex flex-col gap-3 items-center h-fit cursor-pointer group"
onClick={() => (profilePictureInput.current as any)?.click()}>
<div className="relative overflow-hidden h-48 w-48 rounded-full">
<div
className={clsx(
"absolute top-0 left-0 bg-mti-purple-light/60 w-full h-full z-20 flex items-center justify-center opacity-0 group-hover:opacity-100",
"transition ease-in-out duration-300",
)}>
<BsCamera className="text-6xl text-mti-purple-ultralight/80" />
</div>
<img src={profilePicture} alt={user.name} className="aspect-square drop-shadow-xl self-end object-cover" />
</div>
<input type="file" className="hidden" onChange={uploadProfilePicture} accept="image/*" ref={profilePictureInput} />
<span
onClick={() => (profilePictureInput.current as any)?.click()}
className="cursor-pointer text-mti-purple-light text-sm">
Change picture
</span>
<h6 className="font-normal text-base text-mti-gray-taupe">{USER_TYPE_LABELS[user.type]}</h6>
</div>
{user.type === 'agent' && (
<div className="flag items-center h-fit">
<img
alt={user.demographicInformation?.country.toLowerCase() + '_flag'}
src={`https://flagcdn.com/w320/${user.demographicInformation?.country.toLowerCase()}.png`}
width="320"
/>
</div>
)}
</div>
</div>
<div className="flex flex-col gap-4 mt-8 mb-20">
<span className="text-lg font-bold">Bio</span>
<textarea
className="w-full h-full min-h-[148px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
onChange={(e) => setBio(e.target.value)}
defaultValue={bio}
placeholder="Write your text here..."
/>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Link href="/" className="max-w-[200px] self-end w-full">
<Button color="purple" variant="outline" className="max-w-[200px] self-end w-full">
Back
</Button>
</Link>
<Button color="purple" className="max-w-[200px] self-end w-full" onClick={updateUser} disabled={isLoading}>
Save Changes
</Button>
</div>
</section>
</Layout>
)}
{user && <UserProfile user={user} mutateUser={mutateUser} />}
</>
);
}

View File

@@ -1,6 +1,6 @@
/* eslint-disable @next/next/no-img-element */
import {ToastContainer} from "react-toastify";
import {useState} from "react";
import {useEffect, useState} from "react";
import Head from "next/head";
import useUser from "@/hooks/useUser";
import Link from "next/link";
@@ -11,6 +11,7 @@ import RegisterCorporate from "./(register)/RegisterCorporate";
import EmailVerification from "./(auth)/EmailVerification";
import {sendEmailVerification} from "@/utils/email";
import useUsers from "@/hooks/useUsers";
import axios from "axios";
export const getServerSideProps = (context: any) => {
const {code} = context.query;
@@ -21,8 +22,17 @@ export const getServerSideProps = (context: any) => {
};
export default function Register({code: queryCode}: {code: string}) {
const [defaultEmail, setDefaultEmail] = useState<string>();
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (queryCode) {
(async () => {
axios.get<{email?: string}>(`/api/code/${queryCode}`).then((result) => setDefaultEmail(result.data.email));
})();
}
}, [queryCode]);
const {user, mutateUser} = useUser({
redirectTo: "/",
redirectIfFound: true,
@@ -79,11 +89,13 @@ export default function Register({code: queryCode}: {code: string}) {
<Tab.Panels>
<Tab.Panel>
<RegisterIndividual
key={defaultEmail || "individual"}
isLoading={isLoading}
setIsLoading={setIsLoading}
mutateUser={mutateUser}
sendEmailVerification={sendEmailVerification}
queryCode={queryCode}
defaultEmail={defaultEmail}
/>
</Tab.Panel>
<Tab.Panel>