Merge branch 'develop' into feature-report-export
This commit is contained in:
@@ -22,8 +22,8 @@ interface Props {
|
||||
|
||||
export default function Diagnostic({onFinish}: Props) {
|
||||
const [focus, setFocus] = useState<"academic" | "general">();
|
||||
const [levels, setLevels] = useState({reading: -1, listening: -1, writing: -1, speaking: -1});
|
||||
const [desiredLevels, setDesiredLevels] = useState({reading: 9, listening: 9, writing: 9, speaking: 9});
|
||||
const [levels, setLevels] = useState({reading: -1, listening: -1, writing: -1, speaking: -1, level: 0});
|
||||
const [desiredLevels, setDesiredLevels] = useState({reading: 9, listening: 9, writing: 9, speaking: 9, level: 9});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function Diagnostic({onFinish}: Props) {
|
||||
axios
|
||||
.patch("/api/users/update", {
|
||||
focus,
|
||||
levels: Object.values(levels).includes(-1) ? {reading: 0, listening: 0, writing: 0, speaking: 0} : levels,
|
||||
levels: Object.values(levels).includes(-1) ? {reading: 0, listening: 0, writing: 0, speaking: 0, level: 0} : levels,
|
||||
desiredLevels,
|
||||
isFirstLogin: false,
|
||||
})
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
||||
const calculateScore = () => {
|
||||
const total = questions.length || 0;
|
||||
const correct = answers.filter(
|
||||
(x) => questions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false,
|
||||
(x) => questions.find((y) => x.id.toString() === y.id.toString())?.solution?.toLowerCase() === x.solution.toLowerCase() || false,
|
||||
).length;
|
||||
const missing = total - answers.filter((x) => questions.find((y) => x.id.toString() === y.id.toString())).length;
|
||||
|
||||
@@ -62,41 +62,37 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
||||
</div>
|
||||
<span className="text-sm w-full leading-6">You can click a selected option again to deselect it.</span>
|
||||
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-8">
|
||||
{questions.map((question, index) => (
|
||||
<div key={question.id.toString()} className="flex flex-col gap-4">
|
||||
<span>
|
||||
{index + 1}. {question.prompt}
|
||||
</span>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
variant={
|
||||
answers.find((x) => x.id.toString() === question.id.toString())?.solution === "true" ? "solid" : "outline"
|
||||
}
|
||||
onClick={() => toggleAnswer("true", question.id.toString())}
|
||||
className="!py-2">
|
||||
True
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
answers.find((x) => x.id.toString() === question.id.toString())?.solution === "false" ? "solid" : "outline"
|
||||
}
|
||||
onClick={() => toggleAnswer("false", question.id.toString())}
|
||||
className="!py-2">
|
||||
False
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
answers.find((x) => x.id.toString() === question.id.toString())?.solution === "not_given"
|
||||
? "solid"
|
||||
: "outline"
|
||||
}
|
||||
onClick={() => toggleAnswer("not_given", question.id.toString())}
|
||||
className="!py-2">
|
||||
Not Given
|
||||
</Button>
|
||||
{questions.map((question, index) => {
|
||||
const id = question.id.toString();
|
||||
|
||||
return (
|
||||
<div key={question.id.toString()} className="flex flex-col gap-4">
|
||||
<span>
|
||||
{index + 1}. {question.prompt}
|
||||
</span>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
variant={answers.find((x) => x.id.toString() === id)?.solution === "true" ? "solid" : "outline"}
|
||||
onClick={() => toggleAnswer("true", id)}
|
||||
className="!py-2">
|
||||
True
|
||||
</Button>
|
||||
<Button
|
||||
variant={answers.find((x) => x.id.toString() === id)?.solution === "false" ? "solid" : "outline"}
|
||||
onClick={() => toggleAnswer("false", id)}
|
||||
className="!py-2">
|
||||
False
|
||||
</Button>
|
||||
<Button
|
||||
variant={answers.find((x) => x.id.toString() === id)?.solution === "not_given" ? "solid" : "outline"}
|
||||
onClick={() => toggleAnswer("not_given", id)}
|
||||
className="!py-2">
|
||||
Not Given
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ export default function Writing({
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem("enable_paste")) return;
|
||||
|
||||
const listener = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "v") {
|
||||
e.preventDefault();
|
||||
@@ -93,8 +95,8 @@ export default function Writing({
|
||||
)}
|
||||
<div className="flex flex-col h-full w-full gap-9 mb-20">
|
||||
<div className="flex flex-col w-full gap-7 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
||||
<span className="whitespace-pre-wrap">{prefix}</span>
|
||||
<span className="font-semibold whitespace-pre-wrap">{prompt}</span>
|
||||
<span className="whitespace-pre-wrap">{prefix.replaceAll("\\n", "\n")}</span>
|
||||
<span className="font-semibold whitespace-pre-wrap">{prompt.replaceAll("\\n", "\n")}</span>
|
||||
{attachment && (
|
||||
<img
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {BsArrowRepeat} from "react-icons/bs";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
color?: "rose" | "purple" | "red" | "green";
|
||||
color?: "rose" | "purple" | "red" | "green" | "gray";
|
||||
variant?: "outline" | "solid";
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
@@ -39,6 +39,11 @@ export default function Button({
|
||||
outline:
|
||||
"bg-transparent text-mti-red-light border border-mti-red-light hover:bg-mti-red-light disabled:text-mti-red disabled:bg-mti-red-ultralight disabled:border-none selection:bg-mti-red-dark hover:text-white selection:text-white",
|
||||
},
|
||||
gray: {
|
||||
solid: "bg-mti-gray-davy text-white border border-mti-gray-davy hover:bg-mti-gray-davy disabled:text-mti-gray-davy disabled:bg-mti-gray-davy selection:bg-mti-gray-davy",
|
||||
outline:
|
||||
"bg-transparent text-mti-gray-davy border border-mti-gray-davy hover:bg-mti-gray-davy disabled:text-mti-gray-davy disabled:bg-mti-gray-davy disabled:border-none selection:bg-mti-gray-davy hover:text-white selection:text-white",
|
||||
},
|
||||
rose: {
|
||||
solid: "bg-mti-rose-light text-white border border-mti-rose-light hover:bg-mti-rose disabled:text-mti-rose disabled:bg-mti-rose-ultralight selection:bg-mti-rose-dark",
|
||||
outline:
|
||||
|
||||
@@ -103,7 +103,7 @@ export default function MobileMenu({isOpen, onClose, path, user}: Props) {
|
||||
)}>
|
||||
Record
|
||||
</Link>
|
||||
{["admin", "developer", "agent"].includes(user.type) && (
|
||||
{["admin", "developer", "agent", "corporate"].includes(user.type) && (
|
||||
<Link
|
||||
href="/payment-record"
|
||||
className={clsx(
|
||||
|
||||
@@ -1,156 +1,145 @@
|
||||
import React, { ChangeEvent } from "react";
|
||||
import { BsUpload, BsDownload, BsTrash, BsArrowRepeat } from "react-icons/bs";
|
||||
import { FilesStorage } from "@/interfaces/storage.files";
|
||||
import React, {ChangeEvent} from "react";
|
||||
import {BsUpload, BsDownload, BsTrash, BsArrowRepeat, BsXCircleFill} from "react-icons/bs";
|
||||
import {FilesStorage} from "@/interfaces/storage.files";
|
||||
import axios from "axios";
|
||||
|
||||
interface Asset {
|
||||
file: string | File;
|
||||
complete: boolean;
|
||||
file: string | File;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
const PaymentAssetManager = (props: {
|
||||
asset: string | undefined;
|
||||
permissions: "read" | "write";
|
||||
type: FilesStorage;
|
||||
paymentId: string;
|
||||
asset: string | undefined;
|
||||
permissions: "read" | "write";
|
||||
type: FilesStorage;
|
||||
reload: () => void;
|
||||
paymentId: string;
|
||||
}) => {
|
||||
const { asset, permissions, type, paymentId } = props;
|
||||
const {asset, permissions, type, paymentId} = props;
|
||||
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const fileInputReplaceRef = React.useRef<HTMLInputElement>(null);
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const fileInputReplaceRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const [managingAsset, setManagingAsset] = React.useState<Asset>({
|
||||
file: asset || "",
|
||||
complete: asset ? true : false,
|
||||
});
|
||||
const [managingAsset, setManagingAsset] = React.useState<Asset>({
|
||||
file: asset || "",
|
||||
complete: asset ? true : false,
|
||||
});
|
||||
|
||||
const { file, complete } = managingAsset;
|
||||
const {file, complete} = managingAsset;
|
||||
|
||||
const deleteAsset = () => {
|
||||
if (confirm("Are you sure you want to delete this document?")) {
|
||||
axios
|
||||
.delete(`/api/payments/files/${type}/${paymentId}`)
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
console.log("File deleted successfully!");
|
||||
setManagingAsset({
|
||||
file: "",
|
||||
complete: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const deleteAsset = () => {
|
||||
if (confirm("Are you sure you want to delete this document?")) {
|
||||
axios
|
||||
.delete(`/api/payments/files/${type}/${paymentId}`)
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
console.log("File deleted successfully!");
|
||||
setManagingAsset({
|
||||
file: "",
|
||||
complete: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("File deletion failed");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error occurred during file deletion:", error);
|
||||
});
|
||||
}
|
||||
};
|
||||
console.error("File deletion failed");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error occurred during file deletion:", error);
|
||||
})
|
||||
.finally(props.reload);
|
||||
}
|
||||
};
|
||||
|
||||
const renderFileInput = (
|
||||
onChange: any,
|
||||
ref: React.RefObject<HTMLInputElement>
|
||||
) => (
|
||||
<input
|
||||
type="file"
|
||||
ref={ref}
|
||||
style={{ display: "none" }}
|
||||
onChange={onChange}
|
||||
multiple={false}
|
||||
accept="application/pdf"
|
||||
/>
|
||||
);
|
||||
const renderFileInput = (onChange: any, ref: React.RefObject<HTMLInputElement>) => (
|
||||
<input type="file" ref={ref} style={{display: "none"}} onChange={onChange} multiple={false} accept="application/pdf" />
|
||||
);
|
||||
|
||||
const handleFileChange = async (e: Event, method: "post" | "patch") => {
|
||||
const newFile = (e.target as HTMLInputElement).files?.[0];
|
||||
if (newFile) {
|
||||
setManagingAsset({
|
||||
file: newFile,
|
||||
complete: false,
|
||||
});
|
||||
const handleFileChange = async (e: Event, method: "post" | "patch") => {
|
||||
const newFile = (e.target as HTMLInputElement).files?.[0];
|
||||
if (newFile) {
|
||||
setManagingAsset({
|
||||
file: newFile,
|
||||
complete: false,
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", newFile);
|
||||
const formData = new FormData();
|
||||
formData.append("file", newFile);
|
||||
|
||||
axios[method](`/api/payments/files/${type}/${paymentId}`, formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
console.log("File uploaded successfully!");
|
||||
console.log("Uploaded File URL:", response.data.ref);
|
||||
// Further actions upon successful upload
|
||||
setManagingAsset({
|
||||
file: response.data.ref,
|
||||
complete: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
axios[method](`/api/payments/files/${type}/${paymentId}`, formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
console.log("File uploaded successfully!");
|
||||
console.log("Uploaded File URL:", response.data.ref);
|
||||
// Further actions upon successful upload
|
||||
setManagingAsset({
|
||||
file: response.data.ref,
|
||||
complete: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("File upload failed");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error occurred during file upload:", error);
|
||||
});
|
||||
}
|
||||
};
|
||||
console.error("File upload failed");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error occurred during file upload:", error);
|
||||
})
|
||||
.finally(props.reload);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadAsset = () => {
|
||||
axios
|
||||
.get(`/api/payments/files/${type}/${paymentId}`)
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
console.log("Uploaded File URL:", response.data.url);
|
||||
const link = document.createElement("a");
|
||||
link.download = response.data.filename;
|
||||
link.href = response.data.url;
|
||||
link.click();
|
||||
return;
|
||||
}
|
||||
const downloadAsset = () => {
|
||||
axios
|
||||
.get(`/api/payments/files/${type}/${paymentId}`)
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
console.log("Uploaded File URL:", response.data.url);
|
||||
const link = document.createElement("a");
|
||||
link.download = response.data.filename;
|
||||
link.href = response.data.url;
|
||||
link.click();
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("Failed to download file");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error occurred during file upload:", error);
|
||||
});
|
||||
};
|
||||
console.error("Failed to download file");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error occurred during file upload:", error);
|
||||
});
|
||||
};
|
||||
|
||||
if (permissions === "read") {
|
||||
if (file) return <BsDownload onClick={downloadAsset} />;
|
||||
return null;
|
||||
}
|
||||
if (permissions === "read") {
|
||||
if (file) return <BsDownload onClick={downloadAsset} />;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (file) {
|
||||
if (complete) {
|
||||
return (
|
||||
<>
|
||||
<BsDownload onClick={downloadAsset} />
|
||||
<BsArrowRepeat onClick={() => fileInputReplaceRef.current?.click()} />
|
||||
<BsTrash onClick={deleteAsset} />
|
||||
{renderFileInput(
|
||||
(e: Event) => handleFileChange(e, "patch"),
|
||||
fileInputReplaceRef
|
||||
)}
|
||||
{renderFileInput(
|
||||
(e: Event) => handleFileChange(e, "post"),
|
||||
fileInputRef
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (file) {
|
||||
if (complete) {
|
||||
return (
|
||||
<>
|
||||
<BsDownload onClick={downloadAsset} />
|
||||
<BsArrowRepeat onClick={() => fileInputReplaceRef.current?.click()} />
|
||||
<BsTrash onClick={deleteAsset} />
|
||||
{renderFileInput((e: Event) => handleFileChange(e, "patch"), fileInputReplaceRef)}
|
||||
{renderFileInput((e: Event) => handleFileChange(e, "post"), fileInputRef)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="loading loading-infinity w-8" />;
|
||||
}
|
||||
return <span className="loading loading-infinity w-8" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BsUpload onClick={() => fileInputRef.current?.click()} />
|
||||
{renderFileInput((e: Event) => handleFileChange(e, "post"), fileInputRef)}
|
||||
</>
|
||||
);
|
||||
return permissions === "write" ? (
|
||||
<>
|
||||
<BsUpload onClick={() => fileInputRef.current?.click()} />
|
||||
{renderFileInput((e: Event) => handleFileChange(e, "post"), fileInputRef)}
|
||||
</>
|
||||
) : (
|
||||
<BsXCircleFill />
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentAssetManager;
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
||||
)}
|
||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
|
||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
|
||||
{["admin", "developer", "agent"].includes(userType || "") && (
|
||||
{["admin", "developer", "agent", "corporate"].includes(userType || "") && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCurrencyDollar}
|
||||
|
||||
@@ -28,9 +28,9 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
"rounded-full hover:text-white hover:bg-mti-red transition duration-300 ease-in-out my-1 px-5 py-2 text-center text-white bg-mti-red-light",
|
||||
"rounded-full hover:text-white hover:bg-mti-gray-davy transition duration-300 ease-in-out my-1 px-5 py-2 text-center text-white bg-mti-gray-davy",
|
||||
)}>
|
||||
{solution.solution}
|
||||
{solution?.solution}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -99,7 +99,7 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
|
||||
Correct
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-4 h-4 rounded-full bg-mti-red" />
|
||||
<div className="w-4 h-4 rounded-full bg-mti-gray-davy" />
|
||||
Unanswered
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function MatchSentencesSolutions({
|
||||
className={clsx(
|
||||
"w-8 h-8 rounded-full z-10 text-white",
|
||||
"transition duration-300 ease-in-out",
|
||||
!userSolutions.find((x) => x.question.toString() === id.toString()) && "!bg-mti-red",
|
||||
!userSolutions.find((x) => x.question.toString() === id.toString()) && "!bg-mti-gray-davy",
|
||||
userSolutions.find((x) => x.question.toString() === id.toString())?.option === solution && "bg-mti-purple",
|
||||
userSolutions.find((x) => x.question.toString() === id.toString())?.option !== solution && "bg-mti-rose",
|
||||
)}>
|
||||
@@ -96,7 +96,7 @@ export default function MatchSentencesSolutions({
|
||||
<div className="w-4 h-4 rounded-full bg-mti-purple" /> Correct
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-4 h-4 rounded-full bg-mti-red" /> Unanswered
|
||||
<div className="w-4 h-4 rounded-full bg-mti-gray-davy" /> Unanswered
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-4 h-4 rounded-full bg-mti-rose" /> Wrong
|
||||
|
||||
@@ -14,7 +14,7 @@ function Question({
|
||||
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
|
||||
const optionColor = (option: string) => {
|
||||
if (option === solution && !userSolution) {
|
||||
return "!border-mti-red-light !text-mti-red-light";
|
||||
return "!border-mti-gray-davy !text-mti-gray-davy";
|
||||
}
|
||||
|
||||
if (option === solution) {
|
||||
@@ -114,7 +114,7 @@ export default function MultipleChoice({
|
||||
Correct
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-4 h-4 rounded-full bg-mti-red" />
|
||||
<div className="w-4 h-4 rounded-full bg-mti-gray-davy" />
|
||||
Unanswered
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
|
||||
@@ -80,7 +80,8 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{userSolutions[0].evaluation && userSolutions[0].evaluation.perfect_answer ? (
|
||||
{userSolutions[0].evaluation &&
|
||||
(userSolutions[0].evaluation.perfect_answer || userSolutions[0].evaluation.perfect_answer_1) ? (
|
||||
<Tab.Group>
|
||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
||||
<Tab
|
||||
@@ -112,7 +113,10 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
||||
</Tab.Panel>
|
||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
||||
{userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n")}
|
||||
{userSolutions[0].evaluation!.perfect_answer &&
|
||||
userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n")}
|
||||
{userSolutions[0].evaluation!.perfect_answer_1 &&
|
||||
userSolutions[0].evaluation!.perfect_answer_1.replaceAll(/\s{2,}/g, "\n\n")}
|
||||
</span>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
||||
return "rose";
|
||||
}
|
||||
|
||||
return "red";
|
||||
return "gray";
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -67,6 +67,7 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
||||
{userSolutions &&
|
||||
questions.map((question, index) => {
|
||||
const userSolution = userSolutions.find((x) => x.id === question.id.toString());
|
||||
const solution = question.solution.toString().toLowerCase() as Solution;
|
||||
|
||||
return (
|
||||
<div key={question.id.toString()} className="flex flex-col gap-4">
|
||||
@@ -75,23 +76,23 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
||||
</span>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
variant={question.solution === "true" || userSolution?.solution === "true" ? "solid" : "outline"}
|
||||
variant={solution === "true" || userSolution?.solution.toLowerCase() === "true" ? "solid" : "outline"}
|
||||
className="!py-2"
|
||||
color={getButtonColor("true", question.solution, userSolution?.solution)}>
|
||||
color={getButtonColor("true", solution, userSolution?.solution.toLowerCase() as Solution)}>
|
||||
True
|
||||
</Button>
|
||||
<Button
|
||||
variant={question.solution === "false" || userSolution?.solution === "false" ? "solid" : "outline"}
|
||||
variant={solution === "false" || userSolution?.solution.toLowerCase() === "false" ? "solid" : "outline"}
|
||||
className="!py-2"
|
||||
color={getButtonColor("false", question.solution, userSolution?.solution)}>
|
||||
color={getButtonColor("false", solution, userSolution?.solution.toLowerCase() as Solution)}>
|
||||
False
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
question.solution === "not_given" || userSolution?.solution === "not_given" ? "solid" : "outline"
|
||||
solution === "not_given" || userSolution?.solution.toLowerCase() === "not_given" ? "solid" : "outline"
|
||||
}
|
||||
className="!py-2"
|
||||
color={getButtonColor("not_given", question.solution, userSolution?.solution)}>
|
||||
color={getButtonColor("not_given", solution, userSolution?.solution.toLowerCase() as Solution)}>
|
||||
Not Given
|
||||
</Button>
|
||||
</div>
|
||||
@@ -105,7 +106,7 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
||||
Correct
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-4 h-4 rounded-full bg-mti-red" />
|
||||
<div className="w-4 h-4 rounded-full bg-mti-gray-davy" />
|
||||
Unanswered
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
|
||||
@@ -38,7 +38,7 @@ function Blank({
|
||||
|
||||
const getSolutionStyling = () => {
|
||||
if (!userSolution) {
|
||||
return "bg-mti-red-ultralight text-mti-red-light";
|
||||
return "bg-mti-gray-davy text-mti-gray-davy";
|
||||
}
|
||||
|
||||
return "bg-mti-purple-ultralight text-mti-purple-light";
|
||||
@@ -131,7 +131,7 @@ export default function WriteBlanksSolutions({
|
||||
Correct
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-4 h-4 rounded-full bg-mti-red" />
|
||||
<div className="w-4 h-4 rounded-full bg-mti-gray-davy" />
|
||||
Unanswered
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
|
||||
@@ -6,10 +6,31 @@ import Button from "../Low/Button";
|
||||
import {Dialog, Tab, Transition} from "@headlessui/react";
|
||||
import {writingReverseMarking} from "@/utils/score";
|
||||
import clsx from "clsx";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
|
||||
export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const formatSolution = (solution: string, errors: {correction: string | null; misspelled: string}[]) => {
|
||||
const errorRegex = new RegExp(errors.map((x) => `(${x.misspelled})`).join("|"));
|
||||
|
||||
return (
|
||||
<>
|
||||
{reactStringReplace(solution, errorRegex, (match) => {
|
||||
const correction = errors.find((x) => x.misspelled === match)?.correction;
|
||||
|
||||
return (
|
||||
<span
|
||||
data-tip={correction ? correction : undefined}
|
||||
className={clsx("text-mti-red-light font-medium underline underline-offset-2", correction && "tooltip")}>
|
||||
{match}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{attachment && (
|
||||
@@ -67,12 +88,14 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
||||
{userSolutions && (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<span>Your answer:</span>
|
||||
<textarea
|
||||
className="w-full h-full min-h-[320px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
|
||||
contentEditable={false}
|
||||
readOnly
|
||||
value={userSolutions[0]!.solution}
|
||||
/>
|
||||
<div className="w-full h-full min-h-[320px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl whitespace-pre-wrap">
|
||||
{userSolutions[0]!.evaluation && userSolutions[0]!.evaluation.misspelled_pairs
|
||||
? formatSolution(
|
||||
userSolutions[0]!.solution.replaceAll("\\n", "\n"),
|
||||
userSolutions[0]!.evaluation.misspelled_pairs,
|
||||
)
|
||||
: userSolutions[0]!.solution.replaceAll("\\n", "\n")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||
@@ -116,7 +139,7 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
||||
</Tab.Panel>
|
||||
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
||||
{userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n")}
|
||||
{userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n").replaceAll("\\n", "\n")}
|
||||
</span>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
|
||||
@@ -191,11 +191,12 @@ export default function StudentDashboard({user}: Props) {
|
||||
{module === "listening" && <BsHeadphones className="text-ielts-listening w-4 h-4 md:w-5 md:h-5" />}
|
||||
{module === "writing" && <BsPen className="text-ielts-writing w-4 h-4 md:w-5 md:h-5" />}
|
||||
{module === "speaking" && <BsMegaphone className="text-ielts-speaking w-4 h-4 md:w-5 md:h-5" />}
|
||||
{module === "level" && <BsClipboard className="text-ielts-level w-4 h-4 md:w-5 md:h-5" />}
|
||||
</div>
|
||||
<div className="flex justify-between w-full">
|
||||
<span className="font-bold md:font-extrabold text-sm">{capitalize(module)}</span>
|
||||
<span className="text-sm font-normal text-mti-gray-dim">
|
||||
Level {user.levels[module]} / Level {user.desiredLevels[module]}
|
||||
Level {user.levels[module] || 0} / Level {user.desiredLevels[module] || 9}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,8 +10,8 @@ import Link from "next/link";
|
||||
import {useRouter} from "next/router";
|
||||
import {Fragment, useEffect, useState} from "react";
|
||||
import {BsArrowCounterclockwise, BsBook, BsClipboard, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs";
|
||||
import { LevelScore } from "@/constants/ielts";
|
||||
import { getLevelScore } from "@/utils/score";
|
||||
import {LevelScore} from "@/constants/ielts";
|
||||
import {getLevelScore} from "@/utils/score";
|
||||
|
||||
interface Score {
|
||||
module: Module;
|
||||
@@ -71,20 +71,18 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
const bandScore: number = calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus);
|
||||
|
||||
const showLevel = (level: number) => {
|
||||
if(selectedModule === "level") {
|
||||
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 <span className="text-3xl font-bold">{level}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -156,14 +154,16 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
{isLoading && (
|
||||
<div className="w-fit h-fit absolute top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 animate-pulse flex flex-col gap-12 items-center">
|
||||
<span className={clsx("loading loading-infinity w-32", moduleColors[selectedModule].progress)} />
|
||||
<span className={clsx("font-bold text-2xl", moduleColors[selectedModule].progress)}>Evaluating your answers...</span>
|
||||
<span className={clsx("font-bold text-2xl text-center", moduleColors[selectedModule].progress)}>
|
||||
Evaluating your answers, please be patient...
|
||||
<br />
|
||||
You can also check it later on your records page!
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && (
|
||||
<div className="w-full flex gap-9 mt-32 items-center justify-between mb-20">
|
||||
<span className="max-w-3xl">
|
||||
{moduleResultText(selectedModule, bandScore)}
|
||||
</span>
|
||||
<span className="max-w-3xl">{moduleResultText(selectedModule, bandScore)}</span>
|
||||
<div className="flex gap-9 px-16">
|
||||
<div
|
||||
className={clsx("radial-progress overflow-hidden", moduleColors[selectedModule].progress)}
|
||||
|
||||
48
src/hooks/useListSearch.tsx
Normal file
48
src/hooks/useListSearch.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import {useState, useMemo} from 'react';
|
||||
import Input from "@/components/Low/Input";
|
||||
|
||||
/*fields example = [
|
||||
['id'],
|
||||
['companyInformation', 'companyInformation', 'name']
|
||||
]*/
|
||||
|
||||
|
||||
const getFieldValue = (fields: string[], data: any): string => {
|
||||
if(fields.length === 0) return data;
|
||||
const [key, ...otherFields] = fields;
|
||||
|
||||
if(data[key]) return getFieldValue(otherFields, data[key]);
|
||||
return data;
|
||||
}
|
||||
|
||||
export const useListSearch = (fields: string[][], rows: any[]) => {
|
||||
const [text, setText] = useState('');
|
||||
|
||||
const renderSearch = () => (
|
||||
<Input
|
||||
label="Search"
|
||||
type="text"
|
||||
name="search"
|
||||
onChange={setText}
|
||||
placeholder="Enter search text"
|
||||
value={text}
|
||||
/>
|
||||
)
|
||||
|
||||
const updatedRows = useMemo(() => {
|
||||
const searchText = text.toLowerCase();
|
||||
return rows.filter((row) => {
|
||||
return fields.some((fieldsKeys) => {
|
||||
const value = getFieldValue(fieldsKeys, row);
|
||||
if(typeof value === 'string') {
|
||||
return value.toLowerCase().includes(searchText);
|
||||
}
|
||||
})
|
||||
})
|
||||
}, [fields, rows, text])
|
||||
|
||||
return {
|
||||
rows: updatedRows,
|
||||
renderSearch,
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export default function useStats(id?: string) {
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<Stat[]>(!id ? "/api/stats" : `/api/stats/${id}`)
|
||||
.get<Stat[]>(!id ? "/api/stats" : `/api/stats/user/${id}`)
|
||||
.then((response) => setStats(response.data))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface ListeningPart {
|
||||
}
|
||||
|
||||
export interface UserSolution {
|
||||
id?: string;
|
||||
solutions: any[];
|
||||
module?: Module;
|
||||
exam?: string;
|
||||
@@ -91,6 +92,7 @@ export interface Evaluation {
|
||||
comment: string;
|
||||
overall: number;
|
||||
task_response: {[key: string]: number};
|
||||
misspelled_pairs?: {correction: string | null; misspelled: string}[];
|
||||
}
|
||||
|
||||
interface InteractiveSpeakingEvaluation extends Evaluation {
|
||||
@@ -101,6 +103,7 @@ interface InteractiveSpeakingEvaluation extends Evaluation {
|
||||
|
||||
interface CommonEvaluation extends Evaluation {
|
||||
perfect_answer?: string;
|
||||
perfect_answer_1?: string;
|
||||
}
|
||||
|
||||
export interface WritingExercise {
|
||||
|
||||
@@ -98,6 +98,7 @@ export const EMPLOYMENT_STATUS: {status: EmploymentStatus; label: string}[] = [
|
||||
];
|
||||
|
||||
export interface Stat {
|
||||
id: string;
|
||||
user: string;
|
||||
exam: string;
|
||||
exercise: string;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
23
src/pages/api/code/[id].ts
Normal file
23
src/pages/api/code/[id].ts
Normal 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});
|
||||
}
|
||||
@@ -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(
|
||||
{
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
23
src/pages/api/stats/[id].ts
Normal file
23
src/pages/api/stats/[id].ts
Normal 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});
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
29
src/pages/api/stats/user/[user].ts
Normal file
29
src/pages/api/stats/user/[user].ts
Normal 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(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Type} from "@/interfaces/user";
|
||||
import {Type, User, CorporateUser} from "@/interfaces/user";
|
||||
|
||||
export const USER_TYPE_LABELS: {[key in Type]: string} = {
|
||||
student: "Student",
|
||||
@@ -8,3 +8,7 @@ export const USER_TYPE_LABELS: {[key in Type]: string} = {
|
||||
admin: "Admin",
|
||||
developer: "Developer",
|
||||
};
|
||||
|
||||
export function isCorporateUser(user: User): user is CorporateUser {
|
||||
return (user as CorporateUser).corporateInformation !== undefined;
|
||||
}
|
||||
@@ -11,17 +11,19 @@ import {
|
||||
import axios from "axios";
|
||||
import {speakingReverseMarking, writingReverseMarking} from "./score";
|
||||
|
||||
export const evaluateWritingAnswer = async (exercise: WritingExercise, solution: UserSolution) => {
|
||||
export const evaluateWritingAnswer = async (exercise: WritingExercise, solution: UserSolution, id: string): Promise<object | undefined> => {
|
||||
const response = await axios.post<Evaluation>("/api/evaluate/writing", {
|
||||
question: `${exercise.prompt} ${exercise.attachment ? exercise.attachment.description : ""}`.replaceAll("\n", ""),
|
||||
answer: solution.solutions[0].solution.trim().replaceAll("\n", " "),
|
||||
id,
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
return {
|
||||
...solution,
|
||||
id,
|
||||
score: {
|
||||
correct: writingReverseMarking[response.data.overall] || 0,
|
||||
correct: response.data ? writingReverseMarking[response.data.overall] : 0,
|
||||
missing: 0,
|
||||
total: 100,
|
||||
},
|
||||
@@ -32,12 +34,12 @@ export const evaluateWritingAnswer = async (exercise: WritingExercise, solution:
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const evaluateSpeakingAnswer = async (exercise: SpeakingExercise | InteractiveSpeakingExercise, solution: UserSolution) => {
|
||||
export const evaluateSpeakingAnswer = async (exercise: SpeakingExercise | InteractiveSpeakingExercise, solution: UserSolution, id: string) => {
|
||||
switch (exercise?.type) {
|
||||
case "speaking":
|
||||
return await evaluateSpeakingExercise(exercise, exercise.id, solution);
|
||||
return {...(await evaluateSpeakingExercise(exercise, exercise.id, solution, id)), id};
|
||||
case "interactiveSpeaking":
|
||||
return await evaluateInteractiveSpeakingExercise(exercise.id, solution);
|
||||
return {...(await evaluateInteractiveSpeakingExercise(exercise.id, solution, id)), id};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
@@ -48,7 +50,7 @@ const downloadBlob = async (url: string): Promise<Buffer> => {
|
||||
return Buffer.from(blobResponse.data, "binary");
|
||||
};
|
||||
|
||||
const evaluateSpeakingExercise = async (exercise: SpeakingExercise, exerciseId: string, solution: UserSolution) => {
|
||||
const evaluateSpeakingExercise = async (exercise: SpeakingExercise, exerciseId: string, solution: UserSolution, id: string) => {
|
||||
const audioBlob = await downloadBlob(solution.solutions[0].solution.trim());
|
||||
const audioFile = new File([audioBlob], "audio.wav", {type: "audio/wav"});
|
||||
|
||||
@@ -58,6 +60,7 @@ const evaluateSpeakingExercise = async (exercise: SpeakingExercise, exerciseId:
|
||||
const evaluationQuestion =
|
||||
`${exercise.text.replaceAll("\n", "")}` + (exercise.prompts.length > 0 ? `You should talk about: ${exercise.prompts.join(", ")}` : "");
|
||||
formData.append("question", evaluationQuestion);
|
||||
formData.append("id", id);
|
||||
|
||||
const config = {
|
||||
headers: {
|
||||
@@ -71,18 +74,18 @@ const evaluateSpeakingExercise = async (exercise: SpeakingExercise, exerciseId:
|
||||
return {
|
||||
...solution,
|
||||
score: {
|
||||
correct: speakingReverseMarking[response.data.overall] || 0,
|
||||
correct: response.data ? speakingReverseMarking[response.data.overall] : 0,
|
||||
missing: 0,
|
||||
total: 100,
|
||||
},
|
||||
solutions: [{id: exerciseId, solution: response.data.fullPath, evaluation: response.data}],
|
||||
solutions: [{id: exerciseId, solution: response.data ? response.data.fullPath : null, evaluation: response.data}],
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const evaluateInteractiveSpeakingExercise = async (exerciseId: string, solution: UserSolution) => {
|
||||
const evaluateInteractiveSpeakingExercise = async (exerciseId: string, solution: UserSolution, id: string) => {
|
||||
const promiseParts = solution.solutions.map(async (x: {prompt: string; blob: string}) => ({
|
||||
question: x.prompt,
|
||||
answer: await downloadBlob(x.blob),
|
||||
@@ -98,6 +101,7 @@ const evaluateInteractiveSpeakingExercise = async (exerciseId: string, solution:
|
||||
formData.append(`question_${seed}`, question);
|
||||
formData.append(`answer_${seed}`, audioFile, `${seed}.wav`);
|
||||
});
|
||||
formData.append("id", id);
|
||||
|
||||
const config = {
|
||||
headers: {
|
||||
@@ -111,11 +115,11 @@ const evaluateInteractiveSpeakingExercise = async (exerciseId: string, solution:
|
||||
return {
|
||||
...solution,
|
||||
score: {
|
||||
correct: speakingReverseMarking[response.data.overall] || 0,
|
||||
correct: response.data ? speakingReverseMarking[response.data.overall] : 0,
|
||||
missing: 0,
|
||||
total: 100,
|
||||
},
|
||||
solutions: [{id: exerciseId, solution: response.data.answer, evaluation: response.data}],
|
||||
solutions: [{id: exerciseId, solution: response.data ? response.data.answer : null, evaluation: response.data}],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user