Compare commits
183 Commits
feature/co
...
feature/pa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b0f8c1c20 | ||
|
|
db2f5f2c0b | ||
|
|
0ed843125a | ||
|
|
14d19257df | ||
|
|
2cd18376f2 | ||
|
|
0694950bba | ||
|
|
c6b15eaca1 | ||
|
|
9ceb71ae2f | ||
|
|
957400cb82 | ||
|
|
e687a2b3e5 | ||
|
|
026730c077 | ||
|
|
35d1157b0c | ||
|
|
06dc92fdaa | ||
|
|
c9cac3539c | ||
|
|
d2276eba1d | ||
|
|
1c2c3fe402 | ||
|
|
d4b90b5fa4 | ||
|
|
383ddde7b5 | ||
|
|
e56636ca1f | ||
|
|
e0be2fd222 | ||
|
|
9e23e3e608 | ||
|
|
47ecc2be27 | ||
|
|
3ca0ad353e | ||
|
|
5447c89da4 | ||
|
|
c88757c869 | ||
|
|
8831729470 | ||
|
|
b3bb5a2337 | ||
|
|
b7ddee1db2 | ||
|
|
d85b9db535 | ||
|
|
d03d790327 | ||
|
|
79b159f948 | ||
|
|
3a0a9e1e99 | ||
|
|
cc2d0bf1b0 | ||
|
|
03a199983b | ||
|
|
a07e5a7312 | ||
|
|
fe5833b061 | ||
|
|
0c2200f49f | ||
|
|
cb73196503 | ||
|
|
c5fe405389 | ||
|
|
fddc3ff2f3 | ||
|
|
9dbe876d65 | ||
|
|
fd402bbd32 | ||
|
|
f2aa377cfe | ||
|
|
0f0223725e | ||
|
|
3ef29e43f5 | ||
|
|
60a7835040 | ||
|
|
1c645fcba2 | ||
|
|
938a5e9c7c | ||
|
|
cc655fed6c | ||
|
|
7f9692a3d9 | ||
|
|
cf90cae4eb | ||
|
|
fea8e0672e | ||
|
|
359748841f | ||
|
|
438778a03c | ||
|
|
c37bb2691b | ||
|
|
6c49409de8 | ||
|
|
2a335026de | ||
|
|
7712e5c71d | ||
|
|
861d97222a | ||
|
|
de862f635c | ||
|
|
ae058422aa | ||
|
|
44454d1e05 | ||
|
|
a2b9ba17a7 | ||
|
|
6f61fe1564 | ||
|
|
73d7ddc4af | ||
|
|
263f4afa82 | ||
|
|
45cf2dc279 | ||
|
|
786a425d85 | ||
|
|
d57223bd01 | ||
|
|
fbc2cff3f1 | ||
|
|
9ad4f077d1 | ||
|
|
e2b6061310 | ||
|
|
b77e97a9d2 | ||
|
|
67925c8a9e | ||
|
|
020ecff29c | ||
|
|
964660ed5d | ||
|
|
1390af62ab | ||
|
|
15947f942c | ||
|
|
7b3c3d15db | ||
|
|
1cff6fe242 | ||
|
|
4cbd045502 | ||
|
|
21b612eaa4 | ||
|
|
ef18e304a1 | ||
|
|
8e4223a9e7 | ||
|
|
7d696735ba | ||
|
|
e0ecc5be05 | ||
|
|
77af0b3495 | ||
|
|
e2e38284a7 | ||
|
|
ccd2560451 | ||
|
|
390658f2b0 | ||
|
|
450a4e9fe3 | ||
|
|
dfbbf0456d | ||
|
|
d46f92edb2 | ||
|
|
26c4368f31 | ||
|
|
ec56a5426b | ||
|
|
fe32584ff9 | ||
|
|
db7762c6e2 | ||
|
|
e70e26f84c | ||
|
|
7dc9d568d1 | ||
|
|
0049ab272b | ||
|
|
f48885bba6 | ||
|
|
5eaa0ac269 | ||
|
|
f7af21878e | ||
|
|
9d4071d4cd | ||
|
|
6f5dd86cd1 | ||
|
|
8b9537b272 | ||
|
|
a526e76c70 | ||
|
|
62b2f477f4 | ||
|
|
f36384fdb4 | ||
|
|
9c8d7988c5 | ||
|
|
18f163768c | ||
|
|
72083439af | ||
|
|
523149327b | ||
|
|
58c18133ec | ||
|
|
03520b650b | ||
|
|
556884058b | ||
|
|
73b0d5d41d | ||
|
|
7c589327f7 | ||
|
|
5c8867555d | ||
|
|
36be5267a2 | ||
|
|
4ebfd49cb9 | ||
|
|
96fe83de14 | ||
|
|
1746db3752 | ||
|
|
58b4883236 | ||
|
|
a3864eb7d3 | ||
|
|
1f0e5f4a08 | ||
|
|
c90234cefc | ||
|
|
f354a4f4fe | ||
|
|
7e0c071eee | ||
|
|
9bed726062 | ||
|
|
3878d4761e | ||
|
|
81f5af5629 | ||
|
|
5f76e430af | ||
|
|
facac33a89 | ||
|
|
f36c63f1b2 | ||
|
|
b1f07b877c | ||
|
|
70611305a7 | ||
|
|
fdedc2c5d3 | ||
|
|
75875b49e6 | ||
|
|
37e52886b5 | ||
|
|
a5dfe69220 | ||
|
|
1c36c7f1e1 | ||
|
|
9de39485de | ||
|
|
0fe2e0d393 | ||
|
|
dbb5e131fc | ||
|
|
ebda1e1717 | ||
|
|
8cbec131fe | ||
|
|
472d4a3331 | ||
|
|
c2f83d996a | ||
|
|
43bd6b24c5 | ||
|
|
ca89261e10 | ||
|
|
a9bbbe8b52 | ||
|
|
fa544bf4e8 | ||
|
|
7e91a989b3 | ||
|
|
c312260721 | ||
|
|
23f2bace5d | ||
|
|
7e2f1fcf9d | ||
|
|
6e420a8a82 | ||
|
|
cd81547022 | ||
|
|
a2baedb80c | ||
|
|
8072cefbe6 | ||
|
|
6bf666d01c | ||
|
|
7672e29063 | ||
|
|
51e7c535df | ||
|
|
d0f89cfe01 | ||
|
|
8de60aeb32 | ||
|
|
0e28473c31 | ||
|
|
52d4b831ae | ||
|
|
cdc8cfe46e | ||
|
|
4c7e8f56d8 | ||
|
|
4753b85ab5 | ||
|
|
13c8459d4b | ||
|
|
19b3bbe139 | ||
|
|
44a89c6645 | ||
|
|
4a51bd7dfa | ||
|
|
dc759a368e | ||
|
|
c28f7bb024 | ||
|
|
d412c1616f | ||
|
|
c2a807efc7 | ||
|
|
6056735c72 | ||
|
|
261ba74105 | ||
|
|
4328a1d72d | ||
|
|
82643b51d3 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -37,4 +37,5 @@ next-env.d.ts
|
||||
|
||||
.env
|
||||
.yarn/*
|
||||
.history*
|
||||
.history*
|
||||
__ENV.js
|
||||
28
.vscode/launch.json
vendored
Normal file
28
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Next.js: debug server-side",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "npm run dev"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug client-side",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:3000"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug full stack",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "npm run dev",
|
||||
"serverReadyAction": {
|
||||
"pattern": "- Local:.+(https?://.+)",
|
||||
"uriFormat": "%s",
|
||||
"action": "debugWithChrome"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
12
package.json
12
package.json
@@ -10,10 +10,13 @@
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"dependencies": {
|
||||
"@beam-australia/react-env": "^3.1.1",
|
||||
"@headlessui/react": "^1.7.13",
|
||||
"@mdi/js": "^7.1.96",
|
||||
"@mdi/react": "^1.6.1",
|
||||
"@next/font": "13.1.6",
|
||||
"@paypal/paypal-js": "^7.1.0",
|
||||
"@paypal/react-paypal-js": "^8.1.3",
|
||||
"@tanstack/react-table": "^8.10.1",
|
||||
"@types/node": "18.13.0",
|
||||
"@types/react": "18.0.27",
|
||||
@@ -24,6 +27,7 @@
|
||||
"clsx": "^1.2.1",
|
||||
"countries-list": "^3.0.1",
|
||||
"country-codes-list": "^1.6.11",
|
||||
"currency-symbol-map": "^5.1.0",
|
||||
"daisyui": "^3.1.5",
|
||||
"eslint": "8.33.0",
|
||||
"eslint-config-next": "13.1.6",
|
||||
@@ -33,6 +37,7 @@
|
||||
"formidable": "^3.5.0",
|
||||
"formidable-serverless": "^1.1.1",
|
||||
"framer-motion": "^9.0.2",
|
||||
"howler": "^2.2.4",
|
||||
"iron-session": "^6.3.1",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.4",
|
||||
@@ -44,6 +49,8 @@
|
||||
"random-words": "^2.0.0",
|
||||
"react": "18.2.0",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-csv": "^2.2.2",
|
||||
"react-currency-input-field": "^3.6.12",
|
||||
"react-datepicker": "^4.18.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-firebase-hooks": "^5.1.1",
|
||||
@@ -68,9 +75,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/formidable": "^3.4.0",
|
||||
"@types/howler": "^2.2.11",
|
||||
"@types/lodash": "^4.14.191",
|
||||
"@types/nodemailer": "^6.4.11",
|
||||
"@types/nodemailer-express-handlebars": "^4.0.3",
|
||||
"@types/react-csv": "^1.1.10",
|
||||
"@types/react-datepicker": "^4.15.1",
|
||||
"@types/uuid": "^9.0.1",
|
||||
"@types/wavesurfer.js": "^6.0.6",
|
||||
@@ -78,6 +87,7 @@
|
||||
"autoprefixer": "^10.4.13",
|
||||
"husky": "^8.0.3",
|
||||
"postcss": "^8.4.21",
|
||||
"tailwindcss": "^3.2.4"
|
||||
"tailwindcss": "^3.2.4",
|
||||
"types/": "paypal/react-paypal-js"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/audio/check.mp3
Normal file
BIN
public/audio/check.mp3
Normal file
Binary file not shown.
BIN
public/audio/sent.mp3
Normal file
BIN
public/audio/sent.mp3
Normal file
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 48 KiB |
@@ -12,16 +12,21 @@ import {KeyedMutator} from "swr";
|
||||
import CountrySelect from "./Low/CountrySelect";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
mutateUser: KeyedMutator<User>;
|
||||
}
|
||||
|
||||
export default function DemographicInformationInput({mutateUser}: Props) {
|
||||
export default function DemographicInformationInput({user, mutateUser}: Props) {
|
||||
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 [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [companyName, setCompanyName] = useState<string>();
|
||||
const [commercialRegistration, setCommercialRegistration] = useState<string>();
|
||||
|
||||
const save = (e?: FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
setIsLoading(true);
|
||||
@@ -32,8 +37,10 @@ export default function DemographicInformationInput({mutateUser}: Props) {
|
||||
country,
|
||||
phone: `+${countryCodes.findOne("countryCode" as any, country!).countryCallingCode}${phone}`,
|
||||
gender,
|
||||
employment,
|
||||
employment: user.type === "corporate" ? undefined : employment,
|
||||
position: user.type === "corporate" ? position : undefined,
|
||||
},
|
||||
agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined,
|
||||
})
|
||||
.then((response) => mutateUser((response.data as {user: User}).user))
|
||||
.catch(() => {
|
||||
@@ -53,6 +60,18 @@ export default function DemographicInformationInput({mutateUser}: Props) {
|
||||
about yourself.
|
||||
</h2>
|
||||
<form className="flex flex-col items-center justify-items-center gap-6 w-full h-full -md:px-4 lg:w-1/2 mb-32" onSubmit={save}>
|
||||
{user.type === "agent" && (
|
||||
<div className="w-full flex gap-8">
|
||||
<Input type="text" onChange={setCompanyName} name="companyName" label="Corporate Name" required />
|
||||
<Input
|
||||
type="text"
|
||||
onChange={setCommercialRegistration}
|
||||
name="commercialRegistration"
|
||||
label="Commercial Registration"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
||||
<CountrySelect value={country} onChange={setCountry} />
|
||||
@@ -99,25 +118,32 @@ export default function DemographicInformationInput({mutateUser}: Props) {
|
||||
</RadioGroup.Option>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<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-44 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>
|
||||
{user.type === "corporate" && (
|
||||
<Input name="position" onChange={setPosition} 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-44 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>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="self-end flex justify-end w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||
@@ -125,7 +151,14 @@ export default function DemographicInformationInput({mutateUser}: Props) {
|
||||
className="lg:mt-8 max-w-[400px] w-full self-end"
|
||||
color="purple"
|
||||
onClick={save}
|
||||
disabled={isLoading || !country || !phone || !gender || !employment}>
|
||||
disabled={
|
||||
isLoading ||
|
||||
!country ||
|
||||
!phone ||
|
||||
!gender ||
|
||||
(user.type === "corporate" ? !position : !employment) ||
|
||||
(user.type === "agent" ? !companyName || !commercialRegistration : false)
|
||||
}>
|
||||
{!isLoading && "Save information"}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import {BAND_SCORES} from "@/constants/ielts";
|
||||
import {Module} from "@/interfaces";
|
||||
import {User} from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
@@ -23,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();
|
||||
|
||||
@@ -52,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,
|
||||
})
|
||||
|
||||
@@ -11,7 +11,16 @@ const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mo
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function InteractiveSpeaking({id, title, text, type, prompts, onNext, onBack}: InteractiveSpeakingExercise & CommonProps) {
|
||||
export default function InteractiveSpeaking({
|
||||
id,
|
||||
title,
|
||||
text,
|
||||
type,
|
||||
prompts,
|
||||
updateIndex,
|
||||
onNext,
|
||||
onBack,
|
||||
}: InteractiveSpeakingExercise & CommonProps) {
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||
@@ -20,6 +29,10 @@ export default function InteractiveSpeaking({id, title, text, type, prompts, onN
|
||||
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
|
||||
useEffect(() => {
|
||||
if (updateIndex) updateIndex(promptIndex);
|
||||
}, [promptIndex, updateIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded) {
|
||||
onNext({
|
||||
|
||||
@@ -48,7 +48,16 @@ function Question({
|
||||
);
|
||||
}
|
||||
|
||||
export default function MultipleChoice({id, prompt, type, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
|
||||
export default function MultipleChoice({
|
||||
id,
|
||||
prompt,
|
||||
type,
|
||||
questions,
|
||||
userSolutions,
|
||||
updateIndex,
|
||||
onNext,
|
||||
onBack,
|
||||
}: MultipleChoiceExercise & CommonProps) {
|
||||
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
||||
const [questionIndex, setQuestionIndex] = useState(0);
|
||||
|
||||
@@ -59,6 +68,10 @@ export default function MultipleChoice({id, prompt, type, questions, userSolutio
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasExamEnded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (updateIndex) updateIndex(questionIndex);
|
||||
}, [questionIndex, updateIndex]);
|
||||
|
||||
const onSelectOption = (option: string) => {
|
||||
const question = questions[questionIndex];
|
||||
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);
|
||||
|
||||
@@ -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,22 +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>
|
||||
{prefix.split("\\n").map((line, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{line}
|
||||
<br />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
<span className="font-semibold">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
<p>{line}</p>
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
</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)}
|
||||
@@ -120,14 +108,7 @@ export default function Writing({
|
||||
</div>
|
||||
|
||||
<div className="w-full h-full flex flex-col gap-4">
|
||||
<span>
|
||||
{suffix.split("\\n").map((line, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{line}
|
||||
<br />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
<span className="whitespace-pre-wrap">{suffix}</span>
|
||||
<textarea
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
className="w-full h-full min-h-[300px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
|
||||
|
||||
@@ -22,11 +22,17 @@ import InteractiveSpeaking from "./InteractiveSpeaking";
|
||||
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false});
|
||||
|
||||
export interface CommonProps {
|
||||
updateIndex?: (internalIndex: number) => void;
|
||||
onNext: (userSolutions: UserSolution) => void;
|
||||
onBack: (userSolutions: UserSolution) => void;
|
||||
}
|
||||
|
||||
export const renderExercise = (exercise: Exercise, onNext: (userSolutions: UserSolution) => void, onBack: (userSolutions: UserSolution) => void) => {
|
||||
export const renderExercise = (
|
||||
exercise: Exercise,
|
||||
onNext: (userSolutions: UserSolution) => void,
|
||||
onBack: (userSolutions: UserSolution) => void,
|
||||
updateIndex?: (internalIndex: number) => void,
|
||||
) => {
|
||||
switch (exercise.type) {
|
||||
case "fillBlanks":
|
||||
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||
@@ -35,7 +41,15 @@ export const renderExercise = (exercise: Exercise, onNext: (userSolutions: UserS
|
||||
case "matchSentences":
|
||||
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "multipleChoice":
|
||||
return <MultipleChoice key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return (
|
||||
<MultipleChoice
|
||||
key={exercise.id}
|
||||
{...(exercise as MultipleChoiceExercise)}
|
||||
updateIndex={updateIndex}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
case "writeBlanks":
|
||||
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "writing":
|
||||
@@ -43,6 +57,14 @@ export const renderExercise = (exercise: Exercise, onNext: (userSolutions: UserS
|
||||
case "speaking":
|
||||
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "interactiveSpeaking":
|
||||
return <InteractiveSpeaking key={exercise.id} {...(exercise as InteractiveSpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return (
|
||||
<InteractiveSpeaking
|
||||
key={exercise.id}
|
||||
{...(exercise as InteractiveSpeakingExercise)}
|
||||
updateIndex={updateIndex}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function Layout({user, children, className, navDisabled = false,
|
||||
focusMode={focusMode}
|
||||
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
|
||||
className="-md:hidden"
|
||||
showAdmin={user.type !== "student"}
|
||||
userType={user.type}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
|
||||
30
src/components/Low/Badge.tsx
Normal file
30
src/components/Low/Badge.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import {Module} from "@/interfaces";
|
||||
import clsx from "clsx";
|
||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||
|
||||
interface Props {
|
||||
module: Module;
|
||||
children: string;
|
||||
}
|
||||
|
||||
export default function Badge({module, children}: Props) {
|
||||
return (
|
||||
<div
|
||||
key={module}
|
||||
className={clsx(
|
||||
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl",
|
||||
module === "reading" && "bg-ielts-reading",
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}>
|
||||
{module === "reading" && <BsBook className="w-4 h-4" />}
|
||||
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
||||
{module === "writing" && <BsPen className="w-4 h-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
||||
{module === "level" && <BsClipboard className="w-4 h-4" />}
|
||||
<span className="text-sm">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,11 +6,15 @@ interface Props {
|
||||
isChecked: boolean;
|
||||
onChange: (isChecked: boolean) => void;
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function Checkbox({isChecked, onChange, children}: Props) {
|
||||
export default function Checkbox({isChecked, onChange, children, disabled}: Props) {
|
||||
return (
|
||||
<div className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer" onClick={() => onChange(!isChecked)}>
|
||||
<div className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer" onClick={() => {
|
||||
if(disabled) return;
|
||||
onChange(!isChecked);
|
||||
}}>
|
||||
<input type="checkbox" className="hidden" />
|
||||
<div
|
||||
className={clsx(
|
||||
|
||||
@@ -3,17 +3,31 @@ import {useState} from "react";
|
||||
|
||||
interface Props {
|
||||
type: "email" | "text" | "password" | "tel" | "number";
|
||||
roundness?: "full" | "xl";
|
||||
required?: boolean;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string | number;
|
||||
value?: string | number;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
name: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function Input({type, label, placeholder, name, required = false, defaultValue, className, disabled = false, onChange}: Props) {
|
||||
export default function Input({
|
||||
type,
|
||||
label,
|
||||
placeholder,
|
||||
name,
|
||||
required = false,
|
||||
value,
|
||||
defaultValue,
|
||||
className,
|
||||
roundness = "full",
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: Props) {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
if (type === "password") {
|
||||
@@ -57,9 +71,15 @@ export default function Input({type, label, placeholder, name, required = false,
|
||||
type={type}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
min={type === "number" ? 0 : undefined}
|
||||
placeholder={placeholder}
|
||||
className="px-8 py-6 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={clsx(
|
||||
"px-8 py-6 text-sm font-normal bg-white border border-mti-gray-platinum focus:outline-none",
|
||||
"placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed",
|
||||
roundness === "full" ? "rounded-full" : "rounded-xl",
|
||||
)}
|
||||
required={required}
|
||||
defaultValue={defaultValue}
|
||||
/>
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function ProgressBar({label, percentage, color, useColor = false,
|
||||
listening: "bg-ielts-listening",
|
||||
writing: "bg-ielts-writing",
|
||||
speaking: "bg-ielts-speaking",
|
||||
level: "bg-ielts-level",
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,7 +4,7 @@ import {moduleLabels} from "@/utils/moduleUtils";
|
||||
import clsx from "clsx";
|
||||
import {motion} from "framer-motion";
|
||||
import {ReactNode, useEffect, useState} from "react";
|
||||
import {BsBook, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
|
||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
|
||||
import ProgressBar from "../Low/ProgressBar";
|
||||
import TimerEndedModal from "../TimerEndedModal";
|
||||
|
||||
@@ -46,6 +46,7 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
|
||||
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,
|
||||
writing: <BsPen className="text-ielts-writing w-6 h-6" />,
|
||||
speaking: <BsMegaphone className="text-ielts-speaking w-6 h-6" />,
|
||||
level: <BsClipboard className="text-ielts-level w-6 h-6" />,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -66,22 +66,27 @@ export default function MobileMenu({isOpen, onClose, path, user}: Props) {
|
||||
)}>
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
href="/exam"
|
||||
className={clsx(
|
||||
"transition ease-in-out duration-300 w-fit",
|
||||
path === "/exam" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
||||
)}>
|
||||
Exams
|
||||
</Link>
|
||||
<Link
|
||||
href="/exercises"
|
||||
className={clsx(
|
||||
"transition ease-in-out duration-300 w-fit",
|
||||
path === "/exercises" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
||||
)}>
|
||||
Exercises
|
||||
</Link>
|
||||
{(user.type === "student" || user.type === "teacher" || user.type === "developer") && (
|
||||
<>
|
||||
<Link
|
||||
href="/exam"
|
||||
className={clsx(
|
||||
"transition ease-in-out duration-300 w-fit",
|
||||
path === "/exam" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
||||
)}>
|
||||
Exams
|
||||
</Link>
|
||||
<Link
|
||||
href="/exercises"
|
||||
className={clsx(
|
||||
"transition ease-in-out duration-300 w-fit",
|
||||
path === "/exercises" &&
|
||||
"text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
||||
)}>
|
||||
Exercises
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<Link
|
||||
href="/stats"
|
||||
className={clsx(
|
||||
@@ -98,14 +103,25 @@ export default function MobileMenu({isOpen, onClose, path, user}: Props) {
|
||||
)}>
|
||||
Record
|
||||
</Link>
|
||||
{user.type !== "student" && (
|
||||
{["admin", "developer", "agent", "corporate"].includes(user.type) && (
|
||||
<Link
|
||||
href="/admin"
|
||||
href="/payment-record"
|
||||
className={clsx(
|
||||
"transition ease-in-out duration-300 w-fit",
|
||||
path === "/admin" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
||||
path === "/payment-record" &&
|
||||
"text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
||||
)}>
|
||||
Admin
|
||||
Payment Record
|
||||
</Link>
|
||||
)}
|
||||
{["admin", "developer", "corporate", "teacher"].includes(user.type) && (
|
||||
<Link
|
||||
href="/settings"
|
||||
className={clsx(
|
||||
"transition ease-in-out duration-300 w-fit",
|
||||
path === "/settings" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
||||
)}>
|
||||
Settings
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
|
||||
@@ -8,6 +8,8 @@ import clsx from "clsx";
|
||||
import moment from "moment";
|
||||
import MobileMenu from "./MobileMenu";
|
||||
import {useState} from "react";
|
||||
import {Type} from "@/interfaces/user";
|
||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
@@ -53,7 +55,7 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
|
||||
<div className="flex justify-end -md:items-center gap-4 md:w-5/6 md:mr-8">
|
||||
{showExpirationDate() && (
|
||||
<Link
|
||||
href="https://encoach.com/join"
|
||||
href="/payment"
|
||||
data-tip="Expiry date"
|
||||
className={clsx(
|
||||
"py-2 px-6 w-fit flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
@@ -69,7 +71,9 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
|
||||
)}
|
||||
<Link href={disableNavigation ? "" : "/profile"} className="flex gap-6 items-center justify-end -md:hidden">
|
||||
<img src={user.profilePicture} alt={user.name} className="w-10 h-10 rounded-full object-cover" />
|
||||
<span className="text-right -md:hidden">{user.name}</span>
|
||||
<span className="text-right -md:hidden">
|
||||
{user.name} | {USER_TYPE_LABELS[user.type]}
|
||||
</span>
|
||||
</Link>
|
||||
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}>
|
||||
<BsList className="text-mti-purple-light w-8 h-8" />
|
||||
|
||||
66
src/components/PayPalPayment.tsx
Normal file
66
src/components/PayPalPayment.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import {DurationUnit} from "@/interfaces/paypal";
|
||||
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OnCancelledActions, OrderResponseBody} from "@paypal/paypal-js";
|
||||
import {PayPalButtons, PayPalScriptProvider, usePayPalScriptReducer} from "@paypal/react-paypal-js";
|
||||
import axios from "axios";
|
||||
import {useEffect, useState} from "react";
|
||||
import {toast} from "react-toastify";
|
||||
|
||||
interface Props {
|
||||
clientID: string;
|
||||
currency: string;
|
||||
price: number;
|
||||
duration: number;
|
||||
duration_unit: DurationUnit;
|
||||
setIsLoading: (isLoading: boolean) => void;
|
||||
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
|
||||
}
|
||||
|
||||
export default function PayPalPayment({clientID, price, currency, duration, duration_unit, setIsLoading, onSuccess}: Props) {
|
||||
const createOrder = async (data: CreateOrderData, actions: CreateOrderActions): Promise<string> => {
|
||||
setIsLoading(true);
|
||||
|
||||
return axios
|
||||
.post<OrderResponseBody>("/api/paypal", {currencyCode: currency, price})
|
||||
.then((response) => response.data)
|
||||
.then((data) => data.id);
|
||||
};
|
||||
|
||||
const onApprove = async (data: OnApproveData, actions: OnApproveActions) => {
|
||||
const request = await axios.post<{ok: boolean; reason?: string}>("/api/paypal/approve", {id: data.orderID, duration, duration_unit});
|
||||
|
||||
if (request.status !== 200) {
|
||||
toast.error("Something went wrong, please try again later");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Your account has been credited more time!");
|
||||
return onSuccess(duration, duration_unit);
|
||||
};
|
||||
|
||||
const onError = async (data: Record<string, unknown>) => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const onCancel = async (data: Record<string, unknown>, actions: OnCancelledActions) => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<PayPalScriptProvider
|
||||
options={{
|
||||
clientId: clientID,
|
||||
currency,
|
||||
intent: "capture",
|
||||
commit: true,
|
||||
vault: true,
|
||||
}}>
|
||||
<PayPalButtons
|
||||
className="w-full"
|
||||
style={{layout: "vertical"}}
|
||||
createOrder={createOrder}
|
||||
onApprove={onApprove}
|
||||
onCancel={onCancel}
|
||||
onError={onError}></PayPalButtons>
|
||||
</PayPalScriptProvider>
|
||||
);
|
||||
}
|
||||
150
src/components/PaymentAssetManager.tsx
Normal file
150
src/components/PaymentAssetManager.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
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;
|
||||
}
|
||||
|
||||
const PaymentAssetManager = (props: {
|
||||
asset: string | undefined;
|
||||
permissions: "read" | "write";
|
||||
type: FilesStorage;
|
||||
reload: () => void;
|
||||
paymentId: string;
|
||||
canEdit: boolean;
|
||||
}) => {
|
||||
const {asset, permissions, type, paymentId} = props;
|
||||
|
||||
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 {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;
|
||||
}
|
||||
|
||||
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 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);
|
||||
|
||||
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);
|
||||
})
|
||||
.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;
|
||||
}
|
||||
|
||||
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 (file) {
|
||||
if (complete) {
|
||||
return (
|
||||
<>
|
||||
<BsDownload onClick={downloadAsset} />
|
||||
{props.canEdit && (
|
||||
<>
|
||||
<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 props.canEdit ? (
|
||||
<>
|
||||
<BsUpload onClick={() => fileInputRef.current?.click()} />
|
||||
{renderFileInput((e: Event) => handleFileChange(e, "post"), fileInputRef)}
|
||||
</>
|
||||
) : (
|
||||
<BsXCircleFill />
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentAssetManager;
|
||||
@@ -1,31 +0,0 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {User} from "@/interfaces/user";
|
||||
import clsx from "clsx";
|
||||
import LevelLabel from "./LevelLabel";
|
||||
import LevelProgressBar from "./LevelProgressBar";
|
||||
import {Avatar} from "primereact/avatar";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
className: string;
|
||||
}
|
||||
|
||||
export default function ProfileCard({user, className}: Props) {
|
||||
return (
|
||||
<div className={clsx("bg-white drop-shadow-xl p-4 md:p-8 rounded-xl w-full flex flex-col gap-6", className)}>
|
||||
<div className="flex w-full items-center gap-8">
|
||||
<div className="w-16 md:w-24 h-16 md:h-24 rounded-full border-2 md:border-4 border-white drop-shadow-md md:drop-shadow-xl">
|
||||
{user.profilePicture.length > 0 && <img src={user.profilePicture} alt="Profile picture" className="rounded-full object-cover" />}
|
||||
{user.profilePicture.length === 0 && (
|
||||
<Avatar size="xlarge" style={{width: "100%", height: "100%"}} label={user.name.slice(0, 1)} shape="circle" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col justify-center">
|
||||
<span className="text-neutral-600 font-bold text-xl lg:text-2xl">{user.name}</span>
|
||||
<LevelLabel experience={user.experience} />
|
||||
</div>
|
||||
</div>
|
||||
<LevelProgressBar experience={user.experience} progressBarWidth="w-32 md:w-96" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {User} from "@/interfaces/user";
|
||||
import {levelCalculator} from "@/resources/level";
|
||||
import clsx from "clsx";
|
||||
import LevelLabel from "./LevelLabel";
|
||||
import LevelProgressBar from "./LevelProgressBar";
|
||||
import {Avatar} from "primereact/avatar";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ProfileLevel({user, className}: Props) {
|
||||
const levelResult = levelCalculator(user.experience);
|
||||
|
||||
return (
|
||||
<div className={clsx("flex flex-col items-center justify-center gap-4", className)}>
|
||||
<div className="w-16 md:w-24 h-16 md:h-24 rounded-full">
|
||||
{user.profilePicture.length > 0 && <img src={user.profilePicture} alt="Profile picture" className="rounded-full object-cover" />}
|
||||
{user.profilePicture.length === 0 && (
|
||||
<Avatar size="xlarge" style={{width: "100%", height: "100%"}} label={user.name.slice(0, 1)} shape="circle" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 items-center">
|
||||
<LevelLabel experience={user.experience} />
|
||||
<LevelProgressBar experience={user.experience} className="text-black" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {User} from "@/interfaces/user";
|
||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||
import {calculateAverageLevel} from "@/utils/score";
|
||||
import {capitalize} from "lodash";
|
||||
import {ReactElement} from "react";
|
||||
@@ -28,7 +29,7 @@ export default function ProfileSummary({user, items}: Props) {
|
||||
<div className="flex -md:flex-col justify-between w-full gap-8">
|
||||
<div className="flex flex-col gap-2 py-2">
|
||||
<h1 className="font-bold text-2xl md:text-4xl">{user.name}</h1>
|
||||
<h6 className="font-normal text-base text-mti-gray-taupe">{capitalize(user.type)}</h6>
|
||||
<h6 className="font-normal text-base text-mti-gray-taupe">{USER_TYPE_LABELS[user.type]}</h6>
|
||||
</div>
|
||||
<ProgressBar
|
||||
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import clsx from "clsx";
|
||||
import {IconType} from "react-icons";
|
||||
import {MdSpaceDashboard} from "react-icons/md";
|
||||
import {BsFileEarmarkText, BsClockHistory, BsPencil, BsGraphUp, BsChevronBarRight, BsChevronBarLeft, BsShieldFill} from "react-icons/bs";
|
||||
import {
|
||||
BsFileEarmarkText,
|
||||
BsClockHistory,
|
||||
BsPencil,
|
||||
BsGraphUp,
|
||||
BsChevronBarRight,
|
||||
BsChevronBarLeft,
|
||||
BsShieldFill,
|
||||
BsCloudFill,
|
||||
BsCurrencyDollar,
|
||||
} from "react-icons/bs";
|
||||
import {RiLogoutBoxFill} from "react-icons/ri";
|
||||
import {SlPencil} from "react-icons/sl";
|
||||
import {FaAward} from "react-icons/fa";
|
||||
@@ -12,13 +22,14 @@ import FocusLayer from "@/components/FocusLayer";
|
||||
import {preventNavigation} from "@/utils/navigation.disabled";
|
||||
import {useState} from "react";
|
||||
import usePreferencesStore from "@/stores/preferencesStore";
|
||||
import {Type} from "@/interfaces/user";
|
||||
interface Props {
|
||||
path: string;
|
||||
navDisabled?: boolean;
|
||||
focusMode?: boolean;
|
||||
onFocusLayerMouseEnter?: () => void;
|
||||
className?: string;
|
||||
showAdmin?: boolean;
|
||||
userType?: Type;
|
||||
}
|
||||
|
||||
interface NavProps {
|
||||
@@ -44,7 +55,7 @@ const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false}
|
||||
</Link>
|
||||
);
|
||||
|
||||
export default function Sidebar({path, navDisabled = false, focusMode = false, showAdmin = false, onFocusLayerMouseEnter, className}: Props) {
|
||||
export default function Sidebar({path, navDisabled = false, focusMode = false, userType, onFocusLayerMouseEnter, className}: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
|
||||
@@ -66,12 +77,57 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, s
|
||||
)}>
|
||||
<div className="xl:flex -xl:hidden flex-col gap-3">
|
||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
|
||||
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={isMinimized} />
|
||||
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={isMinimized} />
|
||||
{(userType === "student" || userType === "teacher" || userType === "developer") && (
|
||||
<>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsFileEarmarkText}
|
||||
label="Exams"
|
||||
path={path}
|
||||
keyPath="/exam"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsPencil}
|
||||
label="Exercises"
|
||||
path={path}
|
||||
keyPath="/exercises"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<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} />
|
||||
{showAdmin && (
|
||||
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Admin" path={path} keyPath="/admin" isMinimized={isMinimized} />
|
||||
{["admin", "developer", "agent", "corporate"].includes(userType || "") && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCurrencyDollar}
|
||||
label="Payment Record"
|
||||
path={path}
|
||||
keyPath="/payment-record"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{["admin", "developer", "corporate", "teacher"].includes(userType || "") && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsShieldFill}
|
||||
label="Settings"
|
||||
path={path}
|
||||
keyPath="/settings"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{userType === "developer" && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCloudFill}
|
||||
label="Generation"
|
||||
path={path}
|
||||
keyPath="/generation"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="xl:hidden -xl:flex flex-col gap-3">
|
||||
@@ -80,10 +136,15 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, s
|
||||
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
|
||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
|
||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
|
||||
{showAdmin && <Nav disabled={disableNavigation} Icon={BsShieldFill} label="Admin" path={path} keyPath="/admin" isMinimized={true} />}
|
||||
{userType !== "student" && (
|
||||
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized={true} />
|
||||
)}
|
||||
{userType === "developer" && (
|
||||
<Nav disabled={disableNavigation} Icon={BsCloudFill} label="Generation" path={path} keyPath="/generation" isMinimized={true} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-0 absolute bottom-12">
|
||||
<div className="flex flex-col gap-0 bottom-12 fixed">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={1}
|
||||
|
||||
@@ -6,6 +6,8 @@ import Button from "../Low/Button";
|
||||
import dynamic from "next/dynamic";
|
||||
import axios from "axios";
|
||||
import {speakingReverseMarking} from "@/utils/score";
|
||||
import {Tab} from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||
|
||||
@@ -47,7 +49,7 @@ export default function InteractiveSpeaking({
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="font-bold">You should talk about the following things:</span>
|
||||
<div className="grid grid-cols-3 gap-6 text-center">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 text-center">
|
||||
{prompts.map((x, index) => (
|
||||
<div className="italic flex flex-col gap-2 text-sm" key={index}>
|
||||
<video key={index} controls className="">
|
||||
@@ -61,11 +63,11 @@ export default function InteractiveSpeaking({
|
||||
</div>
|
||||
|
||||
<div className="w-full h-full flex flex-col gap-8">
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{solutionsURL.map((x, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="w-full min-w-[460px] p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
||||
className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
||||
<div className="flex gap-8 items-center justify-center py-8">
|
||||
<Waveform audio={x} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||
</div>
|
||||
@@ -73,7 +75,7 @@ export default function InteractiveSpeaking({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{userSolutions && userSolutions.length > 0 && (
|
||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex gap-4 px-1">
|
||||
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
|
||||
@@ -82,9 +84,81 @@ export default function InteractiveSpeaking({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-mti-gray-smoke rounded-3xl">
|
||||
{userSolutions[0].evaluation!.comment}
|
||||
</div>
|
||||
{userSolutions[0].evaluation &&
|
||||
Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length === 3 ? (
|
||||
<Tab.Group>
|
||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||
)
|
||||
}>
|
||||
Evaluation
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||
)
|
||||
}>
|
||||
Recommended Answer (Prompt 1)
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||
)
|
||||
}>
|
||||
Recommended Answer (Prompt 2)
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||
)
|
||||
}>
|
||||
Recommended Answer (Prompt 3)
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<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">{userSolutions[0].evaluation!.comment}</span>
|
||||
</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_1!.replaceAll(/\s{2,}/g, "\n\n")}
|
||||
</span>
|
||||
</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_2!.replaceAll(/\s{2,}/g, "\n\n")}
|
||||
</span>
|
||||
</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_3!.replaceAll(/\s{2,}/g, "\n\n")}
|
||||
</span>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
) : (
|
||||
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-ielts-speaking/10 rounded-3xl">
|
||||
{userSolutions[0].evaluation!.comment}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
|
||||
import clsx from "clsx";
|
||||
import {useState} from "react";
|
||||
import {useEffect, useState} from "react";
|
||||
import {CommonProps} from ".";
|
||||
import Button from "../Low/Button";
|
||||
|
||||
@@ -54,7 +54,16 @@ function Question({
|
||||
);
|
||||
}
|
||||
|
||||
export default function MultipleChoice({id, type, prompt, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
|
||||
export default function MultipleChoice({
|
||||
id,
|
||||
type,
|
||||
prompt,
|
||||
questions,
|
||||
userSolutions,
|
||||
updateIndex,
|
||||
onNext,
|
||||
onBack,
|
||||
}: MultipleChoiceExercise & CommonProps) {
|
||||
const [questionIndex, setQuestionIndex] = useState(0);
|
||||
|
||||
const calculateScore = () => {
|
||||
@@ -67,6 +76,10 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
|
||||
return {total, correct, missing};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (updateIndex) updateIndex(questionIndex);
|
||||
}, [questionIndex, updateIndex]);
|
||||
|
||||
const next = () => {
|
||||
if (questionIndex === questions.length - 1) {
|
||||
onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
|
||||
|
||||
@@ -6,6 +6,8 @@ import Button from "../Low/Button";
|
||||
import dynamic from "next/dynamic";
|
||||
import axios from "axios";
|
||||
import {speakingReverseMarking} from "@/utils/score";
|
||||
import {Tab} from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||
|
||||
@@ -69,7 +71,7 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
||||
{solutionURL && <Waveform audio={solutionURL} waveColor="#FCDDEC" progressColor="#EF5DA8" />}
|
||||
</div>
|
||||
</div>
|
||||
{userSolutions && userSolutions.length > 0 && (
|
||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex gap-4 px-1">
|
||||
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
|
||||
@@ -78,9 +80,52 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-mti-gray-smoke rounded-3xl">
|
||||
{userSolutions[0].evaluation!.comment}
|
||||
</div>
|
||||
{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
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||
)
|
||||
}>
|
||||
Evaluation
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||
)
|
||||
}>
|
||||
Recommended Answer
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<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">{userSolutions[0].evaluation!.comment}</span>
|
||||
</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 &&
|
||||
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>
|
||||
</Tab.Group>
|
||||
) : (
|
||||
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-ielts-speaking/10 rounded-3xl">
|
||||
{userSolutions[0].evaluation!.comment}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import {WritingExercise} from "@/interfaces/exam";
|
||||
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import clsx from "clsx";
|
||||
import {CommonProps} from ".";
|
||||
import {Fragment, useEffect, useState} from "react";
|
||||
import {toast} from "react-toastify";
|
||||
import {Fragment, useState} from "react";
|
||||
import Button from "../Low/Button";
|
||||
import {Dialog, Transition} from "@headlessui/react";
|
||||
import {Dialog, Tab, Transition} from "@headlessui/react";
|
||||
import {writingReverseMarking} from "@/utils/score";
|
||||
import clsx from "clsx";
|
||||
|
||||
export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
@@ -79,7 +75,7 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{userSolutions && userSolutions.length > 0 && (
|
||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex gap-4 px-1">
|
||||
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
|
||||
@@ -88,9 +84,48 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-mti-gray-smoke rounded-3xl">
|
||||
{userSolutions[0].evaluation!.comment}
|
||||
</div>
|
||||
{userSolutions[0].evaluation && userSolutions[0].evaluation.perfect_answer ? (
|
||||
<Tab.Group>
|
||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1">
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
|
||||
)
|
||||
}>
|
||||
Evaluation
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
|
||||
)
|
||||
}>
|
||||
Recommended Answer
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<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">{userSolutions[0].evaluation!.comment}</span>
|
||||
</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")}
|
||||
</span>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
) : (
|
||||
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-ielts-writing/10 rounded-3xl">
|
||||
{userSolutions[0].evaluation!.comment}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -22,11 +22,12 @@ import Writing from "./Writing";
|
||||
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), {ssr: false});
|
||||
|
||||
export interface CommonProps {
|
||||
updateIndex?: (internalIndex: number) => void;
|
||||
onNext: (userSolutions: UserSolution) => void;
|
||||
onBack: (userSolutions: UserSolution) => void;
|
||||
}
|
||||
|
||||
export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: () => void) => {
|
||||
export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: () => void, updateIndex?: (internalIndex: number) => void) => {
|
||||
switch (exercise.type) {
|
||||
case "fillBlanks":
|
||||
return <FillBlanks {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||
@@ -35,7 +36,7 @@ export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: (
|
||||
case "matchSentences":
|
||||
return <MatchSentences {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "multipleChoice":
|
||||
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} updateIndex={updateIndex} onNext={onNext} onBack={onBack} />;
|
||||
case "writeBlanks":
|
||||
return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "writing":
|
||||
|
||||
@@ -6,7 +6,7 @@ import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import moment from "moment";
|
||||
import {Divider} from "primereact/divider";
|
||||
import {useState} from "react";
|
||||
import {useEffect, useState} from "react";
|
||||
import ReactDatePicker from "react-datepicker";
|
||||
import {BsFileEarmarkText, BsPencil, BsStar} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
@@ -15,6 +15,10 @@ import Checkbox from "./Low/Checkbox";
|
||||
import CountrySelect from "./Low/CountrySelect";
|
||||
import Input from "./Low/Input";
|
||||
import ProfileSummary from "./ProfileSummary";
|
||||
import Select from "react-select";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||
import {CURRENCIES} from "@/resources/paypal";
|
||||
|
||||
const expirationDateColor = (date: Date) => {
|
||||
const momentDate = moment(date);
|
||||
@@ -27,20 +31,107 @@ const expirationDateColor = (date: Date) => {
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
loggedInUser: User;
|
||||
onClose: (reload?: boolean) => void;
|
||||
onViewStudents?: () => void;
|
||||
onViewTeachers?: () => void;
|
||||
onViewCorporate?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const UserCard = ({user, onClose, onViewStudents, onViewTeachers}: Props) => {
|
||||
const USER_STATUS_OPTIONS = [
|
||||
{
|
||||
value: 'active',
|
||||
label: 'Active',
|
||||
}, {
|
||||
value: 'disabled',
|
||||
label: 'Disabled',
|
||||
}, {
|
||||
value: 'paymentDue',
|
||||
label: 'Payment Due',
|
||||
}
|
||||
];
|
||||
|
||||
const USER_TYPE_OPTIONS = Object.keys(USER_TYPE_LABELS).map((type) => ({
|
||||
value: type,
|
||||
label: USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]
|
||||
}));
|
||||
|
||||
const CURRENCIES_OPTIONS = CURRENCIES.map(({ label, currency}) => ({ value: currency, label }));
|
||||
|
||||
const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers, onViewCorporate, disabled = false}: Props) => {
|
||||
const [expiryDate, setExpiryDate] = useState<Date | null | undefined>(user.subscriptionExpirationDate);
|
||||
const [type, setType] = useState(user.type);
|
||||
const [status, setStatus] = useState(user.status);
|
||||
const [referralAgentLabel, setReferralAgentLabel] = useState<string>();
|
||||
const [position, setPosition] = useState<string | undefined>(user.type === "corporate" ? user.demographicInformation?.position : undefined);
|
||||
|
||||
const [referralAgent, setReferralAgent] = useState(user.type === "corporate" ? user.corporateInformation?.referralAgent : undefined);
|
||||
const [companyName, setCompanyName] = useState(
|
||||
user.type === "corporate"
|
||||
? user.corporateInformation?.companyInformation.name
|
||||
: user.type === "agent"
|
||||
? user.agentInformation.companyName
|
||||
: undefined,
|
||||
);
|
||||
const [commercialRegistration, setCommercialRegistration] = useState(
|
||||
user.type === "agent" ? user.agentInformation.commercialRegistration : undefined,
|
||||
);
|
||||
const [userAmount, setUserAmount] = useState(user.type === "corporate" ? user.corporateInformation?.companyInformation.userAmount : undefined);
|
||||
const [paymentValue, setPaymentValue] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.value : undefined);
|
||||
const [paymentCurrency, setPaymentCurrency] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.currency : "EUR");
|
||||
const [monthlyDuration, setMonthlyDuration] = useState(user.type === "corporate" ? user.corporateInformation?.monthlyDuration : undefined);
|
||||
const [commissionValue, setCommission] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.commission : undefined);
|
||||
const {stats} = useStats(user.id);
|
||||
const {users} = useUsers();
|
||||
|
||||
useEffect(() => {
|
||||
if (users && users.length > 0) {
|
||||
if (!referralAgent) {
|
||||
setReferralAgentLabel("No manager");
|
||||
return;
|
||||
}
|
||||
|
||||
const agent = users.find((x) => x.id === referralAgent);
|
||||
setReferralAgentLabel(`${agent?.name} - ${agent?.email}`);
|
||||
}
|
||||
}, [users, referralAgent]);
|
||||
|
||||
const updateUser = () => {
|
||||
if (user.type === "corporate" && (!paymentValue || paymentValue < 0))
|
||||
return toast.error("Please set a price for the user's package before updating!");
|
||||
if (!confirm(`Are you sure you want to update ${user.name}'s account?`)) return;
|
||||
|
||||
axios
|
||||
.post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {...user, subscriptionExpirationDate: expiryDate})
|
||||
.post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {
|
||||
...user,
|
||||
subscriptionExpirationDate: expiryDate,
|
||||
type,
|
||||
status,
|
||||
agentInformation:
|
||||
type === "agent"
|
||||
? {
|
||||
name: companyName,
|
||||
commercialRegistration,
|
||||
}
|
||||
: undefined,
|
||||
corporateInformation:
|
||||
type === "corporate"
|
||||
? {
|
||||
referralAgent,
|
||||
monthlyDuration,
|
||||
companyInformation: {
|
||||
name: companyName,
|
||||
userAmount,
|
||||
},
|
||||
payment: {
|
||||
value: paymentValue,
|
||||
currency: paymentCurrency,
|
||||
...(referralAgent === "" ? {} : {commission: commissionValue}),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("User updated successfully!");
|
||||
onClose(true);
|
||||
@@ -73,6 +164,156 @@ const UserCard = ({user, onClose, onViewStudents, onViewTeachers}: Props) => {
|
||||
]}
|
||||
/>
|
||||
|
||||
{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={setCompanyName}
|
||||
placeholder="Enter corporate name"
|
||||
defaultValue={companyName}
|
||||
required
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
label="Commercial Registration"
|
||||
type="text"
|
||||
name="commercialRegistration"
|
||||
onChange={setCommercialRegistration}
|
||||
placeholder="Enter commercial registration"
|
||||
defaultValue={commercialRegistration}
|
||||
required
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<Divider className="w-full !m-0" />
|
||||
</>
|
||||
)}
|
||||
{user.type === "corporate" && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 w-full">
|
||||
<Input
|
||||
label="Corporate Name"
|
||||
type="text"
|
||||
name="companyName"
|
||||
onChange={setCompanyName}
|
||||
placeholder="Enter corporate name"
|
||||
defaultValue={companyName}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
label="Number of Users"
|
||||
type="number"
|
||||
name="userAmount"
|
||||
onChange={(e) => setUserAmount(e ? parseInt(e) : undefined)}
|
||||
placeholder="Enter number of users"
|
||||
defaultValue={userAmount}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
label="Monthly Duration"
|
||||
type="number"
|
||||
name="monthlyDuration"
|
||||
onChange={(e) => setMonthlyDuration(e ? parseInt(e) : undefined)}
|
||||
placeholder="Enter monthly duration"
|
||||
defaultValue={monthlyDuration}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="flex flex-col gap-3 w-full lg:col-span-2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Pricing</label>
|
||||
<div className="w-full grid grid-cols-5 gap-2">
|
||||
<Input
|
||||
name="paymentValue"
|
||||
onChange={(e) => setPaymentValue(e ? parseInt(e) : undefined)}
|
||||
type="number"
|
||||
defaultValue={paymentValue || 0}
|
||||
className="col-span-3"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Select
|
||||
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||
options={CURRENCIES_OPTIONS}
|
||||
value={CURRENCIES_OPTIONS.find((c) => c.value === paymentCurrency)}
|
||||
onChange={(value) => setPaymentCurrency(value?.value)}
|
||||
styles={{
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
isDisabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 w-full">
|
||||
<div className="flex flex-col gap-3 w-8/12">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Country Manager</label>
|
||||
{referralAgentLabel && (
|
||||
<Select
|
||||
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||
options={[
|
||||
{value: "", label: "No referral"},
|
||||
...users.filter((u) => u.type === "agent").map((x) => ({value: x.id, label: `${x.name} - ${x.email}`})),
|
||||
]}
|
||||
defaultValue={{
|
||||
value: referralAgent,
|
||||
label: referralAgentLabel,
|
||||
}}
|
||||
onChange={(value) => setReferralAgent(value?.value)}
|
||||
styles={{
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
isDisabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 w-4/12">
|
||||
{referralAgent !== "" ? (
|
||||
<>
|
||||
<label className="font-normal text-base text-mti-gray-dim">Commission</label>
|
||||
<Input
|
||||
name="commissionValue"
|
||||
onChange={(e) => setCommission(e ? parseInt(e) : undefined)}
|
||||
type="number"
|
||||
defaultValue={commissionValue || 0}
|
||||
className="col-span-3"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Divider className="w-full !m-0" />
|
||||
</>
|
||||
)}
|
||||
<section className="flex flex-col gap-4 justify-between">
|
||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||
<Input
|
||||
@@ -112,33 +353,52 @@ const UserCard = ({user, onClose, onViewStudents, onViewTeachers}: Props) => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||
<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={user.demographicInformation?.employment}
|
||||
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>
|
||||
{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={user.demographicInformation?.employment}
|
||||
className="grid grid-cols-2 items-center gap-4 place-items-center"
|
||||
disabled={disabled}>
|
||||
{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>
|
||||
)}
|
||||
{user.type === "corporate" && (
|
||||
<Input
|
||||
name="position"
|
||||
onChange={setPosition}
|
||||
type="text"
|
||||
label="Position"
|
||||
defaultValue={position}
|
||||
placeholder="CEO, Head of Marketing..."
|
||||
disabled
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<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={user.demographicInformation?.gender} className="flex flex-row gap-4 justify-between">
|
||||
<RadioGroup
|
||||
value={user.demographicInformation?.gender}
|
||||
className="flex flex-row gap-4 justify-between"
|
||||
disabled={disabled}
|
||||
>
|
||||
<RadioGroup.Option value="male">
|
||||
{({checked}) => (
|
||||
<span
|
||||
@@ -188,7 +448,9 @@ const UserCard = ({user, onClose, onViewStudents, onViewTeachers}: Props) => {
|
||||
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
|
||||
<Checkbox
|
||||
isChecked={!!expiryDate}
|
||||
onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : undefined)}>
|
||||
onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : null)}
|
||||
disabled={disabled}
|
||||
>
|
||||
Enabled
|
||||
</Checkbox>
|
||||
</div>
|
||||
@@ -212,37 +474,77 @@ const UserCard = ({user, onClose, onViewStudents, onViewTeachers}: Props) => {
|
||||
expirationDateColor(expiryDate),
|
||||
"transition duration-300 ease-in-out",
|
||||
)}
|
||||
filterDate={(date) => moment(date).isAfter(new Date())}
|
||||
filterDate={(date) =>
|
||||
moment(date).isAfter(new Date()) &&
|
||||
(loggedInUser.subscriptionExpirationDate
|
||||
? moment(date).isBefore(moment(loggedInUser.subscriptionExpirationDate))
|
||||
: true)
|
||||
}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={moment(expiryDate).toDate()}
|
||||
onChange={(date) => setExpiryDate(date)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{user.corporateInformation && (
|
||||
{(loggedInUser.type === "developer" || loggedInUser.type === "admin") && (
|
||||
<>
|
||||
<Divider className="w-full" />
|
||||
<Divider className="w-full !m-0" />
|
||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||
<Input
|
||||
label="Company Name"
|
||||
type="text"
|
||||
name="companyName"
|
||||
onChange={() => null}
|
||||
placeholder="Enter company name"
|
||||
defaultValue={user.corporateInformation.companyInformation.name}
|
||||
disabled
|
||||
/>
|
||||
<Input
|
||||
label="Amount of Users"
|
||||
type="number"
|
||||
name="userAmount"
|
||||
onChange={() => null}
|
||||
placeholder="Enter amount of users"
|
||||
defaultValue={user.corporateInformation.companyInformation.userAmount}
|
||||
disabled
|
||||
/>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Status</label>
|
||||
<Select
|
||||
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||
options={USER_STATUS_OPTIONS}
|
||||
value={USER_STATUS_OPTIONS.find((o) => o.value === status)}
|
||||
onChange={(value) => setStatus(value?.value as typeof user.status)}
|
||||
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,
|
||||
}),
|
||||
}}
|
||||
isDisabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Type</label>
|
||||
<Select
|
||||
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||
options={USER_TYPE_OPTIONS}
|
||||
value={USER_TYPE_OPTIONS.find((o) => o.value === type)}
|
||||
onChange={(value) => setType(value?.value as typeof user.type)}
|
||||
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,
|
||||
}),
|
||||
}}
|
||||
isDisabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -250,6 +552,11 @@ const UserCard = ({user, onClose, onViewStudents, onViewTeachers}: Props) => {
|
||||
|
||||
<div className="flex gap-4 justify-between mt-4 w-full">
|
||||
<div className="self-start flex gap-4 justify-start items-center w-full">
|
||||
{onViewCorporate && (
|
||||
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewCorporate}>
|
||||
View Corporate
|
||||
</Button>
|
||||
)}
|
||||
{onViewStudents && (
|
||||
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewStudents}>
|
||||
View Students
|
||||
@@ -265,7 +572,7 @@ const UserCard = ({user, onClose, onViewStudents, onViewTeachers}: Props) => {
|
||||
<Button className="w-full max-w-[200px]" variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={updateUser} className="w-full max-w-[200px]">
|
||||
<Button disabled={disabled} onClick={updateUser} className="w-full max-w-[200px]">
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import {SEMI_TRANSPARENT} from "@/resources/colors";
|
||||
import {Chart as ChartJS, RadialLinearScale, ArcElement, Tooltip, Legend} from "chart.js";
|
||||
import clsx from "clsx";
|
||||
import {PolarArea} from "react-chartjs-2";
|
||||
import {Chart} from "primereact/chart";
|
||||
|
||||
interface Props {
|
||||
data: {label: string; value: number}[];
|
||||
label?: string;
|
||||
title: string;
|
||||
type: string;
|
||||
colors?: string[];
|
||||
}
|
||||
|
||||
ChartJS.register(RadialLinearScale, ArcElement, Tooltip, Legend);
|
||||
|
||||
export default function SingleDatasetChart({data, type, label, title, colors = Object.values(SEMI_TRANSPARENT)}: Props) {
|
||||
const labels = data.map((x) => x.label);
|
||||
const chartData = {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label,
|
||||
data: data.map((x) => x.value),
|
||||
backgroundColor: colors,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return <Chart type={type} data={chartData} options={{plugins: {title: {text: title, display: true}}}} />;
|
||||
}
|
||||
@@ -2,35 +2,119 @@ import {Module} from "@/interfaces";
|
||||
|
||||
export const MODULES: Module[] = ["reading", "listening", "writing", "speaking"];
|
||||
|
||||
export const BAND_SCORES: {[key in Module]: number[]} = {
|
||||
reading: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
|
||||
listening: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
|
||||
writing: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
speaking: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
// BAND SCORES is not in use anymore and level scoring is made based on thresholds
|
||||
// export const BAND_SCORES: {[key in Module]: number[]} = {
|
||||
// reading: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
|
||||
// listening: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
|
||||
// writing: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
// speaking: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
// level: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
// };
|
||||
|
||||
export type LevelScore = "Advanced" | "Upper-Intermediate" | "Intermediate" | "Pre-Intermediate" | "Elementary" | "Beginner";
|
||||
|
||||
|
||||
const generateHighestScoreText = () : React.ReactNode => (
|
||||
<>
|
||||
<br />
|
||||
<br />
|
||||
If you disagree with the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of
|
||||
the results.
|
||||
<br />
|
||||
<br />
|
||||
Please contact us for further information. Congratulations again on your outstanding achievement! We are here to support you on your
|
||||
academic journey.
|
||||
</>
|
||||
);
|
||||
|
||||
const generateAverageScoreText = () : React.ReactNode => (
|
||||
<>
|
||||
<br />
|
||||
<br />
|
||||
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
|
||||
transparency of the results.
|
||||
<br />
|
||||
<br />
|
||||
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
|
||||
journey.
|
||||
</>
|
||||
);
|
||||
|
||||
const generateLowestScoreText = () : React.ReactNode => (
|
||||
<>
|
||||
<br />
|
||||
<br />
|
||||
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
|
||||
transparency of the results.
|
||||
<br />
|
||||
<br />
|
||||
Please contact us for further information. We encourage you to continue your studies and wish you the best of luck in your future
|
||||
endeavors.
|
||||
</>
|
||||
)
|
||||
|
||||
export const levelResultText = (level: number) => {
|
||||
if(level === 9) {
|
||||
return (
|
||||
<>
|
||||
{"Outstanding! Your command of English is excellent. Focus on fine-tuning subtle language nuances and exploring sophisticated vocabulary. Keep up the excellent work!"}
|
||||
{generateHighestScoreText()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if(level >= 8) {
|
||||
return (
|
||||
<>
|
||||
{"Impressive! You're approaching fluency. Continue refining nuances in grammar and expanding your vocabulary to express ideas more precisely."}
|
||||
{generateAverageScoreText()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if(level >= 6) {
|
||||
return (
|
||||
<>
|
||||
{"Great job! You're navigating the complexities of English. Keep honing your grammar skills and exploring more advanced vocabulary."}
|
||||
{generateAverageScoreText()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if(level >= 4) {
|
||||
return (
|
||||
<>
|
||||
{"Well done! You're moving beyond the basics. Work on expanding your vocabulary and refining your understanding of grammar structures."}
|
||||
{generateAverageScoreText()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if(level >= 2) {
|
||||
return (
|
||||
<>
|
||||
{"Good effort! You're making progress. Continue studying and pay attention to common vocabulary and fundamental grammar rules."}
|
||||
{generateAverageScoreText()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if(level >= 0) {
|
||||
return (
|
||||
<>
|
||||
{"Keep practicing! You're just starting, and improvement takes time. Focus on building your vocabulary and basic grammar skills."}
|
||||
{generateLowestScoreText()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const LEVEL_TEXT = {
|
||||
excellent:
|
||||
"Congratulations on your exam performance! You achieved an impressive {{level}}, demonstrating excellent mastery of the assessed knowledge.\n\nIf you disagree with the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of the results.\n\nPlease contact us for further information. Congratulations again on your outstanding achievement! We are here to support you on your academic journey.",
|
||||
high: "Congratulations on your exam performance! You achieved a commendable {{level}}, demonstrating a good understanding of the assessed knowledge.\n\nIf you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of the results.\n\nPlease contact us for further information. Congratulations again on your achievement! We are here to support you on your academic journey.",
|
||||
medium: "Congratulations on your exam performance! You achieved a {{level}}, demonstrating a satisfactory understanding of the assessed knowledge.\n\nIf you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of the results.\n\nPlease contact us for further information. Congratulations again on your achievement! We are here to support you on your academic journey.",
|
||||
low: "Thank you for taking the exam. You achieved a {{level}}, but unfortunately, it did not meet the required standards.\n\nIf you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of the results.\n\nPlease contact us for further information. We encourage you to continue your studies and wish you the best of luck in your future endeavors.",
|
||||
};
|
||||
|
||||
export const levelText = (level: number) => {
|
||||
export const moduleResultText = (module: Module, level: number) => {
|
||||
if(module === 'level') return levelResultText(level);
|
||||
if (level === 9) {
|
||||
return (
|
||||
<>
|
||||
Congratulations on your exam performance! You achieved an impressive <span className="font-bold">level {level}</span>, demonstrating
|
||||
excellent mastery of the assessed knowledge.
|
||||
<br />
|
||||
<br />
|
||||
If you disagree with the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of
|
||||
the results.
|
||||
<br />
|
||||
<br />
|
||||
Please contact us for further information. Congratulations again on your outstanding achievement! We are here to support you on your
|
||||
academic journey.
|
||||
{generateHighestScoreText()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -40,14 +124,7 @@ export const levelText = (level: number) => {
|
||||
<>
|
||||
Congratulations on your exam performance! You achieved a commendable <span className="font-bold">level {level}</span>, demonstrating a
|
||||
good understanding of the assessed knowledge.
|
||||
<br />
|
||||
<br />
|
||||
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
|
||||
transparency of the results.
|
||||
<br />
|
||||
<br />
|
||||
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
|
||||
journey.
|
||||
{generateAverageScoreText()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -57,14 +134,7 @@ export const levelText = (level: number) => {
|
||||
<>
|
||||
Congratulations on your exam performance! You achieved a <span className="font-bold">level of {level}</span>, demonstrating a
|
||||
satisfactory understanding of the assessed knowledge.
|
||||
<br />
|
||||
<br />
|
||||
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
|
||||
transparency of the results.
|
||||
<br />
|
||||
<br />
|
||||
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
|
||||
journey.
|
||||
{generateAverageScoreText()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -73,14 +143,7 @@ export const levelText = (level: number) => {
|
||||
<>
|
||||
Thank you for taking the exam. You achieved a <span className="font-bold">level {level}</span>, but unfortunately, it did not meet the
|
||||
required standards.
|
||||
<br />
|
||||
<br />
|
||||
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
|
||||
transparency of the results.
|
||||
<br />
|
||||
<br />
|
||||
Please contact us for further information. We encourage you to continue your studies and wish you the best of luck in your future
|
||||
endeavors.
|
||||
{generateLowestScoreText()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -2,38 +2,38 @@ import {Type} from "@/interfaces/user";
|
||||
|
||||
export const PERMISSIONS = {
|
||||
generateCode: {
|
||||
student: ["teacher", "corporate", "developer", "owner"],
|
||||
teacher: ["corporate", "developer", "owner"],
|
||||
corporate: ["owner", "developer"],
|
||||
owner: ["developer", "owner"],
|
||||
agent: ["developer", "owner"],
|
||||
student: ["corporate", "developer", "admin"],
|
||||
teacher: ["corporate", "developer", "admin"],
|
||||
corporate: ["admin", "developer"],
|
||||
admin: ["developer", "admin"],
|
||||
agent: ["developer", "admin"],
|
||||
developer: ["developer"],
|
||||
},
|
||||
deleteUser: {
|
||||
student: ["teacher", "corporate", "developer", "owner"],
|
||||
teacher: ["corporate", "developer", "owner"],
|
||||
corporate: ["owner", "developer"],
|
||||
owner: ["developer", "owner"],
|
||||
agent: ["developer", "owner"],
|
||||
student: ["teacher", "corporate", "developer", "admin"],
|
||||
teacher: ["corporate", "developer", "admin"],
|
||||
corporate: ["admin", "developer"],
|
||||
admin: ["developer", "admin"],
|
||||
agent: ["developer", "admin"],
|
||||
developer: ["developer"],
|
||||
},
|
||||
updateUser: {
|
||||
student: ["teacher", "corporate", "developer", "owner"],
|
||||
teacher: ["corporate", "developer", "owner"],
|
||||
corporate: ["owner", "developer"],
|
||||
owner: ["developer", "owner"],
|
||||
agent: ["developer", "owner"],
|
||||
student: ["teacher", "corporate", "developer", "admin"],
|
||||
teacher: ["corporate", "developer", "admin"],
|
||||
corporate: ["admin", "developer"],
|
||||
admin: ["developer", "admin"],
|
||||
agent: ["developer", "admin"],
|
||||
developer: ["developer"],
|
||||
},
|
||||
updateExpiryDate: {
|
||||
student: ["developer", "owner"],
|
||||
teacher: ["developer", "owner"],
|
||||
corporate: ["owner", "developer"],
|
||||
owner: ["developer", "owner"],
|
||||
agent: ["developer", "owner"],
|
||||
student: ["developer", "admin"],
|
||||
teacher: ["developer", "admin"],
|
||||
corporate: ["admin", "developer"],
|
||||
admin: ["developer", "admin"],
|
||||
agent: ["developer", "admin"],
|
||||
developer: ["developer"],
|
||||
},
|
||||
examManagement: {
|
||||
delete: ["developer", "owner"],
|
||||
delete: ["developer", "admin"],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,16 +7,18 @@ import UserList from "@/pages/(admin)/Lists/UserList";
|
||||
import {dateSorter} from "@/utils";
|
||||
import moment from "moment";
|
||||
import {useEffect, useState} from "react";
|
||||
import {BsArrowLeft, BsGlobeCentralSouthAsia, BsPerson, BsPersonFill, BsPersonFillGear, BsPersonGear, BsPersonLinesFill} from "react-icons/bs";
|
||||
import {BsArrowLeft, BsBriefcaseFill, BsGlobeCentralSouthAsia, BsPerson, BsPersonFill, BsPencilSquare, BsBank} from "react-icons/bs";
|
||||
import UserCard from "@/components/UserCard";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import IconCard from "./IconCard";
|
||||
import useFilterStore from "@/stores/listFilterStore";
|
||||
import {useRouter} from "next/router";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export default function OwnerDashboard({user}: Props) {
|
||||
export default function AdminDashboard({user}: Props) {
|
||||
const [page, setPage] = useState("");
|
||||
const [selectedUser, setSelectedUser] = useState<User>();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
@@ -25,17 +27,30 @@ export default function OwnerDashboard({user}: Props) {
|
||||
const {users, reload} = useUsers();
|
||||
const {groups} = useGroups();
|
||||
|
||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
setShowModal(!!selectedUser && page === "");
|
||||
}, [selectedUser, page]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(reload, [page]);
|
||||
|
||||
const inactiveCountryManagerFilter = (x: User) =>
|
||||
x.type === "agent" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
||||
|
||||
const UserDisplay = (displayUser: User) => (
|
||||
<div
|
||||
onClick={() => setSelectedUser(displayUser)}
|
||||
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
||||
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<span>{displayUser.name}</span>
|
||||
<span>
|
||||
{displayUser.type === "corporate"
|
||||
? displayUser.corporateInformation?.companyInformation?.name || displayUser.name
|
||||
: displayUser.name}
|
||||
</span>
|
||||
<span className="text-sm opacity-75">{displayUser.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,7 +63,7 @@ export default function OwnerDashboard({user}: Props) {
|
||||
? groups
|
||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(x.id) || false
|
||||
.includes(x.id)
|
||||
: true);
|
||||
|
||||
return (
|
||||
@@ -63,7 +78,7 @@ export default function OwnerDashboard({user}: Props) {
|
||||
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
|
||||
</div>
|
||||
|
||||
<UserList user={user} filter={filter} />
|
||||
<UserList user={user} filters={[filter]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -90,7 +105,27 @@ export default function OwnerDashboard({user}: Props) {
|
||||
<h2 className="text-2xl font-semibold">Teachers ({users.filter(filter).length})</h2>
|
||||
</div>
|
||||
|
||||
<UserList user={user} filter={filter} />
|
||||
<UserList user={user} filters={[filter]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AgentsList = () => {
|
||||
const filter = (x: User) => x.type === "agent";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">Country Managers ({users.filter(filter).length})</h2>
|
||||
</div>
|
||||
|
||||
<UserList user={user} filters={[filter]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -107,10 +142,28 @@ export default function OwnerDashboard({user}: Props) {
|
||||
<h2 className="text-2xl font-semibold">Corporate ({users.filter((x) => x.type === "corporate").length})</h2>
|
||||
</div>
|
||||
|
||||
<UserList user={user} filter={(x) => x.type === "corporate"} />
|
||||
<UserList user={user} filters={[(x) => x.type === "corporate"]} />
|
||||
</>
|
||||
);
|
||||
|
||||
const InactiveCountryManagerList = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">Inactive Country Managers ({users.filter(inactiveCountryManagerFilter).length})</h2>
|
||||
</div>
|
||||
|
||||
<UserList user={user} filters={[inactiveCountryManagerFilter]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const InactiveStudentsList = () => {
|
||||
const filter = (x: User) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
||||
|
||||
@@ -126,7 +179,7 @@ export default function OwnerDashboard({user}: Props) {
|
||||
<h2 className="text-2xl font-semibold">Inactive Students ({users.filter(filter).length})</h2>
|
||||
</div>
|
||||
|
||||
<UserList user={user} filter={filter} />
|
||||
<UserList user={user} filters={[filter]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -146,14 +199,14 @@ export default function OwnerDashboard({user}: Props) {
|
||||
<h2 className="text-2xl font-semibold">Inactive Corporate ({users.filter(filter).length})</h2>
|
||||
</div>
|
||||
|
||||
<UserList user={user} filter={filter} />
|
||||
<UserList user={user} filters={[filter]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DefaultDashboard = () => (
|
||||
<>
|
||||
<section className="w-full flex flex-wrap gap-4 items-center justify-between">
|
||||
<section className="w-full grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 place-items-center items-center justify-between">
|
||||
<IconCard
|
||||
Icon={BsPersonFill}
|
||||
label="Students"
|
||||
@@ -162,19 +215,26 @@ export default function OwnerDashboard({user}: Props) {
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPersonLinesFill}
|
||||
Icon={BsPencilSquare}
|
||||
label="Teachers"
|
||||
value={users.filter((x) => x.type === "teacher").length}
|
||||
onClick={() => setPage("teachers")}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPersonLinesFill}
|
||||
Icon={BsBank}
|
||||
label="Corporate"
|
||||
value={users.filter((x) => x.type === "corporate").length}
|
||||
onClick={() => setPage("corporate")}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsBriefcaseFill}
|
||||
label="Country Managers"
|
||||
value={users.filter((x) => x.type === "agent").length}
|
||||
onClick={() => setPage("agents")}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsGlobeCentralSouthAsia}
|
||||
label="Countries"
|
||||
@@ -191,6 +251,13 @@ export default function OwnerDashboard({user}: Props) {
|
||||
}
|
||||
color="rose"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("inactiveCountryManagers")}
|
||||
Icon={BsPerson}
|
||||
label="Inactive Country Managers"
|
||||
value={users.filter(inactiveCountryManagerFilter).length}
|
||||
color="rose"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("inactiveCorporate")}
|
||||
Icon={BsPerson}
|
||||
@@ -244,7 +311,8 @@ export default function OwnerDashboard({user}: Props) {
|
||||
(x) =>
|
||||
x.type === "student" &&
|
||||
x.subscriptionExpirationDate &&
|
||||
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")),
|
||||
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
|
||||
moment().isBefore(moment(x.subscriptionExpirationDate)),
|
||||
)
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
@@ -252,14 +320,15 @@ export default function OwnerDashboard({user}: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Teachers expiring in 1 month</span>
|
||||
<span className="p-4">Country Manager expiring in 1 month</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(
|
||||
(x) =>
|
||||
x.type === "teacher" &&
|
||||
x.subscriptionExpirationDate &&
|
||||
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")),
|
||||
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
|
||||
moment().isBefore(moment(x.subscriptionExpirationDate)),
|
||||
)
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
@@ -274,7 +343,45 @@ export default function OwnerDashboard({user}: Props) {
|
||||
(x) =>
|
||||
x.type === "corporate" &&
|
||||
x.subscriptionExpirationDate &&
|
||||
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")),
|
||||
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
|
||||
moment().isBefore(moment(x.subscriptionExpirationDate)),
|
||||
)
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Expired Students</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(
|
||||
(x) => x.type === "student" && x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)),
|
||||
)
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Expired Country Manager</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(
|
||||
(x) => x.type === "teacher" && x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)),
|
||||
)
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Expired Corporate</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(
|
||||
(x) =>
|
||||
x.type === "corporate" && x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)),
|
||||
)
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
@@ -292,14 +399,71 @@ export default function OwnerDashboard({user}: Props) {
|
||||
{selectedUser && (
|
||||
<div className="w-full flex flex-col gap-8">
|
||||
<UserCard
|
||||
loggedInUser={user}
|
||||
onClose={(shouldReload) => {
|
||||
setSelectedUser(undefined);
|
||||
if (shouldReload) reload();
|
||||
}}
|
||||
onViewStudents={
|
||||
selectedUser.type === "corporate" || selectedUser.type === "teacher" ? () => setPage("students") : undefined
|
||||
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
||||
? () => {
|
||||
appendUserFilters({
|
||||
id: "view-students",
|
||||
filter: (x: User) => x.type === "student",
|
||||
});
|
||||
appendUserFilters({
|
||||
id: "belongs-to-admin",
|
||||
filter: (x: User) =>
|
||||
groups
|
||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(x.id),
|
||||
});
|
||||
|
||||
router.push("/list/users");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onViewTeachers={
|
||||
selectedUser.type === "corporate" || selectedUser.type === "student"
|
||||
? () => {
|
||||
appendUserFilters({
|
||||
id: "view-teachers",
|
||||
filter: (x: User) => x.type === "teacher",
|
||||
});
|
||||
appendUserFilters({
|
||||
id: "belongs-to-admin",
|
||||
filter: (x: User) =>
|
||||
groups
|
||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(x.id),
|
||||
});
|
||||
|
||||
router.push("/list/users");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onViewCorporate={
|
||||
selectedUser.type === "teacher" || selectedUser.type === "student"
|
||||
? () => {
|
||||
appendUserFilters({
|
||||
id: "view-corporate",
|
||||
filter: (x: User) => x.type === "corporate",
|
||||
});
|
||||
appendUserFilters({
|
||||
id: "belongs-to-admin",
|
||||
filter: (x: User) =>
|
||||
groups
|
||||
.filter((g) => g.participants.includes(selectedUser.id))
|
||||
.flatMap((g) => [g.admin, ...g.participants])
|
||||
.includes(x.id),
|
||||
});
|
||||
|
||||
router.push("/list/users");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined}
|
||||
user={selectedUser}
|
||||
/>
|
||||
</div>
|
||||
@@ -309,8 +473,10 @@ export default function OwnerDashboard({user}: Props) {
|
||||
{page === "students" && <StudentsList />}
|
||||
{page === "teachers" && <TeachersList />}
|
||||
{page === "corporate" && <CorporateList />}
|
||||
{page === "agents" && <AgentsList />}
|
||||
{page === "inactiveStudents" && <InactiveStudentsList />}
|
||||
{page === "inactiveCorporate" && <InactiveCorporateList />}
|
||||
{page === "inactiveCountryManagers" && <InactiveCountryManagerList />}
|
||||
{page === "" && <DefaultDashboard />}
|
||||
</>
|
||||
);
|
||||
211
src/dashboards/Agent.tsx
Normal file
211
src/dashboards/Agent.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Modal from "@/components/Modal";
|
||||
import useStats from "@/hooks/useStats";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {Group, Stat, User} from "@/interfaces/user";
|
||||
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||
import {dateSorter} from "@/utils";
|
||||
import moment from "moment";
|
||||
import {useEffect, useState} from "react";
|
||||
import {BsArrowLeft, BsPersonFill, BsBank} from "react-icons/bs";
|
||||
import UserCard from "@/components/UserCard";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
|
||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||
import {Module} from "@/interfaces";
|
||||
import {groupByExam} from "@/utils/stats";
|
||||
import IconCard from "./IconCard";
|
||||
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export default function AgentDashboard({user}: Props) {
|
||||
const [page, setPage] = useState("");
|
||||
const [selectedUser, setSelectedUser] = useState<User>();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const {stats} = useStats();
|
||||
const {users, reload} = useUsers();
|
||||
const {groups} = useGroups(user.id);
|
||||
|
||||
useEffect(() => {
|
||||
setShowModal(!!selectedUser && page === "");
|
||||
}, [selectedUser, page]);
|
||||
|
||||
const corporateFilter = (user: User) => user.type === "corporate";
|
||||
const referredCorporateFilter = (x: User) =>
|
||||
x.type === "corporate" && !!x.corporateInformation && x.corporateInformation.referralAgent === user.id;
|
||||
const inactiveReferredCorporateFilter = (x: User) =>
|
||||
referredCorporateFilter(x) && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
||||
|
||||
const UserDisplay = (displayUser: User) => (
|
||||
<div
|
||||
onClick={() => setSelectedUser(displayUser)}
|
||||
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
||||
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<span>
|
||||
{displayUser.type === "corporate"
|
||||
? displayUser.corporateInformation?.companyInformation?.name || displayUser.name
|
||||
: displayUser.name}
|
||||
</span>
|
||||
<span className="text-sm opacity-75">{displayUser.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ReferredCorporateList = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">Corporate ({users.filter(referredCorporateFilter).length})</h2>
|
||||
</div>
|
||||
|
||||
<UserList user={user} filters={[referredCorporateFilter]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const InactiveReferredCorporateList = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">Inactive Corporate ({users.filter(inactiveReferredCorporateFilter).length})</h2>
|
||||
</div>
|
||||
|
||||
<UserList user={user} filters={[inactiveReferredCorporateFilter]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const CorporateList = () => {
|
||||
const filter = (x: User) => x.type === "corporate";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">Corporate ({users.filter(filter).length})</h2>
|
||||
</div>
|
||||
|
||||
<UserList user={user} filters={[filter]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DefaultDashboard = () => (
|
||||
<>
|
||||
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:gap-4 text-center">
|
||||
<IconCard
|
||||
onClick={() => setPage("referredCorporate")}
|
||||
Icon={BsPersonFill}
|
||||
label="Corporate"
|
||||
value={users.filter(referredCorporateFilter).length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("inactiveReferredCorporate")}
|
||||
Icon={BsPersonFill}
|
||||
label="Inactive Corporate"
|
||||
value={users.filter(inactiveReferredCorporateFilter).length}
|
||||
color="rose"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("corporate")}
|
||||
Icon={BsBank}
|
||||
label="Corporate"
|
||||
value={users.filter(corporateFilter).length}
|
||||
color="purple"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Latest Corporate</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(referredCorporateFilter)
|
||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Latest corporate</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(corporateFilter)
|
||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Corporate expiring in 1 month</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(
|
||||
(x) =>
|
||||
referredCorporateFilter(x) &&
|
||||
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
|
||||
moment().isBefore(moment(x.subscriptionExpirationDate)),
|
||||
)
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
|
||||
<>
|
||||
{selectedUser && (
|
||||
<div className="w-full flex flex-col gap-8">
|
||||
<UserCard
|
||||
loggedInUser={user}
|
||||
onClose={(shouldReload) => {
|
||||
setSelectedUser(undefined);
|
||||
if (shouldReload) reload();
|
||||
}}
|
||||
onViewStudents={
|
||||
selectedUser.type === "corporate" || selectedUser.type === "teacher" ? () => setPage("students") : undefined
|
||||
}
|
||||
onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined}
|
||||
user={selectedUser}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</Modal>
|
||||
{page === "referredCorporate" && <ReferredCorporateList />}
|
||||
{page === "corporate" && <CorporateList />}
|
||||
{page === "inactiveReferredCorporate" && <InactiveReferredCorporateList />}
|
||||
{page === "" && <DefaultDashboard />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {calculateBandScore} from "@/utils/score";
|
||||
import clsx from "clsx";
|
||||
import moment from "moment";
|
||||
import {useState} from "react";
|
||||
import {BsBook, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||
|
||||
interface Props {
|
||||
onClick?: () => void;
|
||||
@@ -57,11 +57,13 @@ export default function AssignmentCard({id, name, assigner, startDate, endDate,
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}>
|
||||
{module === "reading" && <BsBook className="w-4 h-4" />}
|
||||
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
||||
{module === "writing" && <BsPen className="w-4 h-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
||||
{module === "level" && <BsClipboard className="w-4 h-4" />}
|
||||
{calculateAverageModuleScore(module) > -1 && (
|
||||
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,7 @@ import Modal from "@/components/Modal";
|
||||
import {Module} from "@/interfaces";
|
||||
import clsx from "clsx";
|
||||
import {useState} from "react";
|
||||
import {BsBook, BsCheckCircle, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||
import {BsBook, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
||||
import {generate} from "random-words";
|
||||
import {capitalize} from "lodash";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
@@ -18,6 +18,7 @@ import {getExam} from "@/utils/exams";
|
||||
import {toast} from "react-toastify";
|
||||
import {uuidv4} from "@firebase/util";
|
||||
import {Assignment} from "@/interfaces/results";
|
||||
import Checkbox from "@/components/Low/Checkbox";
|
||||
|
||||
interface Props {
|
||||
isCreating: boolean;
|
||||
@@ -35,6 +36,8 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [startDate, setStartDate] = useState<Date | null>(assignment ? moment(assignment.startDate).toDate() : moment().add(1, "day").toDate());
|
||||
const [endDate, setEndDate] = useState<Date | null>(assignment ? moment(assignment.endDate).toDate() : moment().add(8, "day").toDate());
|
||||
// creates a new exam for each assignee or just one exam for all assignees
|
||||
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
|
||||
|
||||
const toggleModule = (module: Module) => {
|
||||
const modules = selectedModules.filter((x) => x !== module);
|
||||
@@ -46,36 +49,33 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
};
|
||||
|
||||
const createAssignment = () => {
|
||||
setIsLoading(true);
|
||||
setIsLoading(true);
|
||||
|
||||
const examPromises = selectedModules.map(async (module) => getExam(module, false));
|
||||
Promise.all(examPromises)
|
||||
.then((exams) => {
|
||||
(assignment ? axios.patch : axios.post)(`/api/assignments${assignment ? `/${assignment.id}` : ""}`, {
|
||||
assigner,
|
||||
assignees,
|
||||
name,
|
||||
startDate,
|
||||
endDate,
|
||||
results: [],
|
||||
exams: exams.map((e) => ({module: e?.module, id: e?.id})),
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`);
|
||||
cancelCreation();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
(assignment ? axios.patch : axios.post)(
|
||||
`/api/assignments${assignment ? `/${assignment.id}` : ""}`,
|
||||
{
|
||||
assignees,
|
||||
name,
|
||||
startDate,
|
||||
endDate,
|
||||
selectedModules,
|
||||
generateMultiple,
|
||||
}
|
||||
)
|
||||
.then(() => {
|
||||
toast.success(
|
||||
`The assignment "${name}" has been ${
|
||||
assignment ? "updated" : "created"
|
||||
} successfully!`
|
||||
);
|
||||
cancelCreation();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
const deleteAssignment = () => {
|
||||
if (assignment) {
|
||||
@@ -99,59 +99,96 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
return (
|
||||
<Modal isOpen={isCreating} onClose={cancelCreation} title="New Assignment">
|
||||
<div className="w-full flex flex-col gap-4">
|
||||
<section className="w-full grid -md:grid-cols-1 md:grid-cols-2 place-items-center lg:grid-cols-4 -md:flex-col -md:items-center -md:gap-12 justify-between gap-8 mt-8 px-8">
|
||||
<section className="w-full grid -md:grid-cols-1 md:grid-cols-2 place-items-center lg:grid-cols-6 -md:flex-col -md:items-center -md:gap-12 justify-between gap-8 mt-8 px-8">
|
||||
<div
|
||||
onClick={() => toggleModule("reading")}
|
||||
onClick={!selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
|
||||
className={clsx(
|
||||
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
||||
"lg:col-span-2",
|
||||
selectedModules.includes("reading") ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||
)}>
|
||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
||||
<BsBook className="text-white w-7 h-7" />
|
||||
</div>
|
||||
<span className="ml-8 font-semibold">Reading</span>
|
||||
{!selectedModules.includes("reading") && <div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />}
|
||||
{!selectedModules.includes("reading") && !selectedModules.includes("level") && (
|
||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
|
||||
{selectedModules.includes("reading") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
|
||||
</div>
|
||||
<div
|
||||
onClick={() => toggleModule("listening")}
|
||||
onClick={!selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
|
||||
className={clsx(
|
||||
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
||||
"lg:col-span-2",
|
||||
selectedModules.includes("listening") ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||
)}>
|
||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
||||
<BsHeadphones className="text-white w-7 h-7" />
|
||||
</div>
|
||||
<span className="ml-8 font-semibold">Listening</span>
|
||||
{!selectedModules.includes("listening") && <div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />}
|
||||
{!selectedModules.includes("listening") && !selectedModules.includes("level") && (
|
||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
|
||||
{selectedModules.includes("listening") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
|
||||
</div>
|
||||
<div
|
||||
onClick={() => toggleModule("writing")}
|
||||
onClick={!selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
|
||||
className={clsx(
|
||||
"w-52 relative max-w-xs flex lg:flex-row-reverse items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
||||
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
||||
"lg:col-span-2",
|
||||
selectedModules.includes("writing") ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||
)}>
|
||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-1/2 -translate-y-1/2 -lg:left-0 -lg:-translate-x-1/2 lg:right-0 lg:translate-x-1/2">
|
||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
||||
<BsPen className="text-white w-7 h-7" />
|
||||
</div>
|
||||
<span className="lg:mr-8 -lg:ml-8 font-semibold">Writing</span>
|
||||
{!selectedModules.includes("writing") && <div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />}
|
||||
<span className="ml-8 font-semibold">Writing</span>
|
||||
{!selectedModules.includes("writing") && !selectedModules.includes("level") && (
|
||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
|
||||
{selectedModules.includes("writing") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
|
||||
</div>
|
||||
<div
|
||||
onClick={() => toggleModule("speaking")}
|
||||
onClick={!selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
|
||||
className={clsx(
|
||||
"w-52 relative max-w-xs flex lg:flex-row-reverse items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
||||
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
||||
"lg:col-span-3",
|
||||
selectedModules.includes("speaking") ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||
)}>
|
||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-1/2 -translate-y-1/2 -lg:left-0 -lg:-translate-x-1/2 lg:right-0 lg:translate-x-1/2">
|
||||
<BsMegaphone className="text-white w-7 h-7 lg:-scale-x-100" />
|
||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
||||
<BsMegaphone className="text-white w-7 h-7" />
|
||||
</div>
|
||||
<span className="lg:mr-8 -lg:ml-8 font-semibold">Speaking</span>
|
||||
{!selectedModules.includes("speaking") && <div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />}
|
||||
<span className="ml-8 font-semibold">Speaking</span>
|
||||
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && (
|
||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
|
||||
{selectedModules.includes("speaking") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
|
||||
</div>
|
||||
<div
|
||||
onClick={
|
||||
(!selectedModules.includes("level") && selectedModules.length === 0) || selectedModules.includes("level")
|
||||
? () => toggleModule("level")
|
||||
: undefined
|
||||
}
|
||||
className={clsx(
|
||||
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
||||
"lg:col-span-3",
|
||||
selectedModules.includes("level") ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||
)}>
|
||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
||||
<BsClipboard className="text-white w-7 h-7" />
|
||||
</div>
|
||||
<span className="ml-8 font-semibold">Level</span>
|
||||
{!selectedModules.includes("level") && selectedModules.length === 0 && (
|
||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
||||
)}
|
||||
{!selectedModules.includes("level") && selectedModules.length > 0 && <BsXCircle className="text-mti-red-light w-8 h-8" />}
|
||||
{selectedModules.includes("level") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Input type="text" name="name" onChange={(e) => setName(e)} defaultValue={name} label="Assignment Name" required />
|
||||
@@ -247,6 +284,11 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<div className="flex gap-4 w-full justify-end">
|
||||
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple(d => !d)}>
|
||||
Generate different exams
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div className="flex gap-4 w-full justify-end">
|
||||
<Button className="w-full max-w-[200px]" variant="outline" onClick={cancelCreation} disabled={isLoading} isLoading={isLoading}>
|
||||
Cancel
|
||||
|
||||
@@ -10,10 +10,10 @@ import {sortByModule} from "@/utils/moduleUtils";
|
||||
import {calculateBandScore} from "@/utils/score";
|
||||
import {convertToUserSolutions} from "@/utils/stats";
|
||||
import clsx from "clsx";
|
||||
import {uniqBy} from "lodash";
|
||||
import {capitalize, uniqBy} from "lodash";
|
||||
import moment from "moment";
|
||||
import {useRouter} from "next/router";
|
||||
import {BsBook, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
@@ -73,6 +73,11 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
level: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
};
|
||||
|
||||
stats.forEach((x) => {
|
||||
@@ -153,11 +158,13 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}>
|
||||
{module === "reading" && <BsBook className="w-4 h-4" />}
|
||||
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
||||
{module === "writing" && <BsPen className="w-4 h-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
||||
{module === "level" && <BsClipboard className="w-4 h-4" />}
|
||||
<span className="text-sm">{level.toFixed(1)}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -232,18 +239,21 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
||||
<div className="grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2">
|
||||
{assignment?.exams.map(({module}) => (
|
||||
<div
|
||||
data-tip={capitalize(module)}
|
||||
key={module}
|
||||
className={clsx(
|
||||
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl",
|
||||
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl tooltip",
|
||||
module === "reading" && "bg-ielts-reading",
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}>
|
||||
{module === "reading" && <BsBook className="w-4 h-4" />}
|
||||
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
||||
{module === "writing" && <BsPen className="w-4 h-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
||||
{module === "level" && <BsClipboard className="w-4 h-4" />}
|
||||
{calculateAverageModuleScore(module) > -1 && (
|
||||
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
|
||||
)}
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
BsPersonFill,
|
||||
BsPersonFillGear,
|
||||
BsPersonGear,
|
||||
BsPersonLinesFill,
|
||||
BsPencilSquare,
|
||||
} from "react-icons/bs";
|
||||
import UserCard from "@/components/UserCard";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
@@ -29,6 +29,8 @@ import {Module} from "@/interfaces";
|
||||
import {groupByExam} from "@/utils/stats";
|
||||
import IconCard from "./IconCard";
|
||||
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
||||
import useFilterStore from "@/stores/listFilterStore";
|
||||
import {useRouter} from "next/router";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
@@ -43,6 +45,9 @@ export default function CorporateDashboard({user}: Props) {
|
||||
const {users, reload} = useUsers();
|
||||
const {groups} = useGroups(user.id);
|
||||
|
||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
setShowModal(!!selectedUser && page === "");
|
||||
}, [selectedUser, page]);
|
||||
@@ -86,7 +91,7 @@ export default function CorporateDashboard({user}: Props) {
|
||||
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
|
||||
</div>
|
||||
|
||||
<UserList user={user} filter={filter} />
|
||||
<UserList user={user} filters={[filter]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -113,7 +118,7 @@ export default function CorporateDashboard({user}: Props) {
|
||||
<h2 className="text-2xl font-semibold">Teachers ({users.filter(filter).length})</h2>
|
||||
</div>
|
||||
|
||||
<UserList user={user} filter={filter} />
|
||||
<UserList user={user} filters={[filter]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -147,7 +152,7 @@ export default function CorporateDashboard({user}: Props) {
|
||||
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
|
||||
}));
|
||||
|
||||
const levels: {[key in Module]: number} = {reading: 0, listening: 0, writing: 0, speaking: 0};
|
||||
const levels: {[key in Module]: number} = {reading: 0, listening: 0, writing: 0, speaking: 0, level: 0};
|
||||
bandScores.forEach((b) => (levels[b.module] += b.level));
|
||||
|
||||
return calculateAverageLevel(levels);
|
||||
@@ -165,7 +170,7 @@ export default function CorporateDashboard({user}: Props) {
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("teachers")}
|
||||
Icon={BsPersonLinesFill}
|
||||
Icon={BsPencilSquare}
|
||||
label="Teachers"
|
||||
value={users.filter(teacherFilter).length}
|
||||
color="purple"
|
||||
@@ -250,14 +255,51 @@ export default function CorporateDashboard({user}: Props) {
|
||||
{selectedUser && (
|
||||
<div className="w-full flex flex-col gap-8">
|
||||
<UserCard
|
||||
loggedInUser={user}
|
||||
onClose={(shouldReload) => {
|
||||
setSelectedUser(undefined);
|
||||
if (shouldReload) reload();
|
||||
}}
|
||||
onViewStudents={
|
||||
selectedUser.type === "corporate" || selectedUser.type === "teacher" ? () => setPage("students") : undefined
|
||||
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
||||
? () => {
|
||||
appendUserFilters({
|
||||
id: "view-students",
|
||||
filter: (x: User) => x.type === "student",
|
||||
});
|
||||
appendUserFilters({
|
||||
id: "belongs-to-admin",
|
||||
filter: (x: User) =>
|
||||
groups
|
||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(x.id),
|
||||
});
|
||||
|
||||
router.push("/list/users");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onViewTeachers={
|
||||
selectedUser.type === "corporate" || selectedUser.type === "student"
|
||||
? () => {
|
||||
appendUserFilters({
|
||||
id: "view-teachers",
|
||||
filter: (x: User) => x.type === "teacher",
|
||||
});
|
||||
appendUserFilters({
|
||||
id: "belongs-to-admin",
|
||||
filter: (x: User) =>
|
||||
groups
|
||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(x.id),
|
||||
});
|
||||
|
||||
router.push("/list/users");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined}
|
||||
user={selectedUser}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function IconCard({Icon, label, value, color, onClick}: Props) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
||||
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center text-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
||||
<Icon className={clsx("text-6xl", colorClasses[color])} />
|
||||
<span className="flex flex-col gap-1 items-center text-xl">
|
||||
<span className="text-lg">{label}</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import PayPalPayment from "@/components/PayPalPayment";
|
||||
import ProfileSummary from "@/components/ProfileSummary";
|
||||
import useAssignments from "@/hooks/useAssignments";
|
||||
import useStats from "@/hooks/useStats";
|
||||
@@ -7,14 +8,18 @@ import {Assignment} from "@/interfaces/results";
|
||||
import {User} from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {MODULE_ARRAY, sortByModule} from "@/utils/moduleUtils";
|
||||
import {MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
|
||||
import {averageScore, groupBySession} from "@/utils/stats";
|
||||
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js";
|
||||
import {PayPalButtons} from "@paypal/react-paypal-js";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize} from "lodash";
|
||||
import moment from "moment";
|
||||
import Link from "next/link";
|
||||
import {useRouter} from "next/router";
|
||||
import {BsArrowRepeat, BsBook, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs";
|
||||
import {BsArrowRepeat, BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
@@ -33,7 +38,7 @@ export default function StudentDashboard({user}: Props) {
|
||||
const setAssignment = useExamStore((state) => state.setAssignment);
|
||||
|
||||
const startAssignment = (assignment: Assignment) => {
|
||||
const examPromises = assignment.exams.map((e) => getExamById(e.module, e.id));
|
||||
const examPromises = assignment.exams.filter((e) => e.assignee === user.id).map((e) => getExamById(e.module, e.id));
|
||||
|
||||
Promise.all(examPromises).then((exams) => {
|
||||
if (exams.every((x) => !!x)) {
|
||||
@@ -115,34 +120,29 @@ export default function StudentDashboard({user}: Props) {
|
||||
</div>
|
||||
<div className="flex justify-between w-full items-center">
|
||||
<div className="grid grid-cols-2 gap-2 place-items-center justify-center w-fit min-w-[104px] -md:mt-2">
|
||||
{MODULE_ARRAY.map((module) => (
|
||||
<div
|
||||
key={module}
|
||||
className={clsx(
|
||||
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl",
|
||||
module === "reading" &&
|
||||
(assignment.exams.map((e) => e.module).includes("reading")
|
||||
? "bg-ielts-reading"
|
||||
: "bg-mti-black/40"),
|
||||
module === "listening" &&
|
||||
(assignment.exams.map((e) => e.module).includes("listening")
|
||||
? "bg-ielts-listening"
|
||||
: "bg-mti-black/40"),
|
||||
module === "writing" &&
|
||||
(assignment.exams.map((e) => e.module).includes("writing")
|
||||
? "bg-ielts-writing"
|
||||
: "bg-mti-black/40"),
|
||||
module === "speaking" &&
|
||||
(assignment.exams.map((e) => e.module).includes("speaking")
|
||||
? "bg-ielts-speaking"
|
||||
: "bg-mti-black/40"),
|
||||
)}>
|
||||
{module === "reading" && <BsBook className="w-4 h-4" />}
|
||||
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
||||
{module === "writing" && <BsPen className="w-4 h-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
||||
</div>
|
||||
))}
|
||||
{assignment.exams
|
||||
.filter((e) => e.assignee === user.id)
|
||||
.map((e) => e.module)
|
||||
.sort(sortByModuleName)
|
||||
.map((module) => (
|
||||
<div
|
||||
key={module}
|
||||
data-tip={capitalize(module)}
|
||||
className={clsx(
|
||||
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl tooltip",
|
||||
module === "reading" && "bg-ielts-reading",
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}>
|
||||
{module === "reading" && <BsBook className="w-4 h-4" />}
|
||||
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
||||
{module === "writing" && <BsPen className="w-4 h-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
||||
{module === "level" && <BsClipboard className="w-4 h-4" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!assignment.results.map((r) => r.user).includes(user.id) && (
|
||||
<>
|
||||
@@ -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>
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
BsPersonFill,
|
||||
BsPersonFillGear,
|
||||
BsPersonGear,
|
||||
BsPersonLinesFill,
|
||||
BsPlus,
|
||||
BsRepeat,
|
||||
BsRepeat1,
|
||||
@@ -104,7 +103,7 @@ export default function TeacherDashboard({user}: Props) {
|
||||
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
|
||||
</div>
|
||||
|
||||
<UserList user={user} filter={filter} />
|
||||
<UserList user={user} filters={[filter]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -138,7 +137,7 @@ export default function TeacherDashboard({user}: Props) {
|
||||
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
|
||||
}));
|
||||
|
||||
const levels: {[key in Module]: number} = {reading: 0, listening: 0, writing: 0, speaking: 0};
|
||||
const levels: {[key in Module]: number} = {reading: 0, listening: 0, writing: 0, speaking: 0, level: 0};
|
||||
bandScores.forEach((b) => (levels[b.module] += b.level));
|
||||
|
||||
return calculateAverageLevel(levels);
|
||||
@@ -317,6 +316,7 @@ export default function TeacherDashboard({user}: Props) {
|
||||
{selectedUser && (
|
||||
<div className="w-full flex flex-col gap-8">
|
||||
<UserCard
|
||||
loggedInUser={user}
|
||||
onClose={(shouldReload) => {
|
||||
setSelectedUser(undefined);
|
||||
if (shouldReload) reload();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||
import {levelText, LEVEL_TEXT} from "@/constants/ielts";
|
||||
import {moduleResultText} from "@/constants/ielts";
|
||||
import {Module} from "@/interfaces";
|
||||
import {User} from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
@@ -9,7 +9,9 @@ import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
import {useRouter} from "next/router";
|
||||
import {Fragment, useEffect, useState} from "react";
|
||||
import {BsArrowCounterclockwise, BsBook, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs";
|
||||
import {BsArrowCounterclockwise, BsBook, BsClipboard, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs";
|
||||
import {LevelScore} from "@/constants/ielts";
|
||||
import {getLevelScore} from "@/utils/score";
|
||||
|
||||
interface Score {
|
||||
module: Module;
|
||||
@@ -51,6 +53,10 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
progress: "text-ielts-speaking",
|
||||
inner: "bg-ielts-speaking-light",
|
||||
},
|
||||
level: {
|
||||
progress: "text-ielts-level",
|
||||
inner: "bg-ielts-level-light",
|
||||
},
|
||||
};
|
||||
|
||||
const getTotalExercises = () => {
|
||||
@@ -62,6 +68,22 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
return exam.exercises.length;
|
||||
};
|
||||
|
||||
const bandScore: number = calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus);
|
||||
|
||||
const showLevel = (level: number) => {
|
||||
if (selectedModule === "level") {
|
||||
const [levelStr, grade] = getLevelScore(level);
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-1">
|
||||
<span className="text-xl font-bold">{levelStr}</span>
|
||||
<span className="text-xl">{grade}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="text-3xl font-bold">{level}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full min-h-full h-fit flex flex-col items-center justify-between gap-8">
|
||||
@@ -117,18 +139,31 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
<span className="font-semibold">Speaking</span>
|
||||
</div>
|
||||
)}
|
||||
{modules.includes("level") && (
|
||||
<div
|
||||
onClick={() => setSelectedModule("level")}
|
||||
className={clsx(
|
||||
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-level hover:text-white",
|
||||
selectedModule === "level" ? "bg-ielts-level text-white" : "bg-mti-gray-smoke text-ielts-level",
|
||||
)}>
|
||||
<BsClipboard className="w-6 h-6" />
|
||||
<span className="font-semibold">Level</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{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">
|
||||
{levelText(calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus))}
|
||||
</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)}
|
||||
@@ -141,9 +176,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
moduleColors[selectedModule].inner,
|
||||
)}>
|
||||
<span className="text-xl">Level</span>
|
||||
<span className="text-3xl font-bold">
|
||||
{calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus)}
|
||||
</span>
|
||||
{showLevel(bandScore)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
102
src/exams/Level.tsx
Normal file
102
src/exams/Level.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import {renderExercise} from "@/components/Exercises";
|
||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||
import {renderSolution} from "@/components/Solutions";
|
||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import {LevelExam, UserSolution, WritingExam} from "@/interfaces/exam";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {defaultUserSolutions} from "@/utils/exams";
|
||||
import {countExercises} from "@/utils/moduleUtils";
|
||||
import {mdiArrowRight} from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import clsx from "clsx";
|
||||
import {Fragment, useEffect, useState} from "react";
|
||||
import {toast} from "react-toastify";
|
||||
|
||||
interface Props {
|
||||
exam: LevelExam;
|
||||
showSolutions?: boolean;
|
||||
onFinish: (userSolutions: UserSolution[]) => void;
|
||||
}
|
||||
|
||||
export default function Level({exam, showSolutions = false, onFinish}: Props) {
|
||||
const [questionIndex, setQuestionIndex] = useState(0);
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [exerciseIndex, setExerciseIndex] = useState(0);
|
||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
|
||||
|
||||
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentQuestionIndex(0);
|
||||
}, [questionIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded && exerciseIndex === -1) {
|
||||
setExerciseIndex((prev) => prev + 1);
|
||||
}
|
||||
}, [hasExamEnded, exerciseIndex]);
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
}
|
||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
||||
|
||||
if (exerciseIndex + 1 < exam.exercises.length) {
|
||||
setExerciseIndex((prev) => prev + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (exerciseIndex >= exam.exercises.length) return;
|
||||
|
||||
setHasExamEnded(false);
|
||||
|
||||
if (solution) {
|
||||
onFinish(
|
||||
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "level", exam: exam.id})),
|
||||
);
|
||||
} else {
|
||||
onFinish(userSolutions.map((x) => ({...x, module: "level", exam: exam.id})));
|
||||
}
|
||||
};
|
||||
|
||||
const previousExercise = (solution?: UserSolution) => {
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
}
|
||||
|
||||
if (exerciseIndex > 0) {
|
||||
setExerciseIndex((prev) => prev - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const getExercise = () => {
|
||||
const exercise = exam.exercises[exerciseIndex];
|
||||
return {
|
||||
...exercise,
|
||||
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col h-full w-full gap-8 items-center">
|
||||
<ModuleTitle
|
||||
minTimer={exam.minTimer}
|
||||
exerciseIndex={exerciseIndex + 1 + questionIndex + currentQuestionIndex}
|
||||
module="level"
|
||||
totalExercises={countExercises(exam.exercises)}
|
||||
disableTimer={showSolutions}
|
||||
/>
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
showSolutions &&
|
||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function Listening({exam, showSolutions = false, onFinish}: Props) {
|
||||
const [questionIndex, setQuestionIndex] = useState(0);
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [exerciseIndex, setExerciseIndex] = useState(showSolutions ? 0 : -1);
|
||||
const [partIndex, setPartIndex] = useState(0);
|
||||
const [timesListened, setTimesListened] = useState(0);
|
||||
@@ -33,6 +35,10 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
}
|
||||
}, [hasExamEnded, exerciseIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentQuestionIndex(0);
|
||||
}, [questionIndex]);
|
||||
|
||||
const confirmFinishModule = (keepGoing?: boolean) => {
|
||||
if (!keepGoing) {
|
||||
setShowBlankModal(false);
|
||||
@@ -46,6 +52,7 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
}
|
||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
||||
|
||||
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
||||
setExerciseIndex((prev) => prev + 1);
|
||||
@@ -130,7 +137,10 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
.flatMap((x) => x.exercises)
|
||||
.findIndex(
|
||||
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
|
||||
) || 0) + (exerciseIndex === -1 ? 0 : 1)
|
||||
) || 0) +
|
||||
(exerciseIndex === -1 ? 0 : 1) +
|
||||
questionIndex +
|
||||
currentQuestionIndex
|
||||
}
|
||||
minTimer={exam.minTimer}
|
||||
module="listening"
|
||||
@@ -141,11 +151,11 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), nextExercise, previousExercise)}
|
||||
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||
showSolutions &&
|
||||
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
</div>
|
||||
{exerciseIndex === -1 && partIndex > 0 && (
|
||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||
|
||||
@@ -81,6 +81,8 @@ function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: s
|
||||
}
|
||||
|
||||
export default function Reading({exam, showSolutions = false, onFinish}: Props) {
|
||||
const [questionIndex, setQuestionIndex] = useState(0);
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [exerciseIndex, setExerciseIndex] = useState(showSolutions ? 0 : -1);
|
||||
const [partIndex, setPartIndex] = useState(0);
|
||||
const [showTextModal, setShowTextModal] = useState(false);
|
||||
@@ -105,6 +107,10 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentQuestionIndex(0);
|
||||
}, [questionIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded && exerciseIndex === -1) {
|
||||
setExerciseIndex((prev) => prev + 1);
|
||||
@@ -124,6 +130,7 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
}
|
||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
||||
|
||||
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
||||
setExerciseIndex((prev) => prev + 1);
|
||||
@@ -207,7 +214,10 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
||||
.flatMap((x) => x.exercises)
|
||||
.findIndex(
|
||||
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
|
||||
) || 0) + (exerciseIndex === -1 ? 0 : 1)
|
||||
) || 0) +
|
||||
(exerciseIndex === -1 ? 0 : 1) +
|
||||
questionIndex +
|
||||
currentQuestionIndex
|
||||
}
|
||||
module="reading"
|
||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||
@@ -219,11 +229,11 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), nextExercise, previousExercise)}
|
||||
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||
showSolutions &&
|
||||
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
</div>
|
||||
{exerciseIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
|
||||
<Button
|
||||
|
||||
@@ -4,7 +4,7 @@ import {Module} from "@/interfaces";
|
||||
import clsx from "clsx";
|
||||
import {User} from "@/interfaces/user";
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import {BsBook, BsCheck, BsCheckCircle, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||
import {BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
||||
import {totalExamsByModule} from "@/utils/stats";
|
||||
import useStats from "@/hooks/useStats";
|
||||
import Button from "@/components/Low/Button";
|
||||
@@ -57,6 +57,11 @@ export default function Selection({user, page, onStart, disableSelection = false
|
||||
label: "Speaking",
|
||||
value: totalExamsByModule(stats, "speaking"),
|
||||
},
|
||||
{
|
||||
icon: <BsClipboard className="text-ielts-level w-6 h-6 md:w-8 md:h-8" />,
|
||||
label: "Level",
|
||||
value: totalExamsByModule(stats, "level"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
@@ -87,11 +92,11 @@ export default function Selection({user, page, onStart, disableSelection = false
|
||||
)}
|
||||
</span>
|
||||
</section>
|
||||
<section className="w-full flex -md:flex-col -md:items-center -md:gap-12 justify-between gap-8 mt-8">
|
||||
<section className="w-full flex -lg:flex-col -lg:items-center -lg:gap-12 justify-between gap-8 mt-8">
|
||||
<div
|
||||
onClick={!disableSelection ? () => toggleModule("reading") : undefined}
|
||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
|
||||
className={clsx(
|
||||
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
||||
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
||||
selectedModules.includes("reading") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||
)}>
|
||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-0 -translate-y-1/2">
|
||||
@@ -101,17 +106,18 @@ export default function Selection({user, page, onStart, disableSelection = false
|
||||
<p className="text-center text-xs">
|
||||
Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
|
||||
</p>
|
||||
{!selectedModules.includes("reading") && !disableSelection && (
|
||||
{!selectedModules.includes("reading") && !selectedModules.includes("level") && !disableSelection && (
|
||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
||||
)}
|
||||
{(selectedModules.includes("reading") || disableSelection) && (
|
||||
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
|
||||
</div>
|
||||
<div
|
||||
onClick={!disableSelection ? () => toggleModule("listening") : undefined}
|
||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
|
||||
className={clsx(
|
||||
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
||||
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
||||
selectedModules.includes("listening") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||
)}>
|
||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-0 -translate-y-1/2">
|
||||
@@ -121,17 +127,18 @@ export default function Selection({user, page, onStart, disableSelection = false
|
||||
<p className="text-center text-xs">
|
||||
Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
|
||||
</p>
|
||||
{!selectedModules.includes("listening") && !disableSelection && (
|
||||
{!selectedModules.includes("listening") && !selectedModules.includes("level") && !disableSelection && (
|
||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
||||
)}
|
||||
{(selectedModules.includes("listening") || disableSelection) && (
|
||||
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
|
||||
</div>
|
||||
<div
|
||||
onClick={!disableSelection ? () => toggleModule("writing") : undefined}
|
||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
|
||||
className={clsx(
|
||||
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
||||
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
||||
selectedModules.includes("writing") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||
)}>
|
||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-0 -translate-y-1/2">
|
||||
@@ -141,17 +148,18 @@ export default function Selection({user, page, onStart, disableSelection = false
|
||||
<p className="text-center text-xs">
|
||||
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
|
||||
</p>
|
||||
{!selectedModules.includes("writing") && !disableSelection && (
|
||||
{!selectedModules.includes("writing") && !selectedModules.includes("level") && !disableSelection && (
|
||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
||||
)}
|
||||
{(selectedModules.includes("writing") || disableSelection) && (
|
||||
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
|
||||
</div>
|
||||
<div
|
||||
onClick={!disableSelection ? () => toggleModule("speaking") : undefined}
|
||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
|
||||
className={clsx(
|
||||
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
||||
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
||||
selectedModules.includes("speaking") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||
)}>
|
||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-0 -translate-y-1/2">
|
||||
@@ -161,13 +169,37 @@ export default function Selection({user, page, onStart, disableSelection = false
|
||||
<p className="text-center text-xs">
|
||||
You'll have access to interactive dialogs, pronunciation exercises and speech recordings.
|
||||
</p>
|
||||
{!selectedModules.includes("speaking") && !disableSelection && (
|
||||
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && !disableSelection && (
|
||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
||||
)}
|
||||
{(selectedModules.includes("speaking") || disableSelection) && (
|
||||
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
|
||||
</div>
|
||||
{!disableSelection && (
|
||||
<div
|
||||
onClick={selectedModules.length === 0 || selectedModules.includes("level") ? () => toggleModule("level") : undefined}
|
||||
className={clsx(
|
||||
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
||||
selectedModules.includes("level") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||
)}>
|
||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-0 -translate-y-1/2">
|
||||
<BsClipboard className="text-white w-7 h-7" />
|
||||
</div>
|
||||
<span className="font-semibold">Level:</span>
|
||||
<p className="text-center text-xs">You'll be able to test your english level with multiple choice questions.</p>
|
||||
{!selectedModules.includes("level") && selectedModules.length === 0 && !disableSelection && (
|
||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
||||
)}
|
||||
{(selectedModules.includes("level") || disableSelection) && (
|
||||
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
|
||||
)}
|
||||
{!selectedModules.includes("level") && selectedModules.length > 0 && (
|
||||
<BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<div className="flex w-full -md:flex-col -md:gap-4 -md:justify-center md:justify-between items-center">
|
||||
<div
|
||||
|
||||
@@ -20,14 +20,21 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function Speaking({exam, showSolutions = false, onFinish}: Props) {
|
||||
const [questionIndex, setQuestionIndex] = useState(0);
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [exerciseIndex, setExerciseIndex] = useState(0);
|
||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
|
||||
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentQuestionIndex(0);
|
||||
}, [questionIndex]);
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
}
|
||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
||||
|
||||
if (exerciseIndex + 1 < exam.exercises.length) {
|
||||
setExerciseIndex((prev) => prev + 1);
|
||||
@@ -71,7 +78,7 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
||||
<ModuleTitle
|
||||
label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
|
||||
minTimer={exam.minTimer}
|
||||
exerciseIndex={exerciseIndex + 1}
|
||||
exerciseIndex={exerciseIndex + 1 + questionIndex + currentQuestionIndex}
|
||||
module="speaking"
|
||||
totalExercises={countExercises(exam.exercises)}
|
||||
disableTimer={showSolutions}
|
||||
@@ -79,11 +86,11 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), nextExercise, previousExercise)}
|
||||
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
showSolutions &&
|
||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {initializeApp} from "firebase/app";
|
||||
import * as admin from "firebase-admin/app";
|
||||
import { getStorage } from "firebase/storage";
|
||||
|
||||
const serviceAccount = require("@/constants/serviceAccountKey.json");
|
||||
|
||||
@@ -10,7 +11,6 @@ const firebaseConfig = {
|
||||
storageBucket: process.env.FIREBASE_STORAGE_BUCKET || "",
|
||||
messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID || "",
|
||||
appId: process.env.FIREBASE_APP_ID || "",
|
||||
measurementId: process.env.FIREBASE_MEASUREMENT_ID || "",
|
||||
};
|
||||
|
||||
export const app = initializeApp(firebaseConfig, Math.random().toString());
|
||||
@@ -20,3 +20,4 @@ export const adminApp = admin.initializeApp(
|
||||
},
|
||||
Math.random().toString(),
|
||||
);
|
||||
export const storage = getStorage(app);
|
||||
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,
|
||||
}
|
||||
}
|
||||
22
src/hooks/usePackages.tsx
Normal file
22
src/hooks/usePackages.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import {Exam} from "@/interfaces/exam";
|
||||
import {Package} from "@/interfaces/paypal";
|
||||
import axios from "axios";
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
export default function usePackages() {
|
||||
const [packages, setPackages] = useState<Package[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const getData = () => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<Package[]>("/api/packages")
|
||||
.then((response) => setPackages(response.data))
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
useEffect(getData, []);
|
||||
|
||||
return {packages, isLoading, isError, reload: getData};
|
||||
}
|
||||
24
src/hooks/usePayments.tsx
Normal file
24
src/hooks/usePayments.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import {Payment} from "@/interfaces/paypal";
|
||||
import {Group, User} from "@/interfaces/user";
|
||||
import axios from "axios";
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
export default function usePayments() {
|
||||
const [payments, setPayments] = useState<Payment[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const getData = () => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<Payment[]>("/api/payments")
|
||||
.then((response) => {
|
||||
return setPayments(response.data);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
useEffect(getData, []);
|
||||
|
||||
return {payments, isLoading, isError, reload: getData};
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function useUsers() {
|
||||
const getData = () => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<User[]>("/api/users/list")
|
||||
.get<User[]>("/api/users/list", {headers: {page: "register"}})
|
||||
.then((response) => setUsers(response.data))
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import {Module} from ".";
|
||||
|
||||
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam;
|
||||
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
|
||||
|
||||
export interface ReadingExam {
|
||||
parts: {
|
||||
text: {
|
||||
title: string;
|
||||
content: string;
|
||||
};
|
||||
exercises: Exercise[];
|
||||
}[];
|
||||
parts: ReadingPart[];
|
||||
id: string;
|
||||
module: "reading";
|
||||
minTimer: number;
|
||||
@@ -17,21 +11,40 @@ export interface ReadingExam {
|
||||
isDiagnostic: boolean;
|
||||
}
|
||||
|
||||
export interface ReadingPart {
|
||||
text: {
|
||||
title: string;
|
||||
content: string;
|
||||
};
|
||||
exercises: Exercise[];
|
||||
}
|
||||
|
||||
export interface LevelExam {
|
||||
module: "level";
|
||||
id: string;
|
||||
exercises: Exercise[];
|
||||
minTimer: number;
|
||||
isDiagnostic: boolean;
|
||||
}
|
||||
|
||||
export interface ListeningExam {
|
||||
parts: {
|
||||
audio: {
|
||||
source: string;
|
||||
repeatableTimes: number; // *The amount of times the user is allowed to repeat the audio, 0 for unlimited
|
||||
};
|
||||
exercises: Exercise[];
|
||||
}[];
|
||||
parts: ListeningPart[];
|
||||
id: string;
|
||||
module: "listening";
|
||||
minTimer: number;
|
||||
isDiagnostic: boolean;
|
||||
}
|
||||
|
||||
export interface ListeningPart {
|
||||
audio: {
|
||||
source: string;
|
||||
repeatableTimes: number; // *The amount of times the user is allowed to repeat the audio, 0 for unlimited
|
||||
};
|
||||
exercises: Exercise[];
|
||||
}
|
||||
|
||||
export interface UserSolution {
|
||||
id?: string;
|
||||
solutions: any[];
|
||||
module?: Module;
|
||||
exam?: string;
|
||||
@@ -80,6 +93,18 @@ export interface Evaluation {
|
||||
overall: number;
|
||||
task_response: {[key: string]: number};
|
||||
}
|
||||
|
||||
interface InteractiveSpeakingEvaluation extends Evaluation {
|
||||
perfect_answer_1?: string;
|
||||
perfect_answer_2?: string;
|
||||
perfect_answer_3?: string;
|
||||
}
|
||||
|
||||
interface CommonEvaluation extends Evaluation {
|
||||
perfect_answer?: string;
|
||||
perfect_answer_1?: string;
|
||||
}
|
||||
|
||||
export interface WritingExercise {
|
||||
id: string;
|
||||
type: "writing";
|
||||
@@ -94,7 +119,7 @@ export interface WritingExercise {
|
||||
userSolutions: {
|
||||
id: string;
|
||||
solution: string;
|
||||
evaluation?: Evaluation;
|
||||
evaluation?: CommonEvaluation;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -108,7 +133,7 @@ export interface SpeakingExercise {
|
||||
userSolutions: {
|
||||
id: string;
|
||||
solution: string;
|
||||
evaluation?: Evaluation;
|
||||
evaluation?: CommonEvaluation;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -121,7 +146,7 @@ export interface InteractiveSpeakingExercise {
|
||||
userSolutions: {
|
||||
id: string;
|
||||
solution: {question: string; answer: string}[];
|
||||
evaluation?: Evaluation;
|
||||
evaluation?: InteractiveSpeakingEvaluation;
|
||||
}[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
export type Module = "reading" | "listening" | "writing" | "speaking";
|
||||
export type Module = "reading" | "listening" | "writing" | "speaking" | "level";
|
||||
|
||||
37
src/interfaces/paypal.ts
Normal file
37
src/interfaces/paypal.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export interface TokenSuccess {
|
||||
scope: string;
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
app_id: string;
|
||||
expires_in: number;
|
||||
nonce: string;
|
||||
}
|
||||
|
||||
export interface TokenError {
|
||||
error: string;
|
||||
error_description: string;
|
||||
}
|
||||
|
||||
export interface Package {
|
||||
id: string;
|
||||
currency: string;
|
||||
duration: number;
|
||||
duration_unit: DurationUnit;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export type DurationUnit = "weeks" | "days" | "months" | "years";
|
||||
|
||||
export interface Payment {
|
||||
id: string;
|
||||
corporate: string;
|
||||
agent?: string;
|
||||
agentCommission: number;
|
||||
agentValue: number;
|
||||
currency: string;
|
||||
value: number;
|
||||
isPaid: boolean;
|
||||
date: Date | string;
|
||||
corporateTransfer?: string;
|
||||
commissionTransfer?: string;
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export interface Assignment {
|
||||
type: "academic" | "general";
|
||||
stats: Stat[];
|
||||
}[];
|
||||
exams: {id: string; module: Module}[];
|
||||
exams: {id: string; module: Module, assignee: string}[];
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||
1
src/interfaces/storage.files.ts
Normal file
1
src/interfaces/storage.files.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type FilesStorage = "commission" | "corporate";
|
||||
@@ -1,11 +1,12 @@
|
||||
import {Module} from ".";
|
||||
|
||||
export interface User {
|
||||
export type User = StudentUser | TeacherUser | CorporateUser | AgentUser | AdminUser | DeveloperUser;
|
||||
|
||||
export interface BasicUser {
|
||||
email: string;
|
||||
name: string;
|
||||
profilePicture: string;
|
||||
id: string;
|
||||
experience: number;
|
||||
isFirstLogin: boolean;
|
||||
focus: "academic" | "general";
|
||||
levels: {[key in Module]: number};
|
||||
@@ -13,20 +14,57 @@ export interface User {
|
||||
type: Type;
|
||||
bio: string;
|
||||
isVerified: boolean;
|
||||
demographicInformation?: DemographicInformation;
|
||||
corporateInformation?: CorporateInformation;
|
||||
subscriptionExpirationDate?: null | Date;
|
||||
registrationDate?: Date;
|
||||
status: "active" | "disabled" | "paymentDue";
|
||||
}
|
||||
|
||||
export interface StudentUser extends BasicUser {
|
||||
type: "student";
|
||||
demographicInformation?: DemographicInformation;
|
||||
}
|
||||
|
||||
export interface TeacherUser extends BasicUser {
|
||||
type: "teacher";
|
||||
demographicInformation?: DemographicInformation;
|
||||
}
|
||||
|
||||
export interface CorporateUser extends BasicUser {
|
||||
type: "corporate";
|
||||
corporateInformation: CorporateInformation;
|
||||
demographicInformation?: DemographicCorporateInformation;
|
||||
}
|
||||
|
||||
export interface AgentUser extends BasicUser {
|
||||
type: "agent";
|
||||
agentInformation: AgentInformation;
|
||||
demographicInformation?: DemographicInformation;
|
||||
}
|
||||
|
||||
export interface AdminUser extends BasicUser {
|
||||
type: "admin";
|
||||
demographicInformation?: DemographicInformation;
|
||||
}
|
||||
|
||||
export interface DeveloperUser extends BasicUser {
|
||||
type: "developer";
|
||||
demographicInformation?: DemographicInformation;
|
||||
}
|
||||
|
||||
export interface CorporateInformation {
|
||||
companyInformation: CompanyInformation;
|
||||
monthlyDuration: number;
|
||||
payment?: {
|
||||
value: number;
|
||||
currency: string;
|
||||
commission: number;
|
||||
};
|
||||
allowedUserAmount?: number;
|
||||
referralAgent?: string;
|
||||
}
|
||||
|
||||
export interface AgentInformation {
|
||||
companyName: string;
|
||||
commercialRegistration: string;
|
||||
}
|
||||
|
||||
export interface CompanyInformation {
|
||||
@@ -41,6 +79,13 @@ export interface DemographicInformation {
|
||||
employment: EmploymentStatus;
|
||||
}
|
||||
|
||||
export interface DemographicCorporateInformation {
|
||||
country: string;
|
||||
phone: string;
|
||||
gender: Gender;
|
||||
position: string;
|
||||
}
|
||||
|
||||
export type Gender = "male" | "female" | "other";
|
||||
export type EmploymentStatus = "employed" | "student" | "self-employed" | "unemployed" | "retired" | "other";
|
||||
export const EMPLOYMENT_STATUS: {status: EmploymentStatus; label: string}[] = [
|
||||
@@ -53,6 +98,7 @@ export const EMPLOYMENT_STATUS: {status: EmploymentStatus; label: string}[] = [
|
||||
];
|
||||
|
||||
export interface Stat {
|
||||
id: string;
|
||||
user: string;
|
||||
exam: string;
|
||||
exercise: string;
|
||||
@@ -78,5 +124,5 @@ export interface Group {
|
||||
disableEditing?: boolean;
|
||||
}
|
||||
|
||||
export type Type = "student" | "teacher" | "corporate" | "owner" | "developer" | "agent";
|
||||
export const userTypes: Type[] = ["student", "teacher", "corporate", "owner", "developer", "agent"];
|
||||
export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent";
|
||||
export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent"];
|
||||
|
||||
@@ -14,5 +14,6 @@ export const sessionOptions: IronSessionOptions = {
|
||||
declare module "iron-session" {
|
||||
interface IronSessionData {
|
||||
user?: User | null;
|
||||
envVariables?: {[key: string]: string};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import Checkbox from "@/components/Low/Checkbox";
|
||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {Type, User} from "@/interfaces/user";
|
||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize} from "lodash";
|
||||
@@ -17,6 +19,9 @@ export default function BatchCodeGenerator({user}: {user: User}) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
|
||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||
const [type, setType] = useState<Type>("student");
|
||||
|
||||
const {users} = useUsers();
|
||||
|
||||
const {openFilePicker, filesContent} = useFilePicker({
|
||||
accept: ".txt",
|
||||
@@ -38,15 +43,18 @@ export default function BatchCodeGenerator({user}: {user: User}) {
|
||||
const file = filesContent[0];
|
||||
const emails = file.content
|
||||
.split("\n")
|
||||
.filter((x) => new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/).test(x));
|
||||
.map((x) => x.trim())
|
||||
.filter((x) => new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/).test(x))
|
||||
.filter((x) => !users.map((u) => u.email).includes(x));
|
||||
|
||||
if (emails.length === 0) {
|
||||
toast.error("Please upload a .txt file containing e-mails, one per line!");
|
||||
toast.error("Please upload a .txt file containing e-mails, one per line! All already registered e-mails have also been ignored!");
|
||||
return;
|
||||
}
|
||||
|
||||
setEmails([...new Set(emails)]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filesContent]);
|
||||
|
||||
const generateCode = (type: Type) => {
|
||||
@@ -63,12 +71,12 @@ export default function BatchCodeGenerator({user}: {user: User}) {
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
toast.error(`You do not have permission to generate ${capitalize(type)} codes!`, {toastId: "forbidden"});
|
||||
toast.error(data.reason, {toastId: "forbidden"});
|
||||
}
|
||||
})
|
||||
.catch(({response: {status}}) => {
|
||||
.catch(({response: {status, data}}) => {
|
||||
if (status === 403) {
|
||||
toast.error(`You do not have permission to generate ${capitalize(type)} codes!`, {toastId: "forbidden"});
|
||||
toast.error(data.reason, {toastId: "forbidden"});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -83,7 +91,7 @@ export default function BatchCodeGenerator({user}: {user: User}) {
|
||||
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
|
||||
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
||||
</Button>
|
||||
{user && (user.type === "developer" || user.type === "owner") && (
|
||||
{user && (user.type === "developer" || user.type === "admin") && (
|
||||
<>
|
||||
<div className="flex -md:flex-row md:flex-col -md:items-center 2xl:flex-row 2xl:items-center justify-between gap-2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
|
||||
@@ -108,37 +116,20 @@ export default function BatchCodeGenerator({user}: {user: User}) {
|
||||
)}
|
||||
<label className="font-normal text-base text-mti-gray-dim">Select the type of user they should be</label>
|
||||
{user && (
|
||||
<div className="grid -md:grid-cols-2 md:grid-cols-1 xl:grid-cols-2 gap-4 place-items-center">
|
||||
<Button
|
||||
className="w-44 2xl:w-48"
|
||||
variant="outline"
|
||||
onClick={() => generateCode("student")}
|
||||
disabled={emails.length === 0 || isLoading || !PERMISSIONS.generateCode.student.includes(user.type)}>
|
||||
Student
|
||||
</Button>
|
||||
<Button
|
||||
className="w-44 2xl:w-48"
|
||||
variant="outline"
|
||||
onClick={() => generateCode("teacher")}
|
||||
disabled={emails.length === 0 || isLoading || !PERMISSIONS.generateCode.teacher.includes(user.type)}>
|
||||
Teacher
|
||||
</Button>
|
||||
<Button
|
||||
className="w-44 2xl:w-48"
|
||||
variant="outline"
|
||||
onClick={() => generateCode("corporate")}
|
||||
disabled={emails.length === 0 || isLoading || !PERMISSIONS.generateCode.corporate.includes(user.type)}>
|
||||
Admin
|
||||
</Button>
|
||||
<Button
|
||||
className="w-44 2xl:w-48"
|
||||
variant="outline"
|
||||
onClick={() => generateCode("owner")}
|
||||
disabled={emails.length === 0 || isLoading || !PERMISSIONS.generateCode.owner.includes(user.type)}>
|
||||
Owner
|
||||
</Button>
|
||||
</div>
|
||||
<select
|
||||
defaultValue="student"
|
||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
||||
{Object.keys(USER_TYPE_LABELS).map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Button onClick={() => generateCode(type)} disabled={emails.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}>
|
||||
Generate & Send
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import Button from "@/components/Low/Button";
|
||||
import Checkbox from "@/components/Low/Checkbox";
|
||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||
import {Type, User} from "@/interfaces/user";
|
||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize} from "lodash";
|
||||
@@ -15,6 +16,7 @@ export default function CodeGenerator({user}: {user: User}) {
|
||||
const [generatedCode, setGeneratedCode] = useState<string>();
|
||||
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
|
||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||
const [type, setType] = useState<Type>("student");
|
||||
|
||||
useEffect(() => {
|
||||
if (user && (user.type === "corporate" || user.type === "teacher")) {
|
||||
@@ -40,12 +42,12 @@ export default function CodeGenerator({user}: {user: User}) {
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
toast.error(`You do not have permission to generate a ${capitalize(type)} code!`, {toastId: "forbidden"});
|
||||
toast.error(data.reason, {toastId: "forbidden"});
|
||||
}
|
||||
})
|
||||
.catch(({response: {status}}) => {
|
||||
.catch(({response: {status, data}}) => {
|
||||
if (status === 403) {
|
||||
toast.error(`You do not have permission to generate a ${capitalize(type)} code!`, {toastId: "forbidden"});
|
||||
toast.error(data.reason, {toastId: "forbidden"});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,38 +59,18 @@ export default function CodeGenerator({user}: {user: User}) {
|
||||
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
||||
<label className="font-normal text-base text-mti-gray-dim">User Code Generator</label>
|
||||
{user && (
|
||||
<div className="grid -md:grid-cols-2 md:grid-cols-1 place-items-center 2xl:grid-cols-2 gap-4">
|
||||
<Button
|
||||
className="w-44 2xl:w-48"
|
||||
variant="outline"
|
||||
onClick={() => generateCode("student")}
|
||||
disabled={!PERMISSIONS.generateCode.student.includes(user.type) || (isExpiryDateEnabled && expiryDate === null)}>
|
||||
Student
|
||||
</Button>
|
||||
<Button
|
||||
className="w-44 2xl:w-48"
|
||||
variant="outline"
|
||||
onClick={() => generateCode("teacher")}
|
||||
disabled={!PERMISSIONS.generateCode.teacher.includes(user.type) || (isExpiryDateEnabled && expiryDate === null)}>
|
||||
Teacher
|
||||
</Button>
|
||||
<Button
|
||||
className="w-44 2xl:w-48"
|
||||
variant="outline"
|
||||
onClick={() => generateCode("corporate")}
|
||||
disabled={!PERMISSIONS.generateCode.corporate.includes(user.type) || (isExpiryDateEnabled && expiryDate === null)}>
|
||||
Admin
|
||||
</Button>
|
||||
<Button
|
||||
className="w-44 2xl:w-48"
|
||||
variant="outline"
|
||||
onClick={() => generateCode("owner")}
|
||||
disabled={!PERMISSIONS.generateCode.owner.includes(user.type) || (isExpiryDateEnabled && expiryDate === null)}>
|
||||
Owner
|
||||
</Button>
|
||||
</div>
|
||||
<select
|
||||
defaultValue="student"
|
||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
||||
{Object.keys(USER_TYPE_LABELS).map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{user && (user.type === "developer" || user.type === "owner") && (
|
||||
{user && (user.type === "developer" || user.type === "admin") && (
|
||||
<>
|
||||
<div className="flex -md:flex-row md:flex-col -md:items-center 2xl:flex-row 2xl:items-center justify-between gap-2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
|
||||
@@ -111,6 +93,9 @@ export default function CodeGenerator({user}: {user: User}) {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button onClick={() => generateCode(type)} disabled={isExpiryDateEnabled ? !expiryDate : false}>
|
||||
Generate
|
||||
</Button>
|
||||
<label className="font-normal text-base text-mti-gray-dim">Generated Code:</label>
|
||||
<div
|
||||
className={clsx(
|
||||
|
||||
50
src/pages/(admin)/ExamGenerator.tsx
Normal file
50
src/pages/(admin)/ExamGenerator.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import Input from "@/components/Low/Input";
|
||||
import {Module} from "@/interfaces";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||
import {RadioGroup} from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize} from "lodash";
|
||||
import {useRouter} from "next/router";
|
||||
import {FormEvent, useState} from "react";
|
||||
import {toast} from "react-toastify";
|
||||
|
||||
export default function ExamGenerator() {
|
||||
const [selectedModule, setSelectedModule] = useState<Module>();
|
||||
const [examId, setExamId] = useState<string>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const setExams = useExamStore((state) => state.setExams);
|
||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const generateExam = (module: Module) => {
|
||||
axios.get(`/api/exam/${module}/generate`).then((result) => console.log(result.data));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Exam Generator</label>
|
||||
<div className="w-full grid grid-cols-2 gap-2">
|
||||
{MODULE_ARRAY.map((module) => (
|
||||
<Button
|
||||
onClick={() => generateExam(module)}
|
||||
key={module}
|
||||
className={clsx(
|
||||
"w-full min-w-[200px]",
|
||||
module === "reading" && "!bg-ielts-reading/80 !border-ielts-reading hover:!bg-ielts-reading",
|
||||
module === "listening" && "!bg-ielts-listening/80 !border-ielts-listening hover:!bg-ielts-listening",
|
||||
module === "writing" && "!bg-ielts-writing/80 !border-ielts-writing hover:!bg-ielts-writing",
|
||||
module === "speaking" && "!bg-ielts-speaking/80 !border-ielts-speaking hover:!bg-ielts-speaking",
|
||||
)}>
|
||||
{capitalize(module)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -69,8 +69,8 @@ export default function ExamLoader() {
|
||||
</RadioGroup.Option>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<Input type="text" name="examId" onChange={setExamId} placeholder="Exam ID" className="-md:!w-full md:!w-44 2xl:!w-full" />
|
||||
<Button disabled={!selectedModule || !examId} isLoading={isLoading} className="-md:!w-full md:!w-44 2xl:!w-full">
|
||||
<Input type="text" name="examId" onChange={setExamId} placeholder="Exam ID" className="w-full" />
|
||||
<Button disabled={!selectedModule || !examId} isLoading={isLoading} className="w-full">
|
||||
Load Exam
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -20,6 +20,7 @@ const CLASSES: {[key in Module]: string} = {
|
||||
listening: "text-ielts-listening",
|
||||
speaking: "text-ielts-speaking",
|
||||
writing: "text-ielts-writing",
|
||||
level: "text-ielts-level",
|
||||
};
|
||||
|
||||
const columnHelper = createColumnHelper<Exam>();
|
||||
@@ -130,7 +131,7 @@ export default function ExamList({user}: {user: User}) {
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th className="py-4" key={header.id}>
|
||||
<th className="p-4 text-left" key={header.id}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {toast} from "react-toastify";
|
||||
import Select from "react-select";
|
||||
import {uuidv4} from "@firebase/util";
|
||||
import {useFilePicker} from "use-file-picker";
|
||||
import Modal from "@/components/Modal";
|
||||
|
||||
const columnHelper = createColumnHelper<Group>();
|
||||
|
||||
@@ -23,10 +24,10 @@ interface CreateDialogProps {
|
||||
user: User;
|
||||
users: User[];
|
||||
group?: Group;
|
||||
onCreate: (group: Group) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const CreatePanel = ({user, users, group, onCreate}: CreateDialogProps) => {
|
||||
const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
||||
const [name, setName] = useState<string | undefined>(group?.name || undefined);
|
||||
const [admin, setAdmin] = useState<string>(group?.admin || user.id);
|
||||
const [participants, setParticipants] = useState<string[]>(group?.participants || []);
|
||||
@@ -51,7 +52,7 @@ const CreatePanel = ({user, users, group, onCreate}: CreateDialogProps) => {
|
||||
const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined);
|
||||
const filteredUsers = emailUsers.filter(
|
||||
(x) =>
|
||||
((user.type === "developer" || user.type === "owner" || user.type === "corporate") &&
|
||||
((user.type === "developer" || user.type === "admin" || user.type === "corporate") &&
|
||||
(x?.type === "student" || x?.type === "teacher")) ||
|
||||
(user.type === "teacher" && x?.type === "student"),
|
||||
);
|
||||
@@ -66,6 +67,24 @@ const CreatePanel = ({user, users, group, onCreate}: CreateDialogProps) => {
|
||||
}
|
||||
}, [filesContent, user.type, users]);
|
||||
|
||||
const submit = () => {
|
||||
if (name !== group?.name && (name === "Students" || name === "Teachers")) {
|
||||
toast.error("That group name is reserved and cannot be used, please enter another one.");
|
||||
return;
|
||||
}
|
||||
|
||||
(group ? axios.patch : axios.post)(group ? `/api/groups/${group.id}` : "/api/groups", {name, admin, participants})
|
||||
.then(() => {
|
||||
toast.success(`Group "${name}" ${group ? "edited" : "created"} successfully`);
|
||||
return true;
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
return false;
|
||||
})
|
||||
.finally(onClose);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-12 mt-4 w-full px-4 py-2">
|
||||
<div className="flex flex-col gap-8">
|
||||
@@ -106,18 +125,14 @@ const CreatePanel = ({user, users, group, onCreate}: CreateDialogProps) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full max-w-[200px] self-end"
|
||||
disabled={!name}
|
||||
onClick={() => {
|
||||
if (name !== group?.name && (name === "Students" || name === "Teachers")) {
|
||||
toast.error("That group name is reserved and cannot be used, please enter another one.");
|
||||
return;
|
||||
}
|
||||
onCreate({name: name!, admin, participants, id: group?.id || uuidv4()});
|
||||
}}>
|
||||
{!group ? "Create" : "Update"}
|
||||
</Button>
|
||||
<div className="flex w-full justify-end items-center gap-8 mt-8">
|
||||
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full max-w-[200px]" onClick={submit} disabled={!name}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -125,56 +140,19 @@ const CreatePanel = ({user, users, group, onCreate}: CreateDialogProps) => {
|
||||
const filterTypes = ["corporate", "teacher"];
|
||||
|
||||
export default function GroupList({user}: {user: User}) {
|
||||
const [editingID, setEditingID] = useState<string>();
|
||||
const [showDisclosure, setShowDisclosure] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [editingGroup, setEditingGroup] = useState<Group>();
|
||||
const [filterByUser, setFilterByUser] = useState(false);
|
||||
|
||||
const {users} = useUsers();
|
||||
const {groups, reload} = useGroups(user && filterTypes.includes(user?.type) ? user.id : undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingID) setShowDisclosure(true);
|
||||
}, [editingID]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showDisclosure) document.getElementById("disclosure")?.scrollTo();
|
||||
if (!showDisclosure) setEditingID(undefined);
|
||||
}, [showDisclosure]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user && (user.type === "corporate" || user.type === "teacher")) {
|
||||
setFilterByUser(true);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const createGroup = (group: Group) => {
|
||||
return axios
|
||||
.post<{ok: boolean}>("/api/groups", group)
|
||||
.then(() => {
|
||||
toast.success(`Group "${group.name}" created successfully`);
|
||||
return true;
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
return false;
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
|
||||
const updateGroup = (group: Group) => {
|
||||
return axios
|
||||
.patch<{ok: boolean}>(`/api/groups/${group.id}`, group)
|
||||
.then(() => {
|
||||
toast.success(`Group "${group.name}" created successfully`);
|
||||
return true;
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
return false;
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
|
||||
const deleteGroup = (group: Group) => {
|
||||
if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return;
|
||||
|
||||
@@ -216,10 +194,10 @@ export default function GroupList({user}: {user: User}) {
|
||||
cell: ({row}: {row: {original: Group}}) => {
|
||||
return (
|
||||
<>
|
||||
{(user?.type === "developer" || user?.type === "owner" || user.id === row.original.admin) && (
|
||||
{user && (user.type === "developer" || user.type === "admin" || user.id === row.original.admin) && (
|
||||
<div className="flex gap-2">
|
||||
{editingID !== row.original.id && (
|
||||
<div data-tip="Edit" className="cursor-pointer tooltip" onClick={() => setEditingID(row.original.id)}>
|
||||
{!row.original.disableEditing && (
|
||||
<div data-tip="Edit" className="cursor-pointer tooltip" onClick={() => setEditingGroup(row.original)}>
|
||||
<BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
)}
|
||||
@@ -242,8 +220,32 @@ export default function GroupList({user}: {user: User}) {
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const closeModal = () => {
|
||||
setIsCreating(false);
|
||||
setEditingGroup(undefined);
|
||||
reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full h-full rounded-xl">
|
||||
<Modal isOpen={isCreating || !!editingGroup} onClose={closeModal} title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}>
|
||||
<CreatePanel
|
||||
group={editingGroup}
|
||||
user={user}
|
||||
onClose={closeModal}
|
||||
users={
|
||||
user?.type === "corporate" || user?.type === "teacher"
|
||||
? users.filter(
|
||||
(u) =>
|
||||
groups
|
||||
.filter((g) => g.admin === user.id)
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(u.id) || groups.flatMap((g) => g.participants).includes(u.id),
|
||||
)
|
||||
: users
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
@@ -268,54 +270,12 @@ export default function GroupList({user}: {user: User}) {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
"w-full px-4 py-2 bg-mti-purple-ultralight/40 flex gap-2 items-center justify-center rounded-lg",
|
||||
"transition duration-300 ease-in-out",
|
||||
"hover:bg-mti-purple-ultralight cursor-pointer",
|
||||
)}
|
||||
onClick={() => setShowDisclosure((prev) => !prev)}>
|
||||
{!showDisclosure ? <BsPlus className="w-6 h-6" /> : <BsDash className="w-6 h-6" />}
|
||||
|
||||
<span>{!showDisclosure ? "Create group" : "Cancel"}</span>
|
||||
</div>
|
||||
<Transition
|
||||
show={showDisclosure}
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0">
|
||||
<div id="#disclosure">
|
||||
<CreatePanel
|
||||
group={editingID ? groups.find((x) => x.id === editingID) : undefined}
|
||||
user={user}
|
||||
users={
|
||||
user?.type === "corporate" || user?.type === "teacher"
|
||||
? users.filter(
|
||||
(u) =>
|
||||
groups
|
||||
.filter((g) => g.admin === user.id)
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(u.id) || groups.flatMap((g) => g.participants).includes(u.id),
|
||||
)
|
||||
: users
|
||||
}
|
||||
onCreate={(group) => {
|
||||
(!editingID ? createGroup : updateGroup)(group).then((result) => {
|
||||
if (result) {
|
||||
setShowDisclosure(false);
|
||||
setEditingID(undefined);
|
||||
reload();
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</>
|
||||
</>
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white">
|
||||
New Group
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
261
src/pages/(admin)/Lists/PackageList.tsx
Normal file
261
src/pages/(admin)/Lists/PackageList.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import Input from "@/components/Low/Input";
|
||||
import Modal from "@/components/Modal";
|
||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||
import useExams from "@/hooks/useExams";
|
||||
import usePackages from "@/hooks/usePackages";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {Module} from "@/interfaces";
|
||||
import {Exam} from "@/interfaces/exam";
|
||||
import {Package} from "@/interfaces/paypal";
|
||||
import {Type, User} from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {countExercises} from "@/utils/moduleUtils";
|
||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize} from "lodash";
|
||||
import {useRouter} from "next/router";
|
||||
import {useState} from "react";
|
||||
import {BsCheck, BsPencil, BsTrash, BsUpload} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import Select from "react-select";
|
||||
import {CURRENCIES} from "@/resources/paypal";
|
||||
import Button from "@/components/Low/Button";
|
||||
|
||||
const CLASSES: {[key in Module]: string} = {
|
||||
reading: "text-ielts-reading",
|
||||
listening: "text-ielts-listening",
|
||||
speaking: "text-ielts-speaking",
|
||||
writing: "text-ielts-writing",
|
||||
level: "text-ielts-level",
|
||||
};
|
||||
|
||||
const columnHelper = createColumnHelper<Package>();
|
||||
|
||||
type DurationUnit = "days" | "weeks" | "months" | "years";
|
||||
|
||||
function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void}) {
|
||||
const [duration, setDuration] = useState(pack?.duration || 1);
|
||||
const [unit, setUnit] = useState<DurationUnit>(pack?.duration_unit || "months");
|
||||
|
||||
const [price, setPrice] = useState(pack?.price || 0);
|
||||
const [currency, setCurrency] = useState<string>(pack?.currency || "EUR");
|
||||
|
||||
const submit = () => {
|
||||
(pack ? axios.patch : axios.post)(pack ? `/api/packages/${pack.id}` : "/api/packages", {
|
||||
duration,
|
||||
duration_unit: unit,
|
||||
price,
|
||||
currency,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("New payment has been created successfully!");
|
||||
onClose();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 py-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Price *</label>
|
||||
<div className="flex gap-4 items-center">
|
||||
<Input defaultValue={price} name="price" type="number" onChange={(e) => setPrice(parseInt(e))} />
|
||||
|
||||
<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"
|
||||
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")}
|
||||
value={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
|
||||
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>
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Duration *</label>
|
||||
<div className="flex gap-4 items-center">
|
||||
<Input defaultValue={duration} name="duration" type="number" onChange={(e) => setDuration(parseInt(e))} />
|
||||
<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"
|
||||
options={[
|
||||
{value: "days", label: "Days"},
|
||||
{value: "weeks", label: "Weeks"},
|
||||
{value: "months", label: "Months"},
|
||||
{value: "years", label: "Years"},
|
||||
]}
|
||||
defaultValue={{value: "months", label: "Months"}}
|
||||
onChange={(value) => setUnit((value?.value as DurationUnit) || "months")}
|
||||
value={{value: unit, label: capitalize(unit)}}
|
||||
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>
|
||||
<div className="flex w-full justify-end items-center gap-8 mt-8">
|
||||
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full max-w-[200px]" onClick={submit} disabled={!duration || !price}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PackageList({user}: {user: User}) {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [editingPackage, setEditingPackage] = useState<Package>();
|
||||
|
||||
const {packages, reload} = usePackages();
|
||||
|
||||
const deletePackage = async (pack: Package) => {
|
||||
if (!confirm(`Are you sure you want to delete this package?`)) return;
|
||||
|
||||
axios
|
||||
.delete(`/api/packages/${pack.id}`)
|
||||
.then(() => toast.success(`Deleted the "${pack.id}" exam`))
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Package not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to delete this exam!");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
|
||||
const defaultColumns = [
|
||||
columnHelper.accessor("id", {
|
||||
header: "ID",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("duration", {
|
||||
header: "Duration",
|
||||
cell: (info) => (
|
||||
<span>
|
||||
{info.getValue()} {info.row.original.duration_unit}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("price", {
|
||||
header: "Price",
|
||||
cell: (info) => (
|
||||
<span>
|
||||
{info.getValue()} {info.row.original.currency}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
{
|
||||
header: "",
|
||||
id: "actions",
|
||||
cell: ({row}: {row: {original: Package}}) => {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{["developer", "admin"].includes(user.type) && (
|
||||
<div data-tip="Edit" className="cursor-pointer tooltip" onClick={() => setEditingPackage(row.original)}>
|
||||
<BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
)}
|
||||
{["developer", "admin"].includes(user.type) && (
|
||||
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deletePackage(row.original)}>
|
||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: packages,
|
||||
columns: defaultColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const closeModal = () => {
|
||||
setIsCreating(false);
|
||||
setEditingPackage(undefined);
|
||||
reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full rounded-xl">
|
||||
<Modal
|
||||
isOpen={isCreating || !!editingPackage}
|
||||
onClose={closeModal}
|
||||
title={editingPackage ? `Editing ${editingPackage.id}` : "New Package"}>
|
||||
<PackageCreator onClose={closeModal} pack={editingPackage} />
|
||||
</Modal>
|
||||
<table className="bg-mti-purple-ultralight/40 w-full">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th className="p-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" key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white">
|
||||
New Package
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -16,26 +16,48 @@ import {countries, TCountries} from "countries-list";
|
||||
import countryCodes from "country-codes-list";
|
||||
import Modal from "@/components/Modal";
|
||||
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>();
|
||||
|
||||
export default function UserList({user, filter}: {user: User; filter?: (user: User) => boolean}) {
|
||||
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>();
|
||||
const [displayUsers, setDisplayUsers] = useState<User[]>([]);
|
||||
const [selectedUser, setSelectedUser] = useState<User>();
|
||||
|
||||
const {users, reload} = useUsers();
|
||||
const {groups} = useGroups(user ? user.id : undefined);
|
||||
const {groups} = useGroups(user && (user?.type === "corporate" || user?.type === "teacher") ? user.id : undefined);
|
||||
|
||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||
const router = useRouter();
|
||||
|
||||
const expirationDateColor = (date: Date) => {
|
||||
const momentDate = moment(date);
|
||||
const today = moment(new Date());
|
||||
|
||||
if (today.isAfter(momentDate)) return "!text-mti-red-light font-bold line-through";
|
||||
if (today.add(1, "weeks").isAfter(momentDate)) return "!text-mti-red-light";
|
||||
if (today.add(2, "weeks").isAfter(momentDate)) return "!text-mti-rose-light";
|
||||
if (today.add(1, "months").isAfter(momentDate)) return "!text-mti-orange-light";
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user && users) {
|
||||
const filterUsers =
|
||||
user.type === "corporate" || user.type === "student"
|
||||
user.type === "corporate" || user.type === "teacher"
|
||||
? users.filter((u) => groups.flatMap((g) => g.participants).includes(u.id))
|
||||
: users;
|
||||
|
||||
const filteredUsers = filter ? filterUsers.filter(filter) : filterUsers;
|
||||
const filteredUsers = filters.reduce((d, f) => d.filter(f), filterUsers);
|
||||
|
||||
setDisplayUsers([...filteredUsers.sort(sortFunction)]);
|
||||
}
|
||||
@@ -149,13 +171,13 @@ export default function UserList({user, filter}: {user: User; filter?: (user: Us
|
||||
onClick={() => updateAccountType(row.original, "corporate")}
|
||||
className="text-sm !py-2 !px-4"
|
||||
disabled={row.original.type === "corporate" || !PERMISSIONS.generateCode["corporate"].includes(user.type)}>
|
||||
Admin
|
||||
Corporate
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => updateAccountType(row.original, "owner")}
|
||||
onClick={() => updateAccountType(row.original, "admin")}
|
||||
className="text-sm !py-2 !px-4"
|
||||
disabled={row.original.type === "owner" || !PERMISSIONS.generateCode["owner"].includes(user.type)}>
|
||||
Owner
|
||||
disabled={row.original.type === "admin" || !PERMISSIONS.generateCode["admin"].includes(user.type)}>
|
||||
Admin
|
||||
</Button>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
@@ -231,14 +253,15 @@ export default function UserList({user, filter}: {user: User; filter?: (user: Us
|
||||
cell: (info) => info.getValue() || "Not available",
|
||||
enableSorting: true,
|
||||
}),
|
||||
columnHelper.accessor("demographicInformation.employment", {
|
||||
columnHelper.accessor((x) => (x.type === "corporate" ? x.demographicInformation?.position : x.demographicInformation?.employment), {
|
||||
id: "employment",
|
||||
header: (
|
||||
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "employment"))}>
|
||||
<span>Employment</span>
|
||||
<span>Employment/Position</span>
|
||||
<SorterArrow name="employment" />
|
||||
</button>
|
||||
) as any,
|
||||
cell: (info) => capitalize(info.getValue()) || "Not available",
|
||||
cell: (info) => (info.row.original.type === "corporate" ? info.getValue() : capitalize(info.getValue())) || "Not available",
|
||||
enableSorting: true,
|
||||
}),
|
||||
columnHelper.accessor("demographicInformation.gender", {
|
||||
@@ -277,7 +300,7 @@ export default function UserList({user, filter}: {user: User; filter?: (user: Us
|
||||
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
|
||||
)}
|
||||
onClick={() => (PERMISSIONS.updateExpiryDate[row.original.type].includes(user.type) ? setSelectedUser(row.original) : null)}>
|
||||
{getValue()}
|
||||
{row.original.type === "corporate" ? row.original.corporateInformation?.companyInformation?.name || getValue() : getValue()}
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
@@ -306,7 +329,16 @@ export default function UserList({user, filter}: {user: User; filter?: (user: Us
|
||||
<SorterArrow name="type" />
|
||||
</button>
|
||||
) as any,
|
||||
cell: (info) => capitalize(info.getValue()),
|
||||
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: (
|
||||
@@ -315,7 +347,11 @@ export default function UserList({user, filter}: {user: User; filter?: (user: Us
|
||||
<SorterArrow name="expiryDate" />
|
||||
</button>
|
||||
) as any,
|
||||
cell: (info) => (!info.getValue() ? "No expiry date" : moment(info.getValue()).format("DD/MM/YYYY")),
|
||||
cell: (info) => (
|
||||
<span className={clsx(info.getValue() ? expirationDateColor(moment(info.getValue()).toDate()) : "")}>
|
||||
{!info.getValue() ? "No expiry date" : moment(info.getValue()).format("DD/MM/YYYY")}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("isVerified", {
|
||||
header: (
|
||||
@@ -357,6 +393,14 @@ export default function UserList({user, filter}: {user: User; filter?: (user: Us
|
||||
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);
|
||||
@@ -404,13 +448,14 @@ export default function UserList({user, filter}: {user: User; filter?: (user: Us
|
||||
}
|
||||
|
||||
if (sorter === "employment" || sorter === reverseString("employment")) {
|
||||
if (!a.demographicInformation?.employment && b.demographicInformation?.employment) return sorter === "employment" ? -1 : 1;
|
||||
if (a.demographicInformation?.employment && !b.demographicInformation?.employment) return sorter === "employment" ? 1 : -1;
|
||||
if (!a.demographicInformation?.employment && !b.demographicInformation?.employment) return 0;
|
||||
const aSortingItem = a.type === "corporate" ? a.demographicInformation?.position : a.demographicInformation?.employment;
|
||||
const bSortingItem = b.type === "corporate" ? b.demographicInformation?.position : b.demographicInformation?.employment;
|
||||
|
||||
return sorter === "employment"
|
||||
? a.demographicInformation!.employment.localeCompare(b.demographicInformation!.employment)
|
||||
: b.demographicInformation!.employment.localeCompare(a.demographicInformation!.employment);
|
||||
if (!aSortingItem && bSortingItem) return sorter === "employment" ? -1 : 1;
|
||||
if (aSortingItem && !bSortingItem) return sorter === "employment" ? 1 : -1;
|
||||
if (!aSortingItem && !bSortingItem) return 0;
|
||||
|
||||
return sorter === "employment" ? aSortingItem!.localeCompare(bSortingItem!) : bSortingItem!.localeCompare(aSortingItem!);
|
||||
}
|
||||
|
||||
if (sorter === "gender" || sorter === reverseString("gender")) {
|
||||
@@ -423,11 +468,28 @@ export default function UserList({user, filter}: {user: User; filter?: (user: Us
|
||||
: 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(),
|
||||
});
|
||||
@@ -439,6 +501,67 @@ export default function UserList({user, filter}: {user: User; filter?: (user: Us
|
||||
{selectedUser && (
|
||||
<div className="w-full flex flex-col gap-8">
|
||||
<UserCard
|
||||
loggedInUser={user}
|
||||
onViewStudents={
|
||||
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
||||
? () => {
|
||||
appendUserFilters({
|
||||
id: "view-students",
|
||||
filter: (x: User) => x.type === "student",
|
||||
});
|
||||
appendUserFilters({
|
||||
id: "belongs-to-admin",
|
||||
filter: (x: User) =>
|
||||
groups
|
||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(x.id),
|
||||
});
|
||||
|
||||
router.push("/list/users");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onViewTeachers={
|
||||
selectedUser.type === "corporate" || selectedUser.type === "student"
|
||||
? () => {
|
||||
appendUserFilters({
|
||||
id: "view-teachers",
|
||||
filter: (x: User) => x.type === "teacher",
|
||||
});
|
||||
appendUserFilters({
|
||||
id: "belongs-to-admin",
|
||||
filter: (x: User) =>
|
||||
groups
|
||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(x.id),
|
||||
});
|
||||
|
||||
router.push("/list/users");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onViewCorporate={
|
||||
selectedUser.type === "teacher" || selectedUser.type === "student"
|
||||
? () => {
|
||||
appendUserFilters({
|
||||
id: "view-corporate",
|
||||
filter: (x: User) => x.type === "corporate",
|
||||
});
|
||||
appendUserFilters({
|
||||
id: "belongs-to-admin",
|
||||
filter: (x: User) =>
|
||||
groups
|
||||
.filter((g) => g.participants.includes(selectedUser.id))
|
||||
.flatMap((g) => [g.admin, ...g.participants])
|
||||
.includes(x.id),
|
||||
});
|
||||
|
||||
router.push("/list/users");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onClose={(shouldReload) => {
|
||||
setSelectedUser(undefined);
|
||||
if (shouldReload) reload();
|
||||
@@ -449,30 +572,33 @@ export default function UserList({user, filter}: {user: User; filter?: (user: Us
|
||||
)}
|
||||
</>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {Tab} from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import ExamList from "./ExamList";
|
||||
import GroupList from "./GroupList";
|
||||
import PackageList from "./PackageList";
|
||||
import UserList from "./UserList";
|
||||
|
||||
export default function Lists({user}: {user: User}) {
|
||||
@@ -44,6 +45,19 @@ export default function Lists({user}: {user: User}) {
|
||||
}>
|
||||
Group List
|
||||
</Tab>
|
||||
{user && ["developer", "admin"].includes(user.type) && (
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
|
||||
)
|
||||
}>
|
||||
Package List
|
||||
</Tab>
|
||||
)}
|
||||
</Tab.List>
|
||||
<Tab.Panels className="mt-2">
|
||||
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
||||
@@ -57,6 +71,11 @@ export default function Lists({user}: {user: User}) {
|
||||
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
||||
<GroupList user={user} />
|
||||
</Tab.Panel>
|
||||
{user && ["developer", "admin"].includes(user.type) && (
|
||||
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
||||
<PackageList user={user} />
|
||||
</Tab.Panel>
|
||||
)}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
);
|
||||
|
||||
@@ -30,7 +30,6 @@ export default function EmailVerification({user, isLoading, setIsLoading}: Props
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider className="max-w-xs lg:max-w-md" />
|
||||
<div className="flex flex-col items-center gap-6 w-full -lg:px-8 lg:w-1/2 relative">
|
||||
<h4 className="font-semibold text-2xl text-mti-purple-light">Please confirm your account!</h4>
|
||||
<span className="text-center">
|
||||
|
||||
@@ -22,6 +22,7 @@ import {evaluateSpeakingAnswer, evaluateWritingAnswer} from "@/utils/evaluation"
|
||||
import {useRouter} from "next/router";
|
||||
import {getExam} from "@/utils/exams";
|
||||
import {capitalize} from "lodash";
|
||||
import Level from "@/exams/Level";
|
||||
|
||||
interface Props {
|
||||
page: "exams" | "exercises";
|
||||
@@ -36,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]);
|
||||
@@ -93,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!,
|
||||
@@ -110,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) =>
|
||||
@@ -136,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);
|
||||
});
|
||||
}
|
||||
@@ -182,6 +219,11 @@ export default function ExamPage({page}: Props) {
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
level: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
};
|
||||
|
||||
answers.forEach((x) => {
|
||||
@@ -244,20 +286,15 @@ export default function ExamPage({page}: Props) {
|
||||
return <Speaking exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
|
||||
}
|
||||
|
||||
if (exam && exam.module === "level") {
|
||||
return <Level exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
|
||||
}
|
||||
|
||||
return <>Loading...</>;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{capitalize(page).toString()} | EnCoach</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<ToastContainer />
|
||||
{user && (
|
||||
<Layout
|
||||
|
||||
210
src/pages/(generation)/LevelGeneration.tsx
Normal file
210
src/pages/(generation)/LevelGeneration.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import {LevelExam, MultipleChoiceExercise} from "@/interfaces/exam";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {playSound} from "@/utils/sound";
|
||||
import {Tab} from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {useRouter} from "next/router";
|
||||
import {useState} from "react";
|
||||
import {BsArrowRepeat} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import {v4} from "uuid";
|
||||
|
||||
const TaskTab = ({exam, setExam}: {exam?: LevelExam; setExam: (exam: LevelExam) => void}) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const generate = () => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get(`/api/exam/level/generate/level`)
|
||||
.then((result) => {
|
||||
playSound("check");
|
||||
console.log(result.data);
|
||||
setExam(result.data);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
toast.error("Something went wrong!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Tab.Panel className="w-full bg-ielts-level/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
|
||||
<div className="flex gap-4 items-end">
|
||||
<button
|
||||
onClick={generate}
|
||||
disabled={isLoading}
|
||||
className={clsx(
|
||||
"bg-ielts-level/70 border border-ielts-level text-white w-full px-6 py-6 rounded-xl h-[70px]",
|
||||
"hover:bg-ielts-level disabled:bg-ielts-level/40 disabled:cursor-not-allowed",
|
||||
"transition ease-in-out duration-300",
|
||||
)}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
||||
</div>
|
||||
) : (
|
||||
"Generate"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className="w-fit h-fit mt-12 self-center animate-pulse flex flex-col gap-8 items-center">
|
||||
<span className={clsx("loading loading-infinity w-32 text-ielts-level")} />
|
||||
<span className={clsx("font-bold text-2xl text-ielts-level")}>Generating...</span>
|
||||
</div>
|
||||
)}
|
||||
{exam && (
|
||||
<div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide h-full">
|
||||
{exam.exercises
|
||||
.filter((x) => x.type === "multipleChoice")
|
||||
.map((ex) => {
|
||||
const exercise = ex as MultipleChoiceExercise;
|
||||
|
||||
return (
|
||||
<div key={ex.id} className="w-full h-full flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<span className="text-xl font-semibold">Multiple Choice</span>
|
||||
<span className="rounded-xl bg-white border border-ielts-level p-1 px-4 w-fit">
|
||||
{exercise.questions.length} questions
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{exercise.questions.map((question) => (
|
||||
<div key={question.id} className="flex flex-col gap-1">
|
||||
<span className="font-semibold">
|
||||
{question.id}. {question.prompt}
|
||||
</span>
|
||||
<span>
|
||||
<span className="font-semibold text-ielts-level">({question.solution})</span>{" "}
|
||||
{question.options.find((o) => o.id === question.solution)?.text}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Tab.Panel>
|
||||
);
|
||||
};
|
||||
|
||||
const LevelGeneration = () => {
|
||||
const [generatedExam, setGeneratedExam] = useState<LevelExam>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [resultingExam, setResultingExam] = useState<LevelExam>();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const setExams = useExamStore((state) => state.setExams);
|
||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||
|
||||
const loadExam = async (examId: string) => {
|
||||
const exam = await getExamById("level", examId.trim());
|
||||
if (!exam) {
|
||||
toast.error("Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID", {
|
||||
toastId: "invalid-exam-id",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setExams([exam]);
|
||||
setSelectedModules(["level"]);
|
||||
|
||||
router.push("/exercises");
|
||||
};
|
||||
|
||||
const submitExam = () => {
|
||||
if (!generatedExam) {
|
||||
toast.error("Please generate all tasks before submitting");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const exam: LevelExam = {
|
||||
...generatedExam,
|
||||
isDiagnostic: false,
|
||||
minTimer: 25,
|
||||
module: "level",
|
||||
id: v4(),
|
||||
};
|
||||
|
||||
axios
|
||||
.post(`/api/exam/level`, exam)
|
||||
.then((result) => {
|
||||
playSound("sent");
|
||||
console.log(`Generated Exam ID: ${result.data.id}`);
|
||||
toast.success("This new exam has been generated successfully! Check the ID in our browser's console.");
|
||||
setResultingExam(result.data);
|
||||
|
||||
setGeneratedExam(undefined);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
toast.error("Something went wrong while generating, please try again later.");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tab.Group>
|
||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1">
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-level/70",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-level focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-level",
|
||||
)
|
||||
}>
|
||||
Exam
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<TaskTab exam={generatedExam} setExam={setGeneratedExam} />
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
<div className="w-full flex justify-end gap-4">
|
||||
{resultingExam && (
|
||||
<button
|
||||
disabled={isLoading}
|
||||
onClick={() => loadExam(resultingExam.id)}
|
||||
className={clsx(
|
||||
"bg-white border border-ielts-level text-ielts-level w-full max-w-[200px] rounded-xl h-[70px] self-end",
|
||||
"hover:bg-ielts-level hover:text-white disabled:bg-ielts-level/40 disabled:cursor-not-allowed",
|
||||
"transition ease-in-out duration-300",
|
||||
)}>
|
||||
Perform Exam
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
disabled={!generatedExam || isLoading}
|
||||
data-tip="Please generate all three passages"
|
||||
onClick={submitExam}
|
||||
className={clsx(
|
||||
"bg-ielts-level/70 border border-ielts-level text-white w-full max-w-[200px] rounded-xl h-[70px] self-end",
|
||||
"hover:bg-ielts-level disabled:bg-ielts-level/40 disabled:cursor-not-allowed",
|
||||
"transition ease-in-out duration-300",
|
||||
!generatedExam && "tooltip",
|
||||
)}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
||||
</div>
|
||||
) : (
|
||||
"Submit"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LevelGeneration;
|
||||
289
src/pages/(generation)/ListeningGeneration.tsx
Normal file
289
src/pages/(generation)/ListeningGeneration.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import Input from "@/components/Low/Input";
|
||||
import {Exercise, ListeningExam} from "@/interfaces/exam";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {playSound} from "@/utils/sound";
|
||||
import {convertCamelCaseToReadable} from "@/utils/string";
|
||||
import {Tab} from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {useRouter} from "next/router";
|
||||
import {useState} from "react";
|
||||
import {BsArrowRepeat} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
|
||||
const PartTab = ({part, types, index, setPart}: {part?: ListeningPart; types: string[]; index: number; setPart: (part?: ListeningPart) => void}) => {
|
||||
const [topic, setTopic] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const generate = () => {
|
||||
const url = new URLSearchParams();
|
||||
if (topic) url.append("topic", topic);
|
||||
if (types) types.forEach((t) => url.append("exercises", t));
|
||||
|
||||
setPart(undefined);
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get(`/api/exam/listening/generate/listening_section_${index}${topic || types ? `?${url.toString()}` : ""}`)
|
||||
.then((result) => {
|
||||
playSound("check");
|
||||
setPart(result.data);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
toast.error("Something went wrong!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Tab.Panel className="w-full bg-ielts-listening/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
|
||||
<div className="flex gap-4 items-end">
|
||||
<Input type="text" placeholder="Grand Canyon..." name="topic" label="Topic" onChange={setTopic} roundness="xl" defaultValue={topic} />
|
||||
<button
|
||||
onClick={generate}
|
||||
disabled={isLoading}
|
||||
data-tip="The passage is currently being generated"
|
||||
className={clsx(
|
||||
"bg-ielts-listening/70 border border-ielts-listening text-white w-full max-w-[200px] rounded-xl h-[70px]",
|
||||
"hover:bg-ielts-listening disabled:bg-ielts-listening/40 disabled:cursor-not-allowed",
|
||||
"transition ease-in-out duration-300",
|
||||
isLoading && "tooltip",
|
||||
)}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
||||
</div>
|
||||
) : (
|
||||
"Generate"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className="w-fit h-fit mt-12 self-center animate-pulse flex flex-col gap-8 items-center">
|
||||
<span className={clsx("loading loading-infinity w-32 text-ielts-listening")} />
|
||||
<span className={clsx("font-bold text-2xl text-ielts-listening")}>Generating...</span>
|
||||
</div>
|
||||
)}
|
||||
{part && (
|
||||
<div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide">
|
||||
<div className="flex gap-4">
|
||||
{part.exercises.map((x) => (
|
||||
<span className="rounded-xl bg-white border border-ielts-listening p-1 px-4" key={x.id}>
|
||||
{x.type && convertCamelCaseToReadable(x.type)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{typeof part.text === "string" && <span className="w-full h-96">{part.text.replaceAll("\n\n", " ")}</span>}
|
||||
{typeof part.text !== "string" && (
|
||||
<div className="w-full h-96 flex flex-col gap-2">
|
||||
{part.text.conversation.map((x, index) => (
|
||||
<span key={index} className="flex gap-1">
|
||||
<span className="font-semibold">{x.name}:</span>
|
||||
{x.text.replaceAll("\n\n", " ")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Tab.Panel>
|
||||
);
|
||||
};
|
||||
|
||||
interface ListeningPart {
|
||||
exercises: Exercise[];
|
||||
text:
|
||||
| {
|
||||
conversation: {
|
||||
gender: string;
|
||||
name: string;
|
||||
text: string;
|
||||
voice: string;
|
||||
}[];
|
||||
}
|
||||
| string;
|
||||
}
|
||||
|
||||
const ListeningGeneration = () => {
|
||||
const [part1, setPart1] = useState<ListeningPart>();
|
||||
const [part2, setPart2] = useState<ListeningPart>();
|
||||
const [part3, setPart3] = useState<ListeningPart>();
|
||||
const [part4, setPart4] = useState<ListeningPart>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [resultingExam, setResultingExam] = useState<ListeningExam>();
|
||||
const [types, setTypes] = useState<string[]>([]);
|
||||
|
||||
const availableTypes = [
|
||||
{type: "multipleChoice", label: "Multiple Choice"},
|
||||
{type: "writeBlanksQuestions", label: "Write the Blanks: Questions"},
|
||||
{type: "writeBlanksFill", label: "Write the Blanks: Fill"},
|
||||
{type: "writeBlanksForm", label: "Write the Blanks: Form"},
|
||||
];
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const setExams = useExamStore((state) => state.setExams);
|
||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||
|
||||
const toggleType = (type: string) => setTypes((prev) => (prev.includes(type) ? [...prev.filter((x) => x !== type)] : [...prev, type]));
|
||||
|
||||
const submitExam = () => {
|
||||
if (!part1 || !part2 || !part3 || !part4) return toast.error("Please generate all for sections!");
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
axios
|
||||
.post(`/api/exam/listening/generate/listening`, {parts: [part1, part2, part3, part4]})
|
||||
.then((result) => {
|
||||
playSound("sent");
|
||||
console.log(`Generated Exam ID: ${result.data.id}`);
|
||||
toast.success("This new exam has been generated successfully! Check the ID in our browser's console.");
|
||||
setResultingExam(result.data);
|
||||
|
||||
setPart1(undefined);
|
||||
setPart2(undefined);
|
||||
setPart3(undefined);
|
||||
setPart4(undefined);
|
||||
setTypes([]);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
toast.error("Something went wrong!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
const loadExam = async (examId: string) => {
|
||||
const exam = await getExamById("listening", examId.trim());
|
||||
if (!exam) {
|
||||
toast.error("Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID", {
|
||||
toastId: "invalid-exam-id",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setExams([exam]);
|
||||
setSelectedModules(["listening"]);
|
||||
|
||||
router.push("/exercises");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Exercises</label>
|
||||
<div className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
|
||||
{availableTypes.map((x) => (
|
||||
<span
|
||||
onClick={() => toggleType(x.type)}
|
||||
key={x.type}
|
||||
className={clsx(
|
||||
"px-6 py-4 w-64 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"transition duration-300 ease-in-out",
|
||||
!types.includes(x.type)
|
||||
? "bg-white border-mti-gray-platinum"
|
||||
: "bg-ielts-listening/70 border-ielts-listening text-white",
|
||||
)}>
|
||||
{x.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tab.Group>
|
||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-listening/20 p-1">
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-listening focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-listening",
|
||||
)
|
||||
}>
|
||||
Section 1
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-listening focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-listening",
|
||||
)
|
||||
}>
|
||||
Section 2
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-listening focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-listening",
|
||||
)
|
||||
}>
|
||||
Section 3
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-listening/70",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-listening focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-listening",
|
||||
)
|
||||
}>
|
||||
Section 4
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
{[
|
||||
{part: part1, setPart: setPart1},
|
||||
{part: part2, setPart: setPart2},
|
||||
{part: part3, setPart: setPart3},
|
||||
{part: part4, setPart: setPart4},
|
||||
].map(({part, setPart}, index) => (
|
||||
<PartTab part={part} types={types} index={index + 1} key={index} setPart={setPart} />
|
||||
))}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
<div className="w-full flex justify-end gap-4">
|
||||
{resultingExam && (
|
||||
<button
|
||||
disabled={isLoading}
|
||||
onClick={() => loadExam(resultingExam.id)}
|
||||
className={clsx(
|
||||
"bg-white border border-ielts-listening text-ielts-listening w-full max-w-[200px] rounded-xl h-[70px] self-end",
|
||||
"hover:bg-ielts-listening hover:text-white disabled:bg-ielts-listening/40 disabled:cursor-not-allowed",
|
||||
"transition ease-in-out duration-300",
|
||||
)}>
|
||||
Perform Exam
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
disabled={!part1 || !part2 || !part3 || !part4 || isLoading}
|
||||
data-tip="Please generate all three passages"
|
||||
onClick={submitExam}
|
||||
className={clsx(
|
||||
"bg-ielts-listening/70 border border-ielts-listening text-white w-full max-w-[200px] rounded-xl h-[70px] self-end",
|
||||
"hover:bg-ielts-listening disabled:bg-ielts-listening/40 disabled:cursor-not-allowed",
|
||||
"transition ease-in-out duration-300",
|
||||
(!part1 || !part2 || !part3 || !part4) && "tooltip",
|
||||
)}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
||||
</div>
|
||||
) : (
|
||||
"Submit"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListeningGeneration;
|
||||
262
src/pages/(generation)/ReadingGeneration.tsx
Normal file
262
src/pages/(generation)/ReadingGeneration.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import Input from "@/components/Low/Input";
|
||||
import {ReadingExam, ReadingPart} from "@/interfaces/exam";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {playSound} from "@/utils/sound";
|
||||
import {convertCamelCaseToReadable} from "@/utils/string";
|
||||
import {Tab} from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {useRouter} from "next/router";
|
||||
import {useState} from "react";
|
||||
import {BsArrowRepeat} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import {v4} from "uuid";
|
||||
|
||||
const PartTab = ({part, types, index, setPart}: {part?: ReadingPart; types: string[]; index: number; setPart: (part?: ReadingPart) => void}) => {
|
||||
const [topic, setTopic] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const generate = () => {
|
||||
const url = new URLSearchParams();
|
||||
if (topic) url.append("topic", topic);
|
||||
if (types) types.forEach((t) => url.append("exercises", t));
|
||||
|
||||
setPart(undefined);
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get(`/api/exam/reading/generate/reading_passage_${index}${topic || types ? `?${url.toString()}` : ""}`)
|
||||
.then((result) => {
|
||||
playSound("check");
|
||||
setPart(result.data);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
toast.error("Something went wrong!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Tab.Panel className="w-full bg-ielts-reading/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
|
||||
<div className="flex gap-4 items-end">
|
||||
<Input type="text" placeholder="Grand Canyon..." name="topic" label="Topic" onChange={setTopic} roundness="xl" defaultValue={topic} />
|
||||
<button
|
||||
onClick={generate}
|
||||
disabled={isLoading}
|
||||
data-tip="The passage is currently being generated"
|
||||
className={clsx(
|
||||
"bg-ielts-reading/70 border border-ielts-reading text-white w-full max-w-[200px] rounded-xl h-[70px]",
|
||||
"hover:bg-ielts-reading disabled:bg-ielts-reading/40 disabled:cursor-not-allowed",
|
||||
"transition ease-in-out duration-300",
|
||||
isLoading && "tooltip",
|
||||
)}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
||||
</div>
|
||||
) : (
|
||||
"Generate"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className="w-fit h-fit mt-12 self-center animate-pulse flex flex-col gap-8 items-center">
|
||||
<span className={clsx("loading loading-infinity w-32 text-ielts-reading")} />
|
||||
<span className={clsx("font-bold text-2xl text-ielts-reading")}>Generating...</span>
|
||||
</div>
|
||||
)}
|
||||
{part && (
|
||||
<div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide">
|
||||
<div className="flex gap-4">
|
||||
{part.exercises.map((x) => (
|
||||
<span className="rounded-xl bg-white border border-ielts-reading p-1 px-4" key={x.id}>
|
||||
{x.type && convertCamelCaseToReadable(x.type)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold">{part.text.title}</h3>
|
||||
<span className="w-full h-96">{part.text.content}</span>
|
||||
</div>
|
||||
)}
|
||||
</Tab.Panel>
|
||||
);
|
||||
};
|
||||
|
||||
const ReadingGeneration = () => {
|
||||
const [part1, setPart1] = useState<ReadingPart>();
|
||||
const [part2, setPart2] = useState<ReadingPart>();
|
||||
const [part3, setPart3] = useState<ReadingPart>();
|
||||
const [types, setTypes] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [resultingExam, setResultingExam] = useState<ReadingExam>();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const setExams = useExamStore((state) => state.setExams);
|
||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||
|
||||
const availableTypes = [
|
||||
{type: "fillBlanks", label: "Fill the Blanks"},
|
||||
{type: "multipleChoice", label: "Multiple Choice"},
|
||||
{type: "trueFalse", label: "True or False"},
|
||||
{type: "writeBlanks", label: "Write the Blanks"},
|
||||
];
|
||||
|
||||
const toggleType = (type: string) => setTypes((prev) => (prev.includes(type) ? [...prev.filter((x) => x !== type)] : [...prev, type]));
|
||||
|
||||
const loadExam = async (examId: string) => {
|
||||
const exam = await getExamById("reading", examId.trim());
|
||||
if (!exam) {
|
||||
toast.error("Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID", {
|
||||
toastId: "invalid-exam-id",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setExams([exam]);
|
||||
setSelectedModules(["reading"]);
|
||||
|
||||
router.push("/exercises");
|
||||
};
|
||||
|
||||
const submitExam = () => {
|
||||
if (!part1 || !part2 || !part3) {
|
||||
toast.error("Please generate all three passages before submitting");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const exam: ReadingExam = {
|
||||
parts: [part1, part2, part3],
|
||||
isDiagnostic: false,
|
||||
minTimer: 60,
|
||||
module: "reading",
|
||||
id: v4(),
|
||||
type: "academic",
|
||||
};
|
||||
|
||||
axios
|
||||
.post(`/api/exam/reading`, exam)
|
||||
.then((result) => {
|
||||
playSound("sent");
|
||||
console.log(`Generated Exam ID: ${result.data.id}`);
|
||||
toast.success("This new exam has been generated successfully! Check the ID in our browser's console.");
|
||||
setResultingExam(result.data);
|
||||
|
||||
setPart1(undefined);
|
||||
setPart2(undefined);
|
||||
setPart3(undefined);
|
||||
setTypes([]);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
toast.error("Something went wrong while generating, please try again later.");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Exercises</label>
|
||||
<div className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
|
||||
{availableTypes.map((x) => (
|
||||
<span
|
||||
onClick={() => toggleType(x.type)}
|
||||
key={x.type}
|
||||
className={clsx(
|
||||
"px-6 py-4 w-64 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"transition duration-300 ease-in-out",
|
||||
!types.includes(x.type) ? "bg-white border-mti-gray-platinum" : "bg-ielts-reading/70 border-ielts-reading text-white",
|
||||
)}>
|
||||
{x.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tab.Group>
|
||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-reading/20 p-1">
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-reading/70",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-reading focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-reading",
|
||||
)
|
||||
}>
|
||||
Passage 1
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-reading/70",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-reading focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-reading",
|
||||
)
|
||||
}>
|
||||
Passage 2
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-reading/70",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-reading focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-reading",
|
||||
)
|
||||
}>
|
||||
Passage 3
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
{[
|
||||
{part: part1, setPart: setPart1},
|
||||
{part: part2, setPart: setPart2},
|
||||
{part: part3, setPart: setPart3},
|
||||
].map(({part, setPart}, index) => (
|
||||
<PartTab part={part} types={types} index={index + 1} key={index} setPart={setPart} />
|
||||
))}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
<div className="w-full flex justify-end gap-4">
|
||||
{resultingExam && (
|
||||
<button
|
||||
disabled={isLoading}
|
||||
onClick={() => loadExam(resultingExam.id)}
|
||||
className={clsx(
|
||||
"bg-white border border-ielts-reading text-ielts-reading w-full max-w-[200px] rounded-xl h-[70px] self-end",
|
||||
"hover:bg-ielts-reading hover:text-white disabled:bg-ielts-reading/40 disabled:cursor-not-allowed",
|
||||
"transition ease-in-out duration-300",
|
||||
)}>
|
||||
Perform Exam
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
disabled={!part1 || !part2 || !part3 || isLoading}
|
||||
data-tip="Please generate all three passages"
|
||||
onClick={submitExam}
|
||||
className={clsx(
|
||||
"bg-ielts-reading/70 border border-ielts-reading text-white w-full max-w-[200px] rounded-xl h-[70px] self-end",
|
||||
"hover:bg-ielts-reading disabled:bg-ielts-reading/40 disabled:cursor-not-allowed",
|
||||
"transition ease-in-out duration-300",
|
||||
(!part1 || !part2 || !part3) && "tooltip",
|
||||
)}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
||||
</div>
|
||||
) : (
|
||||
"Submit"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReadingGeneration;
|
||||
234
src/pages/(generation)/SpeakingGeneration.tsx
Normal file
234
src/pages/(generation)/SpeakingGeneration.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import Input from "@/components/Low/Input";
|
||||
import {Exercise, SpeakingExam} from "@/interfaces/exam";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {playSound} from "@/utils/sound";
|
||||
import {convertCamelCaseToReadable} from "@/utils/string";
|
||||
import {Tab} from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {useRouter} from "next/router";
|
||||
import {useState} from "react";
|
||||
import {BsArrowRepeat} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
|
||||
const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; setPart: (part?: SpeakingPart) => void}) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const generate = () => {
|
||||
setPart(undefined);
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get(`/api/exam/speaking/generate/speaking_task_${index}`)
|
||||
.then((result) => {
|
||||
playSound("check");
|
||||
setPart(result.data);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
toast.error("Something went wrong!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Tab.Panel className="w-full bg-ielts-speaking/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
|
||||
<div className="flex gap-4 items-end">
|
||||
<button
|
||||
onClick={generate}
|
||||
disabled={isLoading}
|
||||
data-tip="The passage is currently being generated"
|
||||
className={clsx(
|
||||
"bg-ielts-speaking/70 border border-ielts-speaking text-white w-full rounded-xl h-[70px]",
|
||||
"hover:bg-ielts-speaking disabled:bg-ielts-speaking/40 disabled:cursor-not-allowed",
|
||||
"transition ease-in-out duration-300",
|
||||
isLoading && "tooltip",
|
||||
)}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
||||
</div>
|
||||
) : (
|
||||
"Generate"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className="w-fit h-fit mt-12 self-center animate-pulse flex flex-col gap-8 items-center">
|
||||
<span className={clsx("loading loading-infinity w-32 text-ielts-speaking")} />
|
||||
<span className={clsx("font-bold text-2xl text-ielts-speaking")}>Generating...</span>
|
||||
</div>
|
||||
)}
|
||||
{part && (
|
||||
<div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide h-96">
|
||||
<h3 className="text-xl font-semibold">{part.topic}</h3>
|
||||
{part.question && <span className="w-full">{part.question}</span>}
|
||||
{part.questions && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{part.questions.map((question, index) => (
|
||||
<span className="w-full" key={index}>
|
||||
- {question}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{part.prompts && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">You should talk about the following things:</span>
|
||||
{part.prompts.map((prompt, index) => (
|
||||
<span className="w-full" key={index}>
|
||||
- {prompt}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Tab.Panel>
|
||||
);
|
||||
};
|
||||
|
||||
interface SpeakingPart {
|
||||
prompts?: string[];
|
||||
question?: string;
|
||||
questions?: string[];
|
||||
topic: string;
|
||||
}
|
||||
|
||||
const SpeakingGeneration = () => {
|
||||
const [part1, setPart1] = useState<SpeakingPart>();
|
||||
const [part2, setPart2] = useState<SpeakingPart>();
|
||||
const [part3, setPart3] = useState<SpeakingPart>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [resultingExam, setResultingExam] = useState<SpeakingExam>();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const setExams = useExamStore((state) => state.setExams);
|
||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||
|
||||
const submitExam = () => {
|
||||
if (!part1 || !part2 || !part3) return toast.error("Please generate all for tasks!");
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
axios
|
||||
.post(`/api/exam/speaking/generate/speaking`, {exercises: [part1, part2, part3]})
|
||||
.then((result) => {
|
||||
playSound("sent");
|
||||
console.log(`Generated Exam ID: ${result.data.id}`);
|
||||
toast.success("This new exam has been generated successfully! Check the ID in our browser's console.");
|
||||
setResultingExam(result.data);
|
||||
|
||||
setPart1(undefined);
|
||||
setPart2(undefined);
|
||||
setPart3(undefined);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
toast.error("Something went wrong!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
const loadExam = async (examId: string) => {
|
||||
const exam = await getExamById("speaking", examId.trim());
|
||||
if (!exam) {
|
||||
toast.error("Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID", {
|
||||
toastId: "invalid-exam-id",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setExams([exam]);
|
||||
setSelectedModules(["speaking"]);
|
||||
|
||||
router.push("/exercises");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tab.Group>
|
||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/70",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||
)
|
||||
}>
|
||||
Task 1
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/70",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||
)
|
||||
}>
|
||||
Task 2
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/70",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||
)
|
||||
}>
|
||||
Task 3
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
{[
|
||||
{part: part1, setPart: setPart1},
|
||||
{part: part2, setPart: setPart2},
|
||||
{part: part3, setPart: setPart3},
|
||||
].map(({part, setPart}, index) => (
|
||||
<PartTab part={part} index={index + 1} key={index} setPart={setPart} />
|
||||
))}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
<div className="w-full flex justify-end gap-4">
|
||||
{resultingExam && (
|
||||
<button
|
||||
disabled={isLoading}
|
||||
onClick={() => loadExam(resultingExam.id)}
|
||||
className={clsx(
|
||||
"bg-white border border-ielts-speaking text-ielts-speaking w-full max-w-[200px] rounded-xl h-[70px] self-end",
|
||||
"hover:bg-ielts-speaking hover:text-white disabled:bg-ielts-speaking/40 disabled:cursor-not-allowed",
|
||||
"transition ease-in-out duration-300",
|
||||
)}>
|
||||
Perform Exam
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
disabled={!part1 || !part2 || !part3 || isLoading}
|
||||
data-tip="Please generate all three passages"
|
||||
onClick={submitExam}
|
||||
className={clsx(
|
||||
"bg-ielts-speaking/70 border border-ielts-speaking text-white w-full max-w-[200px] rounded-xl h-[70px] self-end",
|
||||
"hover:bg-ielts-speaking disabled:bg-ielts-speaking/40 disabled:cursor-not-allowed",
|
||||
"transition ease-in-out duration-300",
|
||||
(!part1 || !part2 || !part3) && "tooltip",
|
||||
)}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
||||
</div>
|
||||
) : (
|
||||
"Submit"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpeakingGeneration;
|
||||
225
src/pages/(generation)/WritingGeneration.tsx
Normal file
225
src/pages/(generation)/WritingGeneration.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import Input from "@/components/Low/Input";
|
||||
import {WritingExam} from "@/interfaces/exam";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {playSound} from "@/utils/sound";
|
||||
import {Tab} from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {useRouter} from "next/router";
|
||||
import {useState} from "react";
|
||||
import {BsArrowRepeat} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import {v4} from "uuid";
|
||||
|
||||
const TaskTab = ({task, index, setTask}: {task?: string; index: number; setTask: (task: string) => void}) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const generate = () => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get(`/api/exam/writing/generate/writing_task${index}_general`)
|
||||
.then((result) => {
|
||||
playSound("check");
|
||||
setTask(result.data.question);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
toast.error("Something went wrong!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Tab.Panel className="w-full bg-ielts-writing/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
|
||||
<div className="flex gap-4 items-end">
|
||||
<button
|
||||
onClick={generate}
|
||||
disabled={isLoading}
|
||||
className={clsx(
|
||||
"bg-ielts-writing/70 border border-ielts-writing text-white w-full px-6 py-6 rounded-xl h-[70px]",
|
||||
"hover:bg-ielts-writing disabled:bg-ielts-writing/40 disabled:cursor-not-allowed",
|
||||
"transition ease-in-out duration-300",
|
||||
)}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
||||
</div>
|
||||
) : (
|
||||
"Generate"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className="w-fit h-fit mt-12 self-center animate-pulse flex flex-col gap-8 items-center">
|
||||
<span className={clsx("loading loading-infinity w-32 text-ielts-writing")} />
|
||||
<span className={clsx("font-bold text-2xl text-ielts-writing")}>Generating...</span>
|
||||
</div>
|
||||
)}
|
||||
{task && (
|
||||
<div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide">
|
||||
<span className="w-full h-96">{task}</span>
|
||||
</div>
|
||||
)}
|
||||
</Tab.Panel>
|
||||
);
|
||||
};
|
||||
|
||||
const WritingGeneration = () => {
|
||||
const [task1, setTask1] = useState<string>();
|
||||
const [task2, setTask2] = useState<string>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [resultingExam, setResultingExam] = useState<WritingExam>();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const setExams = useExamStore((state) => state.setExams);
|
||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||
|
||||
const loadExam = async (examId: string) => {
|
||||
const exam = await getExamById("writing", examId.trim());
|
||||
if (!exam) {
|
||||
toast.error("Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID", {
|
||||
toastId: "invalid-exam-id",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setExams([exam]);
|
||||
setSelectedModules(["writing"]);
|
||||
|
||||
router.push("/exercises");
|
||||
};
|
||||
|
||||
const submitExam = () => {
|
||||
if (!task1 || !task2) {
|
||||
toast.error("Please generate all tasks before submitting");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const exam: WritingExam = {
|
||||
isDiagnostic: false,
|
||||
minTimer: 60,
|
||||
module: "writing",
|
||||
exercises: [
|
||||
{
|
||||
id: v4(),
|
||||
type: "writing",
|
||||
prefix: `You should spend about 20 minutes on this task.`,
|
||||
prompt: task1,
|
||||
userSolutions: [],
|
||||
suffix: "You should write at least 150 words.",
|
||||
wordCounter: {
|
||||
limit: 150,
|
||||
type: "min",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: v4(),
|
||||
type: "writing",
|
||||
prefix: `You should spend about 40 minutes on this task.`,
|
||||
prompt: task2,
|
||||
userSolutions: [],
|
||||
suffix: "You should write at least 250 words.",
|
||||
wordCounter: {
|
||||
limit: 250,
|
||||
type: "min",
|
||||
},
|
||||
},
|
||||
],
|
||||
id: v4(),
|
||||
};
|
||||
|
||||
axios
|
||||
.post(`/api/exam/writing`, exam)
|
||||
.then((result) => {
|
||||
console.log(`Generated Exam ID: ${result.data.id}`);
|
||||
playSound("sent");
|
||||
toast.success("This new exam has been generated successfully! Check the ID in our browser's console.");
|
||||
setResultingExam(result.data);
|
||||
|
||||
setTask1(undefined);
|
||||
setTask2(undefined);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
toast.error("Something went wrong while generating, please try again later.");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tab.Group>
|
||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1">
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/70",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
|
||||
)
|
||||
}>
|
||||
Task 1
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/70",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
|
||||
)
|
||||
}>
|
||||
Task 2
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
{[
|
||||
{task: task1, setTask: setTask1},
|
||||
{task: task2, setTask: setTask2},
|
||||
].map(({task, setTask}, index) => (
|
||||
<TaskTab task={task} index={index + 1} key={index} setTask={setTask} />
|
||||
))}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
<div className="w-full flex justify-end gap-4">
|
||||
{resultingExam && (
|
||||
<button
|
||||
disabled={isLoading}
|
||||
onClick={() => loadExam(resultingExam.id)}
|
||||
className={clsx(
|
||||
"bg-white border border-ielts-writing text-ielts-writing w-full max-w-[200px] rounded-xl h-[70px] self-end",
|
||||
"hover:bg-ielts-writing hover:text-white disabled:bg-ielts-writing/40 disabled:cursor-not-allowed",
|
||||
"transition ease-in-out duration-300",
|
||||
)}>
|
||||
Perform Exam
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
disabled={!task1 || !task2 || isLoading}
|
||||
data-tip="Please generate all three passages"
|
||||
onClick={submitExam}
|
||||
className={clsx(
|
||||
"bg-ielts-writing/70 border border-ielts-writing text-white w-full max-w-[200px] rounded-xl h-[70px] self-end",
|
||||
"hover:bg-ielts-writing disabled:bg-ielts-writing/40 disabled:cursor-not-allowed",
|
||||
"transition ease-in-out duration-300",
|
||||
(!task1 || !task2) && "tooltip",
|
||||
)}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
||||
</div>
|
||||
) : (
|
||||
"Submit"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WritingGeneration;
|
||||
@@ -1,5 +1,6 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import Input from "@/components/Low/Input";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {User} from "@/interfaces/user";
|
||||
import {sendEmailVerification} from "@/utils/email";
|
||||
import axios from "axios";
|
||||
@@ -7,6 +8,8 @@ import {Divider} from "primereact/divider";
|
||||
import {useState} from "react";
|
||||
import {toast} from "react-toastify";
|
||||
import {KeyedMutator} from "swr";
|
||||
import Select from "react-select";
|
||||
import moment from "moment";
|
||||
|
||||
interface Props {
|
||||
isLoading: boolean;
|
||||
@@ -15,14 +18,25 @@ interface Props {
|
||||
sendEmailVerification: typeof sendEmailVerification;
|
||||
}
|
||||
|
||||
const availableDurations = {
|
||||
"1_month": {label: "1 Month", number: 1},
|
||||
"3_months": {label: "3 Months", number: 3},
|
||||
"6_months": {label: "6 Months", number: 6},
|
||||
"12_months": {label: "12 Months", number: 12},
|
||||
};
|
||||
|
||||
export default function RegisterCorporate({isLoading, setIsLoading, mutateUser, sendEmailVerification}: Props) {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [referralAgent, setReferralAgent] = useState<string | undefined>();
|
||||
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const [companyUsers, setCompanyUsers] = useState(0);
|
||||
const [subscriptionDuration, setSubscriptionDuration] = useState(1);
|
||||
|
||||
const {users} = useUsers();
|
||||
|
||||
const onSuccess = () => toast.success("An e-mail has been sent, please make sure to check your spam folder!");
|
||||
|
||||
@@ -47,12 +61,14 @@ export default function RegisterCorporate({isLoading, setIsLoading, mutateUser,
|
||||
password,
|
||||
type: "corporate",
|
||||
profilePicture: "/defaultAvatar.png",
|
||||
subscriptionExpirationDate: moment().subtract(1, "days").toISOString(),
|
||||
corporateInformation: {
|
||||
companyInformation: {
|
||||
name: companyName,
|
||||
userAmount: companyUsers,
|
||||
},
|
||||
allowedUserAmount: companyUsers,
|
||||
monthlyDuration: subscriptionDuration,
|
||||
referralAgent,
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
@@ -78,8 +94,11 @@ export default function RegisterCorporate({isLoading, setIsLoading, mutateUser,
|
||||
|
||||
return (
|
||||
<form className="flex flex-col items-center gap-4 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 />
|
||||
<div className="w-full flex gap-4">
|
||||
<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 />
|
||||
</div>
|
||||
|
||||
<div className="w-full flex gap-4">
|
||||
<Input
|
||||
type="password"
|
||||
@@ -101,22 +120,87 @@ export default function RegisterCorporate({isLoading, setIsLoading, mutateUser,
|
||||
|
||||
<Divider className="w-full !my-2" />
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
name="companyName"
|
||||
onChange={(e) => setCompanyName(e)}
|
||||
placeholder="Institution name"
|
||||
defaultValue={companyName}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
name="companyUsers"
|
||||
onChange={(e) => setCompanyUsers(parseInt(e))}
|
||||
placeholder="Institution name"
|
||||
defaultValue={companyUsers}
|
||||
required
|
||||
/>
|
||||
<div className="w-full flex gap-4">
|
||||
<Input
|
||||
type="text"
|
||||
name="companyName"
|
||||
onChange={(e) => setCompanyName(e)}
|
||||
placeholder="Corporate name"
|
||||
label="Corporate name"
|
||||
defaultValue={companyName}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
name="companyUsers"
|
||||
onChange={(e) => setCompanyUsers(parseInt(e))}
|
||||
label="Number of users"
|
||||
defaultValue={companyUsers}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex gap-4">
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Referral</label>
|
||||
<Select
|
||||
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||
options={[
|
||||
{value: "", label: "No referral"},
|
||||
...users.filter((u) => u.type === "agent").map((x) => ({value: x.id, label: `${x.name} - ${x.email}`})),
|
||||
]}
|
||||
defaultValue={{value: "", label: "No referral"}}
|
||||
onChange={(value) => setReferralAgent(value?.value)}
|
||||
styles={{
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Subscription Duration</label>
|
||||
<Select
|
||||
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||
options={Object.keys(availableDurations).map((value) => ({
|
||||
value,
|
||||
label: availableDurations[value as keyof typeof availableDurations].label,
|
||||
}))}
|
||||
defaultValue={{value: "1_month", label: availableDurations["1_month"].label}}
|
||||
onChange={(value) =>
|
||||
setSubscriptionDuration(value ? availableDurations[value.value as keyof typeof availableDurations].number : 1)
|
||||
}
|
||||
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>
|
||||
|
||||
<Button
|
||||
className="lg:mt-8 w-full"
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import Checkbox from "@/components/Low/Checkbox";
|
||||
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 || "");
|
||||
const [hasCode, setHasCode] = useState<boolean>(!!queryCode);
|
||||
|
||||
const onSuccess = () => toast.success("An e-mail has been sent, please make sure to check your spam folder!");
|
||||
|
||||
@@ -71,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"
|
||||
@@ -88,12 +99,27 @@ export default function RegisterIndividual({queryCode, isLoading, setIsLoading,
|
||||
defaultValue={confirmPassword}
|
||||
required
|
||||
/>
|
||||
<Input type="text" name="code" onChange={(e) => setCode(e)} placeholder="Enter your registration code" defaultValue={code} required />
|
||||
|
||||
{/** TODO: Add a checkbox to disable code */}
|
||||
<div className="flex flex-col gap-4 w-full items-start">
|
||||
<Checkbox isChecked={hasCode} onChange={setHasCode}>
|
||||
I have a code
|
||||
</Checkbox>
|
||||
{hasCode && (
|
||||
<Input
|
||||
type="text"
|
||||
name="code"
|
||||
onChange={(e) => setCode(e)}
|
||||
placeholder="Enter your registration code (optional)"
|
||||
defaultValue={code}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="lg:mt-8 w-full"
|
||||
color="purple"
|
||||
disabled={isLoading || !email || !name || !password || !confirmPassword || password !== confirmPassword || !code}>
|
||||
disabled={isLoading || !email || !name || !password || !confirmPassword || password !== confirmPassword || (hasCode ? !code : false)}>
|
||||
Create account
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
170
src/pages/(status)/PaymentDue.tsx
Normal file
170
src/pages/(status)/PaymentDue.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Layout from "@/components/High/Layout";
|
||||
import PayPalPayment from "@/components/PayPalPayment";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import usePackages from "@/hooks/usePackages";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {User} from "@/interfaces/user";
|
||||
import clsx from "clsx";
|
||||
import {capitalize} from "lodash";
|
||||
import {useState} from "react";
|
||||
import getSymbolFromCurrency from "currency-symbol-map";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
hasExpired?: boolean;
|
||||
clientID: string;
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
export default function PaymentDue({user, hasExpired = false, clientID, reload}: Props) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const {packages} = usePackages();
|
||||
const {users} = useUsers();
|
||||
const {groups} = useGroups();
|
||||
|
||||
const isIndividual = () => {
|
||||
if (user?.type === "developer") return true;
|
||||
if (user?.type !== "student") return false;
|
||||
const userGroups = groups.filter((g) => g.participants.includes(user?.id));
|
||||
|
||||
if (userGroups.length === 0) return true;
|
||||
|
||||
const userGroupsAdminTypes = userGroups.map((g) => users?.find((u) => u.id === g.admin)?.type).filter((t) => !!t);
|
||||
return userGroupsAdminTypes.every((t) => t !== "admin");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && (
|
||||
<div className="w-screen h-screen absolute top-0 left-0 overflow-hidden z-[999] bg-black/60">
|
||||
<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-8 items-center text-white">
|
||||
<span className={clsx("loading loading-infinity w-48")} />
|
||||
<span className={clsx("font-bold text-2xl")}>Completing your payment...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{user ? (
|
||||
<Layout user={user} navDisabled={hasExpired}>
|
||||
<div className="flex flex-col items-center justify-center text-center w-full gap-4">
|
||||
{hasExpired && <span className="font-bold text-lg">You do not have time credits for your account type!</span>}
|
||||
{isIndividual() && (
|
||||
<div className="flex flex-col items-center w-full overflow-x-scroll scrollbar-hide gap-12">
|
||||
<span className="max-w-lg">
|
||||
To add to your use of EnCoach, please purchase one of the time packages available below:
|
||||
</span>
|
||||
<div className="w-full flex flex-wrap justify-center gap-8">
|
||||
{packages.map((p) => (
|
||||
<div key={p.id} className={clsx("p-4 bg-white rounded-xl flex flex-col gap-6 items-start")}>
|
||||
<div className="flex flex-col items-start mb-2">
|
||||
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" />
|
||||
<span className="font-semibold text-xl">
|
||||
EnCoach - {p.duration}{" "}
|
||||
{capitalize(
|
||||
p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 items-start w-full">
|
||||
<span className="text-2xl">
|
||||
{p.price}
|
||||
{getSymbolFromCurrency(p.currency)}
|
||||
</span>
|
||||
<PayPalPayment
|
||||
key={clientID}
|
||||
{...p}
|
||||
clientID={clientID}
|
||||
setIsLoading={setIsLoading}
|
||||
onSuccess={() => {
|
||||
setTimeout(reload, 500);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<span>This includes:</span>
|
||||
<ul className="flex flex-col items-start text-sm">
|
||||
<li>- Train your abilities for the IELTS exam</li>
|
||||
<li>- Gain insights into your weaknesses and strengths</li>
|
||||
<li>- Allow yourself to correctly prepare for the exam</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isIndividual() && user.type === "corporate" && user?.corporateInformation.payment && (
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="max-w-lg">
|
||||
To add to your use of EnCoach and that of your students and teachers, please pay your designated package below:
|
||||
</span>
|
||||
<div className={clsx("p-4 bg-white rounded-xl flex flex-col gap-6 items-start")}>
|
||||
<div className="flex flex-col items-start mb-2">
|
||||
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" />
|
||||
<span className="font-semibold text-xl">EnCoach - {user.corporateInformation?.monthlyDuration} Months</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 items-start w-full">
|
||||
<span className="text-2xl">
|
||||
{user.corporateInformation.payment.value}
|
||||
{getSymbolFromCurrency(user.corporateInformation.payment.currency)}
|
||||
</span>
|
||||
<PayPalPayment
|
||||
key={clientID}
|
||||
clientID={clientID}
|
||||
setIsLoading={setIsLoading}
|
||||
currency={user.corporateInformation.payment.currency}
|
||||
price={user.corporateInformation.payment.value}
|
||||
duration={user.corporateInformation.monthlyDuration}
|
||||
duration_unit="months"
|
||||
onSuccess={() => {
|
||||
setIsLoading(false);
|
||||
setTimeout(reload, 500);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<span>This includes:</span>
|
||||
<ul className="flex flex-col items-start text-sm">
|
||||
<li>
|
||||
- Allow a total of {user.corporateInformation.companyInformation.userAmount} students and teachers to
|
||||
use EnCoach
|
||||
</li>
|
||||
<li>- Train their abilities for the IELTS exam</li>
|
||||
<li>- Gain insights into your students' weaknesses and strengths</li>
|
||||
<li>- Allow them to correctly prepare for the exam</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isIndividual() && user.type !== "corporate" && (
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="max-w-lg">
|
||||
You are not the person in charge of your time credits, please contact your administrator about this situation.
|
||||
</span>
|
||||
<span className="max-w-lg">
|
||||
If you believe this to be a mistake, please contact the platform's administration, thank you for your
|
||||
patience.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!isIndividual() && user.type === "corporate" && !user.corporateInformation.payment && (
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="max-w-lg">
|
||||
An admin nor your agent have yet set the price intended to your requirements in terms of the amount of users you
|
||||
desire and your expected monthly duration.
|
||||
</span>
|
||||
<span className="max-w-lg">
|
||||
Please try again later or contact your agent or an admin, thank you for your patience.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Html, Head, Main, NextScript } from 'next/document'
|
||||
/* eslint-disable @next/next/no-sync-scripts */
|
||||
import {Html, Head, Main, NextScript} from "next/document";
|
||||
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Head />
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
)
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Head />
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function Reset({code, mode, apiKey, continueUrl}: {code: string;
|
||||
if (response.data.ok) {
|
||||
toast.success("Your account has been verified!", {toastId: "verify-successful"});
|
||||
setTimeout(() => {
|
||||
router.push("/");
|
||||
router.reload();
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ import {getFirestore, collection, getDocs, query, where, setDoc, doc} from "fire
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {uuidv4} from "@firebase/util";
|
||||
import { Module } from "@/interfaces";
|
||||
import { getExams } from "@/utils/exams.be";
|
||||
import { Exam } from "@/interfaces/exam";
|
||||
import { flatten } from "lodash";
|
||||
|
||||
const db = getFirestore(app);
|
||||
|
||||
@@ -34,8 +38,107 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
|
||||
res.status(200).json(docs);
|
||||
}
|
||||
|
||||
async function POST(req: NextApiRequest, res: NextApiResponse) {
|
||||
await setDoc(doc(db, "assignments", uuidv4()), {assigner: req.session.user?.id, ...req.body});
|
||||
|
||||
res.status(200).json({ok: true});
|
||||
interface ExamWithUser {
|
||||
module: Module;
|
||||
id: string;
|
||||
assignee: string;
|
||||
}
|
||||
|
||||
function getRandomIndex(arr: any[]): number {
|
||||
const randomIndex = Math.floor(Math.random() * arr.length);
|
||||
return randomIndex;
|
||||
}
|
||||
|
||||
const generateExams = async (
|
||||
generateMultiple: Boolean,
|
||||
selectedModules: Module[],
|
||||
assignees: string[]
|
||||
): Promise<ExamWithUser[]> => {
|
||||
if (generateMultiple) {
|
||||
// for optimization purposes, it would be better to create a new endpoint that returned the answers for all users at once
|
||||
const allExams = await assignees.map(async (assignee) => {
|
||||
const selectedModulePromises = await selectedModules.map(
|
||||
async (module: Module) => {
|
||||
try {
|
||||
const exams: Exam[] = await getExams(db, module, "true", assignee);
|
||||
|
||||
const exam = exams[getRandomIndex(exams)];
|
||||
if (exam) {
|
||||
return { module: exam.module, id: exam.id, assignee };
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
const newModules = await Promise.all(selectedModulePromises);
|
||||
|
||||
return newModules;
|
||||
}, []);
|
||||
|
||||
const exams = flatten(await Promise.all(allExams)).filter(
|
||||
(x) => x !== null
|
||||
) as ExamWithUser[];
|
||||
return exams;
|
||||
}
|
||||
|
||||
const selectedModulePromises = await selectedModules.map(
|
||||
async (module: Module) => {
|
||||
const exams: Exam[] = await getExams(db, module, "false", undefined);
|
||||
const exam = exams[getRandomIndex(exams)];
|
||||
|
||||
if (exam) {
|
||||
return { module: exam.module, id: exam.id };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
const exams = await Promise.all(selectedModulePromises);
|
||||
const examesFiltered = exams.filter((x) => x !== null) as ExamWithUser[];
|
||||
return flatten(
|
||||
assignees.map((assignee) =>
|
||||
examesFiltered.map((exam) => ({ ...exam, assignee }))
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
async function POST(req: NextApiRequest, res: NextApiResponse) {
|
||||
const {
|
||||
selectedModules,
|
||||
assignees,
|
||||
// Generarte multiple true would generate an unique exam for eacah user
|
||||
// false would generate the same exam for all usersa
|
||||
generateMultiple = false,
|
||||
...body
|
||||
} = req.body as {
|
||||
selectedModules: Module[];
|
||||
assignees: string[];
|
||||
generateMultiple: Boolean;
|
||||
};
|
||||
|
||||
const exams: ExamWithUser[] = await generateExams(
|
||||
generateMultiple,
|
||||
selectedModules,
|
||||
assignees
|
||||
);
|
||||
if (exams.length === 0) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ ok: false, error: "No exams found for the selected modules" });
|
||||
return;
|
||||
}
|
||||
|
||||
await setDoc(doc(db, "assignments", uuidv4()), {
|
||||
assigner: req.session.user?.id,
|
||||
assignees,
|
||||
results: [],
|
||||
exams,
|
||||
...body,
|
||||
});
|
||||
|
||||
res.status(200).json({ ok: true });
|
||||
}
|
||||
|
||||
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});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type {NextApiRequest, NextApiResponse} from "next";
|
||||
import {app} from "@/firebase";
|
||||
import {getFirestore, setDoc, doc} from "firebase/firestore";
|
||||
import {getFirestore, setDoc, doc, query, collection, where, getDocs} from "firebase/firestore";
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {Type} from "@/interfaces/user";
|
||||
@@ -15,7 +15,7 @@ export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ok: false});
|
||||
res.status(401).json({ok: false, reason: "You must be logged in to generate a code!"});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -23,15 +23,33 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const permission = PERMISSIONS.generateCode[type];
|
||||
|
||||
if (!permission.includes(req.session.user.type)) {
|
||||
res.status(403).json({ok: false});
|
||||
res.status(403).json({ok: false, reason: "Your account type does not have permissions to generate a code for that type of user!"});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.session.user.type === "corporate") {
|
||||
const codesGeneratedByUserSnapshot = await getDocs(query(collection(db, "codes"), where("creator", "==", req.session.user.id)));
|
||||
const totalCodes = codesGeneratedByUserSnapshot.docs.length + codes.length;
|
||||
const allowedCodes = req.session.user.corporateInformation?.companyInformation.userAmount || 0;
|
||||
|
||||
if (totalCodes > allowedCodes) {
|
||||
res.status(403).json({
|
||||
ok: false,
|
||||
reason: `You have or would have exceeded your amount of allowed codes, you currently are allowed to generate ${
|
||||
allowedCodes - codesGeneratedByUserSnapshot.docs.length
|
||||
} codes.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const codePromises = codes.map(async (code, index) => {
|
||||
const codeRef = doc(db, "codes", code);
|
||||
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 {getStorage, ref, uploadBytes} from "firebase/storage";
|
||||
import {ref, uploadBytes} from "firebase/storage";
|
||||
import fs from "fs";
|
||||
import {app} 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) {
|
||||
@@ -16,8 +20,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = getStorage(app);
|
||||
|
||||
const form = formidable({keepExtensions: true});
|
||||
await form.parse(req, async (err: any, fields: any, files: any) => {
|
||||
if (err) console.log(err);
|
||||
@@ -38,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 {getStorage, ref, uploadBytes} from "firebase/storage";
|
||||
import {ref, uploadBytes} from "firebase/storage";
|
||||
import fs from "fs";
|
||||
import {app} 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) {
|
||||
@@ -16,8 +20,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = getStorage(app);
|
||||
|
||||
const form = formidable({keepExtensions: true});
|
||||
await form.parse(req, async (err: any, fields: any, files: any) => {
|
||||
if (err) console.log(err);
|
||||
@@ -28,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;
|
||||
}
|
||||
|
||||
54
src/pages/api/exam/[module]/generate/[endpoint].ts
Normal file
54
src/pages/api/exam/[module]/generate/[endpoint].ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// 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} from "firebase/firestore";
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {shuffle} from "lodash";
|
||||
import {Exam} from "@/interfaces/exam";
|
||||
import {Stat} from "@/interfaces/user";
|
||||
import {Module} from "@/interfaces";
|
||||
import axios from "axios";
|
||||
|
||||
const db = getFirestore(app);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") return get(req, res);
|
||||
if (req.method === "POST") return post(req, res);
|
||||
|
||||
return res.status(404).json({ok: false});
|
||||
}
|
||||
|
||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) return res.status(401).json({ok: false});
|
||||
if (req.session.user.type !== "developer") return res.status(403).json({ok: false});
|
||||
|
||||
const {endpoint, topic, exercises} = req.query as {module: Module; endpoint: string; topic?: string; exercises?: string[]};
|
||||
const url = `${process.env.BACKEND_URL}/${endpoint}`;
|
||||
|
||||
const result = await axios.get(`${url}${topic && exercises ? `?topic=${topic.toLowerCase()}&exercises=${exercises.join("&exercises=")}` : ""}`, {
|
||||
headers: {Authorization: `Bearer ${process.env.BACKEND_JWT}`},
|
||||
});
|
||||
|
||||
res.status(200).json(result.data);
|
||||
}
|
||||
|
||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) return res.status(401).json({ok: false});
|
||||
if (req.session.user.type !== "developer") return res.status(403).json({ok: false});
|
||||
|
||||
const {endpoint, topic, exercises} = req.query as {module: Module; endpoint: string; topic?: string; exercises?: string[]};
|
||||
const url = `${process.env.BACKEND_URL}/${endpoint}`;
|
||||
|
||||
const result = await axios.post(
|
||||
`${url}${topic && exercises ? `?topic=${topic.toLowerCase()}&exercises=${exercises.join("&exercises=")}` : ""}`,
|
||||
req.body,
|
||||
{
|
||||
headers: {Authorization: `Bearer ${process.env.BACKEND_JWT}`},
|
||||
},
|
||||
);
|
||||
|
||||
res.status(200).json(result.data);
|
||||
}
|
||||
@@ -1,47 +1,51 @@
|
||||
// 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} from "firebase/firestore";
|
||||
import {getFirestore, setDoc, doc} from "firebase/firestore";
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {shuffle} from "lodash";
|
||||
import {Exam} from "@/interfaces/exam";
|
||||
import {Stat} from "@/interfaces/user";
|
||||
|
||||
import { getExams } from "@/utils/exams.be";
|
||||
const db = getFirestore(app);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") return await GET(req, res);
|
||||
if (req.method === "POST") return await POST(req, res);
|
||||
|
||||
res.status(404).json({ok: false});
|
||||
}
|
||||
|
||||
async function GET(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
|
||||
const {module, avoidRepeated} = req.query as {module: string; avoidRepeated: string};
|
||||
const moduleRef = collection(db, module);
|
||||
const {
|
||||
module,
|
||||
avoidRepeated,
|
||||
} = req.query as {module: string; avoidRepeated: string};
|
||||
|
||||
const q = query(moduleRef, where("isDiagnostic", "==", false));
|
||||
const snapshot = await getDocs(q);
|
||||
const exams: Exam[] = await getExams(db, module, avoidRepeated, req.session.user.id);
|
||||
res.status(200).json(exams);
|
||||
}
|
||||
|
||||
const exams: Exam[] = shuffle(
|
||||
snapshot.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
...doc.data(),
|
||||
module,
|
||||
})),
|
||||
) as Exam[];
|
||||
|
||||
if (avoidRepeated === "true") {
|
||||
const statsQ = query(collection(db, "stats"), where("user", "==", req.session.user.id));
|
||||
const statsSnapshot = await getDocs(statsQ);
|
||||
|
||||
const stats: Stat[] = statsSnapshot.docs.map((doc) => ({id: doc.id, ...doc.data()})) as unknown as Stat[];
|
||||
const filteredExams = exams.filter((x) => !stats.map((s) => s.exam).includes(x.id));
|
||||
|
||||
res.status(200).json(filteredExams.length > 0 ? filteredExams : exams);
|
||||
async function POST(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json(exams);
|
||||
if (req.session.user.type !== "developer") {
|
||||
res.status(403).json({ok: false});
|
||||
return;
|
||||
}
|
||||
const {module} = req.query as {module: string};
|
||||
|
||||
const exam = {...req.body, module: module};
|
||||
await setDoc(doc(db, module, req.body.id), exam);
|
||||
|
||||
res.status(200).json(exam);
|
||||
}
|
||||
|
||||
@@ -13,12 +13,18 @@ const db = getFirestore(app);
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") return await GET(req, res);
|
||||
|
||||
res.status(404).json({ok: false});
|
||||
}
|
||||
|
||||
async function GET(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
|
||||
const moduleExamsPromises = MODULE_ARRAY.map(async (module) => {
|
||||
const moduleExamsPromises = [...MODULE_ARRAY, "level"].map(async (module) => {
|
||||
const moduleRef = collection(db, module);
|
||||
|
||||
const q = query(moduleRef, where("isDiagnostic", "==", false));
|
||||
|
||||
@@ -11,9 +11,9 @@ const db = getFirestore(app);
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") await get(req, res);
|
||||
if (req.method === "DELETE") await del(req, res);
|
||||
if (req.method === "PATCH") await patch(req, res);
|
||||
if (req.method === "GET") return await get(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);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
|
||||
const group = {...snapshot.data(), id: snapshot.id} as Group;
|
||||
|
||||
const user = req.session.user;
|
||||
if (user.type === "owner" || user.type === "developer" || user.id === group.admin) {
|
||||
if (user.type === "admin" || user.type === "developer" || user.id === group.admin) {
|
||||
await deleteDoc(snapshot.ref);
|
||||
|
||||
res.status(200).json({ok: true});
|
||||
@@ -69,7 +69,7 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
|
||||
const group = {...snapshot.data(), id: snapshot.id} as Group;
|
||||
|
||||
const user = req.session.user;
|
||||
if (user.type === "owner" || user.type === "developer" || user.id === group.admin) {
|
||||
if (user.type === "admin" || user.type === "developer" || user.id === group.admin) {
|
||||
await setDoc(snapshot.ref, req.body, {merge: true});
|
||||
|
||||
res.status(200).json({ok: true});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {getFirestore, collection, getDocs, setDoc, doc} from "firebase/firestore
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {Group} from "@/interfaces/user";
|
||||
import {v4} from "uuid";
|
||||
|
||||
const db = getFirestore(app);
|
||||
|
||||
@@ -45,6 +46,6 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
const body = req.body as Group;
|
||||
|
||||
await setDoc(doc(db, "groups", body.id), {name: body.name, admin: body.admin, participants: body.participants});
|
||||
await setDoc(doc(db, "groups", v4()), {name: body.name, admin: body.admin, participants: body.participants});
|
||||
res.status(200).json({ok: true});
|
||||
}
|
||||
|
||||
89
src/pages/api/packages/[id].ts
Normal file
89
src/pages/api/packages/[id].ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type {NextApiRequest, NextApiResponse} from "next";
|
||||
import {app} from "@/firebase";
|
||||
import {getFirestore, doc, getDoc, deleteDoc, setDoc} from "firebase/firestore";
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||
|
||||
const db = getFirestore(app);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") return get(req, res);
|
||||
if (req.method === "DELETE") return del(req, res);
|
||||
if (req.method === "PATCH") return patch(req, res);
|
||||
}
|
||||
|
||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
|
||||
const {id} = req.query as {id: string};
|
||||
|
||||
const docRef = doc(db, "packages", id);
|
||||
const docSnap = await getDoc(docRef);
|
||||
|
||||
if (docSnap.exists()) {
|
||||
res.status(200).json({
|
||||
id: docSnap.id,
|
||||
...docSnap.data(),
|
||||
module,
|
||||
});
|
||||
} else {
|
||||
res.status(404).json(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function patch(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
|
||||
const {id} = req.query as {id: string};
|
||||
|
||||
const docRef = doc(db, "packages", id);
|
||||
const docSnap = await getDoc(docRef);
|
||||
|
||||
if (docSnap.exists()) {
|
||||
if (!["developer", "admin"].includes(req.session.user.type)) {
|
||||
res.status(403).json({ok: false});
|
||||
return;
|
||||
}
|
||||
|
||||
await setDoc(docRef, req.body, {merge: true});
|
||||
|
||||
res.status(200).json({ok: true});
|
||||
} else {
|
||||
res.status(404).json({ok: false});
|
||||
}
|
||||
}
|
||||
|
||||
async function del(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
|
||||
const {id} = req.query as {id: string};
|
||||
|
||||
const docRef = doc(db, "packages", id);
|
||||
const docSnap = await getDoc(docRef);
|
||||
|
||||
if (docSnap.exists()) {
|
||||
if (!["developer", "admin"].includes(req.session.user.type)) {
|
||||
res.status(403).json({ok: false});
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteDoc(docRef);
|
||||
|
||||
res.status(200).json({ok: true});
|
||||
} else {
|
||||
res.status(404).json({ok: false});
|
||||
}
|
||||
}
|
||||
44
src/pages/api/packages/index.ts
Normal file
44
src/pages/api/packages/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// 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, setDoc, doc} from "firebase/firestore";
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {Group} from "@/interfaces/user";
|
||||
import {Package} from "@/interfaces/paypal";
|
||||
import {v4} from "uuid";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (req.method === "GET") await get(req, res);
|
||||
if (req.method === "POST") await post(req, res);
|
||||
}
|
||||
|
||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
const snapshot = await getDocs(collection(db, "packages"));
|
||||
|
||||
res.status(200).json(
|
||||
snapshot.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
...doc.data(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!["developer", "admin"].includes(req.session.user!.type))
|
||||
return res.status(403).json({ok: false, reason: "You do not have permission to create a new package"});
|
||||
|
||||
const body = req.body as Package;
|
||||
|
||||
await setDoc(doc(db, "packages", v4()), body);
|
||||
res.status(200).json({ok: true});
|
||||
}
|
||||
81
src/pages/api/payments/[id].ts
Normal file
81
src/pages/api/payments/[id].ts
Normal file
@@ -0,0 +1,81 @@
|
||||
// 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, 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);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") return await get(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);
|
||||
}
|
||||
|
||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
|
||||
const {id} = req.query as {id: string};
|
||||
|
||||
const snapshot = await getDoc(doc(db, "payments", id));
|
||||
|
||||
if (snapshot.exists()) {
|
||||
res.status(200).json({...snapshot.data(), id: snapshot.id});
|
||||
} else {
|
||||
res.status(404).json(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function del(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
|
||||
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") {
|
||||
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;
|
||||
}
|
||||
|
||||
res.status(403).json({ok: false});
|
||||
}
|
||||
|
||||
async function patch(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
|
||||
const {id} = req.query as {id: string};
|
||||
const snapshot = await getDoc(doc(db, "payments", id));
|
||||
|
||||
const user = req.session.user;
|
||||
if (user.type === "admin" || user.type === "developer") {
|
||||
await setDoc(snapshot.ref, req.body, {merge: true});
|
||||
|
||||
return res.status(200).json({ok: true});
|
||||
}
|
||||
|
||||
res.status(403).json({ok: false});
|
||||
}
|
||||
180
src/pages/api/payments/files/[type]/[paymentId].ts
Normal file
180
src/pages/api/payments/files/[type]/[paymentId].ts
Normal file
@@ -0,0 +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, 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 fs from "fs";
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
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 binary = fs.readFileSync(file.path).buffer;
|
||||
const snapshot = await uploadBytes(fileRef, binary);
|
||||
fs.rmSync(file.path);
|
||||
|
||||
const paymentRef = doc(db, "payments", paymentId);
|
||||
|
||||
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.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);
|
||||
}
|
||||
|
||||
async function get(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 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});
|
||||
}
|
||||
|
||||
async function post(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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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"});
|
||||
}
|
||||
}
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
43
src/pages/api/payments/index.ts
Normal file
43
src/pages/api/payments/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// 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, setDoc, doc} 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 {v4} from "uuid";
|
||||
import ShortUniqueId from "short-unique-id";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (req.method === "GET") await get(req, res);
|
||||
if (req.method === "POST") await post(req, res);
|
||||
}
|
||||
|
||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
const snapshot = await getDocs(collection(db, "payments"));
|
||||
|
||||
res.status(200).json(
|
||||
snapshot.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
...doc.data(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
const body = req.body as Payment;
|
||||
|
||||
const shortUID = new ShortUniqueId();
|
||||
await setDoc(doc(db, "payments", shortUID.randomUUID(8)), body);
|
||||
res.status(200).json({ok: true});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user