Compare commits
58 Commits
feature/le
...
faeture/pa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -37,4 +37,5 @@ next-env.d.ts
|
|||||||
|
|
||||||
.env
|
.env
|
||||||
.yarn/*
|
.yarn/*
|
||||||
.history*
|
.history*
|
||||||
|
__ENV.js
|
||||||
10
package.json
10
package.json
@@ -10,10 +10,13 @@
|
|||||||
"prepare": "husky install"
|
"prepare": "husky install"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@beam-australia/react-env": "^3.1.1",
|
||||||
"@headlessui/react": "^1.7.13",
|
"@headlessui/react": "^1.7.13",
|
||||||
"@mdi/js": "^7.1.96",
|
"@mdi/js": "^7.1.96",
|
||||||
"@mdi/react": "^1.6.1",
|
"@mdi/react": "^1.6.1",
|
||||||
"@next/font": "13.1.6",
|
"@next/font": "13.1.6",
|
||||||
|
"@paypal/paypal-js": "^7.1.0",
|
||||||
|
"@paypal/react-paypal-js": "^8.1.3",
|
||||||
"@tanstack/react-table": "^8.10.1",
|
"@tanstack/react-table": "^8.10.1",
|
||||||
"@types/node": "18.13.0",
|
"@types/node": "18.13.0",
|
||||||
"@types/react": "18.0.27",
|
"@types/react": "18.0.27",
|
||||||
@@ -24,6 +27,7 @@
|
|||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
"countries-list": "^3.0.1",
|
"countries-list": "^3.0.1",
|
||||||
"country-codes-list": "^1.6.11",
|
"country-codes-list": "^1.6.11",
|
||||||
|
"currency-symbol-map": "^5.1.0",
|
||||||
"daisyui": "^3.1.5",
|
"daisyui": "^3.1.5",
|
||||||
"eslint": "8.33.0",
|
"eslint": "8.33.0",
|
||||||
"eslint-config-next": "13.1.6",
|
"eslint-config-next": "13.1.6",
|
||||||
@@ -33,6 +37,7 @@
|
|||||||
"formidable": "^3.5.0",
|
"formidable": "^3.5.0",
|
||||||
"formidable-serverless": "^1.1.1",
|
"formidable-serverless": "^1.1.1",
|
||||||
"framer-motion": "^9.0.2",
|
"framer-motion": "^9.0.2",
|
||||||
|
"howler": "^2.2.4",
|
||||||
"iron-session": "^6.3.1",
|
"iron-session": "^6.3.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
@@ -44,6 +49,7 @@
|
|||||||
"random-words": "^2.0.0",
|
"random-words": "^2.0.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-chartjs-2": "^5.2.0",
|
"react-chartjs-2": "^5.2.0",
|
||||||
|
"react-currency-input-field": "^3.6.12",
|
||||||
"react-datepicker": "^4.18.0",
|
"react-datepicker": "^4.18.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-firebase-hooks": "^5.1.1",
|
"react-firebase-hooks": "^5.1.1",
|
||||||
@@ -68,6 +74,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/formidable": "^3.4.0",
|
"@types/formidable": "^3.4.0",
|
||||||
|
"@types/howler": "^2.2.11",
|
||||||
"@types/lodash": "^4.14.191",
|
"@types/lodash": "^4.14.191",
|
||||||
"@types/nodemailer": "^6.4.11",
|
"@types/nodemailer": "^6.4.11",
|
||||||
"@types/nodemailer-express-handlebars": "^4.0.3",
|
"@types/nodemailer-express-handlebars": "^4.0.3",
|
||||||
@@ -78,6 +85,7 @@
|
|||||||
"autoprefixer": "^10.4.13",
|
"autoprefixer": "^10.4.13",
|
||||||
"husky": "^8.0.3",
|
"husky": "^8.0.3",
|
||||||
"postcss": "^8.4.21",
|
"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";
|
import CountrySelect from "./Low/CountrySelect";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
user: User;
|
||||||
mutateUser: KeyedMutator<User>;
|
mutateUser: KeyedMutator<User>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DemographicInformationInput({mutateUser}: Props) {
|
export default function DemographicInformationInput({user, mutateUser}: Props) {
|
||||||
const [country, setCountry] = useState<string>();
|
const [country, setCountry] = useState<string>();
|
||||||
const [phone, setPhone] = useState<string>();
|
const [phone, setPhone] = useState<string>();
|
||||||
const [gender, setGender] = useState<Gender>();
|
const [gender, setGender] = useState<Gender>();
|
||||||
const [employment, setEmployment] = useState<EmploymentStatus>();
|
const [employment, setEmployment] = useState<EmploymentStatus>();
|
||||||
|
const [position, setPosition] = useState<string>();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const [companyName, setCompanyName] = useState<string>();
|
||||||
|
const [commercialRegistration, setCommercialRegistration] = useState<string>();
|
||||||
|
|
||||||
const save = (e?: FormEvent) => {
|
const save = (e?: FormEvent) => {
|
||||||
if (e) e.preventDefault();
|
if (e) e.preventDefault();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -32,8 +37,10 @@ export default function DemographicInformationInput({mutateUser}: Props) {
|
|||||||
country,
|
country,
|
||||||
phone: `+${countryCodes.findOne("countryCode" as any, country!).countryCallingCode}${phone}`,
|
phone: `+${countryCodes.findOne("countryCode" as any, country!).countryCallingCode}${phone}`,
|
||||||
gender,
|
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))
|
.then((response) => mutateUser((response.data as {user: User}).user))
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -53,6 +60,18 @@ export default function DemographicInformationInput({mutateUser}: Props) {
|
|||||||
about yourself.
|
about yourself.
|
||||||
</h2>
|
</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}>
|
<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="Company Name" required />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
onChange={setCommercialRegistration}
|
||||||
|
name="commercialRegistration"
|
||||||
|
label="Commercial Registration"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="relative flex flex-col gap-3 w-full">
|
<div className="relative flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
||||||
<CountrySelect value={country} onChange={setCountry} />
|
<CountrySelect value={country} onChange={setCountry} />
|
||||||
@@ -99,25 +118,32 @@ export default function DemographicInformationInput({mutateUser}: Props) {
|
|||||||
</RadioGroup.Option>
|
</RadioGroup.Option>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex flex-col gap-3 w-full">
|
{user.type === "corporate" && (
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Employment Status *</label>
|
<Input name="position" onChange={setPosition} type="text" label="Position" placeholder="CEO, Head of Marketing..." required />
|
||||||
<RadioGroup value={employment} onChange={setEmployment} className="grid grid-cols-2 items-center gap-4 place-items-center">
|
)}
|
||||||
{EMPLOYMENT_STATUS.map(({status, label}) => (
|
{user.type !== "corporate" && (
|
||||||
<RadioGroup.Option value={status} key={status}>
|
<div className="relative flex flex-col gap-3 w-full">
|
||||||
{({checked}) => (
|
<label className="font-normal text-base text-mti-gray-dim">Employment Status *</label>
|
||||||
<span
|
<RadioGroup value={employment} onChange={setEmployment} className="grid grid-cols-2 items-center gap-4 place-items-center">
|
||||||
className={clsx(
|
{EMPLOYMENT_STATUS.map(({status, label}) => (
|
||||||
"px-6 py-4 w-44 md:w-48 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
<RadioGroup.Option value={status} key={status}>
|
||||||
"transition duration-300 ease-in-out",
|
{({checked}) => (
|
||||||
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
<span
|
||||||
)}>
|
className={clsx(
|
||||||
{label}
|
"px-6 py-4 w-44 md:w-48 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
</span>
|
"transition duration-300 ease-in-out",
|
||||||
)}
|
!checked
|
||||||
</RadioGroup.Option>
|
? "bg-white border-mti-gray-platinum"
|
||||||
))}
|
: "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||||
</RadioGroup>
|
)}>
|
||||||
</div>
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</RadioGroup.Option>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="self-end flex justify-end w-full gap-8 absolute bottom-8 left-0 px-8">
|
<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"
|
className="lg:mt-8 max-w-[400px] w-full self-end"
|
||||||
color="purple"
|
color="purple"
|
||||||
onClick={save}
|
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 && "Save information"}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
|
|||||||
@@ -11,7 +11,16 @@ const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mo
|
|||||||
ssr: false,
|
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 [recordingDuration, setRecordingDuration] = useState(0);
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [mediaBlob, setMediaBlob] = useState<string>();
|
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);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (updateIndex) updateIndex(promptIndex);
|
||||||
|
}, [promptIndex, updateIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded) {
|
if (hasExamEnded) {
|
||||||
onNext({
|
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 [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
||||||
const [questionIndex, setQuestionIndex] = useState(0);
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [hasExamEnded]);
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (updateIndex) updateIndex(questionIndex);
|
||||||
|
}, [questionIndex, updateIndex]);
|
||||||
|
|
||||||
const onSelectOption = (option: string) => {
|
const onSelectOption = (option: string) => {
|
||||||
const question = questions[questionIndex];
|
const question = questions[questionIndex];
|
||||||
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);
|
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);
|
||||||
|
|||||||
@@ -22,11 +22,17 @@ import InteractiveSpeaking from "./InteractiveSpeaking";
|
|||||||
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false});
|
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false});
|
||||||
|
|
||||||
export interface CommonProps {
|
export interface CommonProps {
|
||||||
|
updateIndex?: (internalIndex: number) => void;
|
||||||
onNext: (userSolutions: UserSolution) => void;
|
onNext: (userSolutions: UserSolution) => void;
|
||||||
onBack: (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) {
|
switch (exercise.type) {
|
||||||
case "fillBlanks":
|
case "fillBlanks":
|
||||||
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
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":
|
case "matchSentences":
|
||||||
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "multipleChoice":
|
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":
|
case "writeBlanks":
|
||||||
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "writing":
|
case "writing":
|
||||||
@@ -43,6 +57,14 @@ export const renderExercise = (exercise: Exercise, onNext: (userSolutions: UserS
|
|||||||
case "speaking":
|
case "speaking":
|
||||||
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "interactiveSpeaking":
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,17 +3,31 @@ import {useState} from "react";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
type: "email" | "text" | "password" | "tel" | "number";
|
type: "email" | "text" | "password" | "tel" | "number";
|
||||||
|
roundness?: "full" | "xl";
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
defaultValue?: string | number;
|
defaultValue?: string | number;
|
||||||
|
value?: string | number;
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
onChange: (value: string) => void;
|
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);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
if (type === "password") {
|
if (type === "password") {
|
||||||
@@ -57,9 +71,15 @@ export default function Input({type, label, placeholder, name, required = false,
|
|||||||
type={type}
|
type={type}
|
||||||
name={name}
|
name={name}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
min={type === "number" ? 0 : undefined}
|
||||||
placeholder={placeholder}
|
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}
|
required={required}
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -103,14 +103,25 @@ export default function MobileMenu({isOpen, onClose, path, user}: Props) {
|
|||||||
)}>
|
)}>
|
||||||
Record
|
Record
|
||||||
</Link>
|
</Link>
|
||||||
{user.type !== "student" && (
|
{["admin", "developer", "agent"].includes(user.type) && (
|
||||||
<Link
|
<Link
|
||||||
href="/admin"
|
href="/payment-record"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"transition ease-in-out duration-300 w-fit",
|
"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>
|
||||||
)}
|
)}
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -53,7 +53,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">
|
<div className="flex justify-end -md:items-center gap-4 md:w-5/6 md:mr-8">
|
||||||
{showExpirationDate() && (
|
{showExpirationDate() && (
|
||||||
<Link
|
<Link
|
||||||
href="https://encoach.com/join"
|
href="/payment"
|
||||||
data-tip="Expiry date"
|
data-tip="Expiry date"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"py-2 px-6 w-fit flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
"py-2 px-6 w-fit flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
import {calculateAverageLevel} from "@/utils/score";
|
import {calculateAverageLevel} from "@/utils/score";
|
||||||
import {capitalize} from "lodash";
|
import {capitalize} from "lodash";
|
||||||
import {ReactElement} from "react";
|
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 -md:flex-col justify-between w-full gap-8">
|
||||||
<div className="flex flex-col gap-2 py-2">
|
<div className="flex flex-col gap-2 py-2">
|
||||||
<h1 className="font-bold text-2xl md:text-4xl">{user.name}</h1>
|
<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>
|
</div>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
|
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {IconType} from "react-icons";
|
import {IconType} from "react-icons";
|
||||||
import {MdSpaceDashboard} from "react-icons/md";
|
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 {RiLogoutBoxFill} from "react-icons/ri";
|
||||||
import {SlPencil} from "react-icons/sl";
|
import {SlPencil} from "react-icons/sl";
|
||||||
import {FaAward} from "react-icons/fa";
|
import {FaAward} from "react-icons/fa";
|
||||||
@@ -89,8 +99,35 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
)}
|
)}
|
||||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
|
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
|
||||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
|
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
|
||||||
{userType !== "student" && (
|
{["admin", "developer", "agent"].includes(userType || "") && (
|
||||||
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Admin" path={path} keyPath="/admin" isMinimized={isMinimized} />
|
<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>
|
||||||
<div className="xl:hidden -xl:flex flex-col gap-3">
|
<div className="xl:hidden -xl:flex flex-col gap-3">
|
||||||
@@ -100,7 +137,10 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" 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} />
|
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
|
||||||
{userType !== "student" && (
|
{userType !== "student" && (
|
||||||
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Admin" path={path} keyPath="/admin" isMinimized={true} />
|
<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>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
|
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
import Button from "../Low/Button";
|
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 [questionIndex, setQuestionIndex] = useState(0);
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
@@ -67,6 +76,10 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
|
|||||||
return {total, correct, missing};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (updateIndex) updateIndex(questionIndex);
|
||||||
|
}, [questionIndex, updateIndex]);
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
if (questionIndex === questions.length - 1) {
|
if (questionIndex === questions.length - 1) {
|
||||||
onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
|
onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
|
||||||
|
|||||||
@@ -22,11 +22,12 @@ import Writing from "./Writing";
|
|||||||
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), {ssr: false});
|
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), {ssr: false});
|
||||||
|
|
||||||
export interface CommonProps {
|
export interface CommonProps {
|
||||||
|
updateIndex?: (internalIndex: number) => void;
|
||||||
onNext: (userSolutions: UserSolution) => void;
|
onNext: (userSolutions: UserSolution) => void;
|
||||||
onBack: (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) {
|
switch (exercise.type) {
|
||||||
case "fillBlanks":
|
case "fillBlanks":
|
||||||
return <FillBlanks {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
return <FillBlanks {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
@@ -35,7 +36,7 @@ export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: (
|
|||||||
case "matchSentences":
|
case "matchSentences":
|
||||||
return <MatchSentences {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
return <MatchSentences {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "multipleChoice":
|
case "multipleChoice":
|
||||||
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
|
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} updateIndex={updateIndex} onNext={onNext} onBack={onBack} />;
|
||||||
case "writeBlanks":
|
case "writeBlanks":
|
||||||
return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "writing":
|
case "writing":
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import axios from "axios";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {Divider} from "primereact/divider";
|
import {Divider} from "primereact/divider";
|
||||||
import {useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import ReactDatePicker from "react-datepicker";
|
import ReactDatePicker from "react-datepicker";
|
||||||
import {BsFileEarmarkText, BsPencil, BsStar} from "react-icons/bs";
|
import {BsFileEarmarkText, BsPencil, BsStar} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
@@ -17,6 +17,8 @@ import Input from "./Low/Input";
|
|||||||
import ProfileSummary from "./ProfileSummary";
|
import ProfileSummary from "./ProfileSummary";
|
||||||
import Select from "react-select";
|
import Select from "react-select";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
|
import {CURRENCIES} from "@/resources/paypal";
|
||||||
|
|
||||||
const expirationDateColor = (date: Date) => {
|
const expirationDateColor = (date: Date) => {
|
||||||
const momentDate = moment(date);
|
const momentDate = moment(date);
|
||||||
@@ -33,22 +35,81 @@ interface Props {
|
|||||||
onClose: (reload?: boolean) => void;
|
onClose: (reload?: boolean) => void;
|
||||||
onViewStudents?: () => void;
|
onViewStudents?: () => void;
|
||||||
onViewTeachers?: () => void;
|
onViewTeachers?: () => void;
|
||||||
|
onViewCorporate?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers}: Props) => {
|
const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers, onViewCorporate}: Props) => {
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null | undefined>(user.subscriptionExpirationDate);
|
const [expiryDate, setExpiryDate] = useState<Date | null | undefined>(user.subscriptionExpirationDate);
|
||||||
const [referralAgent, setReferralAgent] = useState(user.corporateInformation?.referralAgent);
|
|
||||||
const [type, setType] = useState(user.type);
|
const [type, setType] = useState(user.type);
|
||||||
const [status, setStatus] = useState(user.status);
|
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 : undefined);
|
||||||
|
const [monthlyDuration, setMonthlyDuration] = useState(user.type === "corporate" ? user.corporateInformation?.monthlyDuration : undefined);
|
||||||
|
|
||||||
const {stats} = useStats(user.id);
|
const {stats} = useStats(user.id);
|
||||||
const {users} = useUsers();
|
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 = () => {
|
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;
|
if (!confirm(`Are you sure you want to update ${user.name}'s account?`)) return;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {...user, subscriptionExpirationDate: expiryDate, type, status})
|
.post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {
|
||||||
|
...user,
|
||||||
|
subscriptionExpirationDate: expiryDate,
|
||||||
|
type,
|
||||||
|
status,
|
||||||
|
agentInformation:
|
||||||
|
type === "agent"
|
||||||
|
? {
|
||||||
|
companyName,
|
||||||
|
commercialRegistration,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
corporateInformation:
|
||||||
|
type === "corporate"
|
||||||
|
? {
|
||||||
|
referralAgent,
|
||||||
|
monthlyDuration,
|
||||||
|
companyInformation: {
|
||||||
|
companyName,
|
||||||
|
userAmount,
|
||||||
|
},
|
||||||
|
payment: {
|
||||||
|
value: paymentValue,
|
||||||
|
currency: paymentCurrency,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("User updated successfully!");
|
toast.success("User updated successfully!");
|
||||||
onClose(true);
|
onClose(true);
|
||||||
@@ -81,6 +142,119 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers}:
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{user.type === "agent" && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full">
|
||||||
|
<Input
|
||||||
|
label="Company Name"
|
||||||
|
type="text"
|
||||||
|
name="companyName"
|
||||||
|
onChange={setCompanyName}
|
||||||
|
placeholder="Enter company name"
|
||||||
|
defaultValue={companyName}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Commercial Registration"
|
||||||
|
type="text"
|
||||||
|
name="commercialRegistration"
|
||||||
|
onChange={setCommercialRegistration}
|
||||||
|
placeholder="Enter company name"
|
||||||
|
defaultValue={commercialRegistration}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</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="Company Name"
|
||||||
|
type="text"
|
||||||
|
name="companyName"
|
||||||
|
onChange={setCompanyName}
|
||||||
|
placeholder="Enter company name"
|
||||||
|
defaultValue={companyName}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Number of Users"
|
||||||
|
type="number"
|
||||||
|
name="userAmount"
|
||||||
|
onChange={(e) => setUserAmount(e ? parseInt(e) : undefined)}
|
||||||
|
placeholder="Enter number of users"
|
||||||
|
defaultValue={userAmount}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Monthly Duration"
|
||||||
|
type="number"
|
||||||
|
name="monthlyDuration"
|
||||||
|
onChange={(e) => setMonthlyDuration(e ? parseInt(e) : undefined)}
|
||||||
|
placeholder="Enter monthly duration"
|
||||||
|
defaultValue={monthlyDuration}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Country Manager</label>
|
||||||
|
{referralAgentLabel && (
|
||||||
|
<Select
|
||||||
|
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
|
options={[
|
||||||
|
{value: "", label: "No referral"},
|
||||||
|
...users.filter((u) => u.type === "agent").map((x) => ({value: x.id, label: `${x.name} - ${x.email}`})),
|
||||||
|
]}
|
||||||
|
defaultValue={{
|
||||||
|
value: referralAgent,
|
||||||
|
label: referralAgentLabel,
|
||||||
|
}}
|
||||||
|
onChange={(value) => setReferralAgent(value?.value)}
|
||||||
|
styles={{
|
||||||
|
control: (styles) => ({
|
||||||
|
...styles,
|
||||||
|
paddingLeft: "4px",
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
":focus": {
|
||||||
|
outline: "none",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
option: (styles, state) => ({
|
||||||
|
...styles,
|
||||||
|
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||||
|
color: state.isFocused ? "black" : styles.color,
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 w-full lg:col-span-2">
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
defaultValue={paymentCurrency}
|
||||||
|
onChange={(e) => setPaymentCurrency(e.target.value)}
|
||||||
|
className="p-6 col-span-2 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
||||||
|
{CURRENCIES.map(({label, currency}) => (
|
||||||
|
<option value={currency} key={currency}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Divider className="w-full !m-0" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<section className="flex flex-col gap-4 justify-between">
|
<section className="flex flex-col gap-4 justify-between">
|
||||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||||
<Input
|
<Input
|
||||||
@@ -120,29 +294,43 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers}:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||||
<div className="relative flex flex-col gap-3 w-full">
|
{user.type !== "corporate" && (
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Employment Status</label>
|
<div className="relative flex flex-col gap-3 w-full">
|
||||||
<RadioGroup
|
<label className="font-normal text-base text-mti-gray-dim">Employment Status</label>
|
||||||
value={user.demographicInformation?.employment}
|
<RadioGroup
|
||||||
className="grid grid-cols-2 items-center gap-4 place-items-center">
|
value={user.demographicInformation?.employment}
|
||||||
{EMPLOYMENT_STATUS.map(({status, label}) => (
|
className="grid grid-cols-2 items-center gap-4 place-items-center">
|
||||||
<RadioGroup.Option value={status} key={status}>
|
{EMPLOYMENT_STATUS.map(({status, label}) => (
|
||||||
{({checked}) => (
|
<RadioGroup.Option value={status} key={status}>
|
||||||
<span
|
{({checked}) => (
|
||||||
className={clsx(
|
<span
|
||||||
"px-6 py-4 w-40 md:w-48 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
className={clsx(
|
||||||
"transition duration-300 ease-in-out",
|
"px-6 py-4 w-40 md:w-48 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
!checked
|
"transition duration-300 ease-in-out",
|
||||||
? "bg-white border-mti-gray-platinum"
|
!checked
|
||||||
: "bg-mti-purple-light border-mti-purple-dark text-white",
|
? "bg-white border-mti-gray-platinum"
|
||||||
)}>
|
: "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||||
{label}
|
)}>
|
||||||
</span>
|
{label}
|
||||||
)}
|
</span>
|
||||||
</RadioGroup.Option>
|
)}
|
||||||
))}
|
</RadioGroup.Option>
|
||||||
</RadioGroup>
|
))}
|
||||||
</div>
|
</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="flex flex-col gap-8 w-full">
|
||||||
<div className="relative flex flex-col gap-3 w-full">
|
<div className="relative flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Gender</label>
|
<label className="font-normal text-base text-mti-gray-dim">Gender</label>
|
||||||
@@ -196,7 +384,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers}:
|
|||||||
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
|
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
isChecked={!!expiryDate}
|
isChecked={!!expiryDate}
|
||||||
onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : undefined)}>
|
onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : null)}>
|
||||||
Enabled
|
Enabled
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
@@ -234,9 +422,9 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers}:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(loggedInUser.type === "developer" || loggedInUser.type === "owner") && (
|
{(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">
|
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Status</label>
|
<label className="font-normal text-base text-mti-gray-dim">Status</label>
|
||||||
@@ -255,76 +443,25 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers}:
|
|||||||
defaultValue={user.type}
|
defaultValue={user.type}
|
||||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||||
className="p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
className="p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
||||||
<option value="student">Student</option>
|
{Object.keys(USER_TYPE_LABELS).map((type) => (
|
||||||
<option value="teacher">Teacher</option>
|
<option key={type} value={type}>
|
||||||
<option value="corporate">Corporate</option>
|
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||||
<option value="agent">Country Agent</option>
|
</option>
|
||||||
<option value="owner">Owner</option>
|
))}
|
||||||
<option value="developer">Developer</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{user.type === "corporate" && (
|
|
||||||
<>
|
|
||||||
<Divider className="w-full" />
|
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Amount of Users"
|
|
||||||
type="number"
|
|
||||||
name="userAmount"
|
|
||||||
onChange={() => null}
|
|
||||||
placeholder="Enter amount of users"
|
|
||||||
defaultValue={user.corporateInformation?.companyInformation.userAmount}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 w-full">
|
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Country Agent</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: referralAgent,
|
|
||||||
label: referralAgent ? users.find((u) => u.id === referralAgent)?.name || "" : "No agent",
|
|
||||||
}}
|
|
||||||
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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="flex gap-4 justify-between mt-4 w-full">
|
<div className="flex gap-4 justify-between mt-4 w-full">
|
||||||
<div className="self-start flex gap-4 justify-start items-center 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 && (
|
{onViewStudents && (
|
||||||
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewStudents}>
|
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewStudents}>
|
||||||
View Students
|
View Students
|
||||||
|
|||||||
@@ -2,38 +2,38 @@ import {Type} from "@/interfaces/user";
|
|||||||
|
|
||||||
export const PERMISSIONS = {
|
export const PERMISSIONS = {
|
||||||
generateCode: {
|
generateCode: {
|
||||||
student: ["corporate", "developer", "owner"],
|
student: ["corporate", "developer", "admin"],
|
||||||
teacher: ["corporate", "developer", "owner"],
|
teacher: ["corporate", "developer", "admin"],
|
||||||
corporate: ["owner", "developer"],
|
corporate: ["admin", "developer"],
|
||||||
owner: ["developer", "owner"],
|
admin: ["developer", "admin"],
|
||||||
agent: ["developer", "owner"],
|
agent: ["developer", "admin"],
|
||||||
developer: ["developer"],
|
developer: ["developer"],
|
||||||
},
|
},
|
||||||
deleteUser: {
|
deleteUser: {
|
||||||
student: ["teacher", "corporate", "developer", "owner"],
|
student: ["teacher", "corporate", "developer", "admin"],
|
||||||
teacher: ["corporate", "developer", "owner"],
|
teacher: ["corporate", "developer", "admin"],
|
||||||
corporate: ["owner", "developer"],
|
corporate: ["admin", "developer"],
|
||||||
owner: ["developer", "owner"],
|
admin: ["developer", "admin"],
|
||||||
agent: ["developer", "owner"],
|
agent: ["developer", "admin"],
|
||||||
developer: ["developer"],
|
developer: ["developer"],
|
||||||
},
|
},
|
||||||
updateUser: {
|
updateUser: {
|
||||||
student: ["teacher", "corporate", "developer", "owner"],
|
student: ["teacher", "corporate", "developer", "admin"],
|
||||||
teacher: ["corporate", "developer", "owner"],
|
teacher: ["corporate", "developer", "admin"],
|
||||||
corporate: ["owner", "developer"],
|
corporate: ["admin", "developer"],
|
||||||
owner: ["developer", "owner"],
|
admin: ["developer", "admin"],
|
||||||
agent: ["developer", "owner"],
|
agent: ["developer", "admin"],
|
||||||
developer: ["developer"],
|
developer: ["developer"],
|
||||||
},
|
},
|
||||||
updateExpiryDate: {
|
updateExpiryDate: {
|
||||||
student: ["developer", "owner"],
|
student: ["developer", "admin"],
|
||||||
teacher: ["developer", "owner"],
|
teacher: ["developer", "admin"],
|
||||||
corporate: ["owner", "developer"],
|
corporate: ["admin", "developer"],
|
||||||
owner: ["developer", "owner"],
|
admin: ["developer", "admin"],
|
||||||
agent: ["developer", "owner"],
|
agent: ["developer", "admin"],
|
||||||
developer: ["developer"],
|
developer: ["developer"],
|
||||||
},
|
},
|
||||||
examManagement: {
|
examManagement: {
|
||||||
delete: ["developer", "owner"],
|
delete: ["developer", "admin"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,16 +7,27 @@ import UserList from "@/pages/(admin)/Lists/UserList";
|
|||||||
import {dateSorter} from "@/utils";
|
import {dateSorter} from "@/utils";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {BsArrowLeft, BsGlobeCentralSouthAsia, BsPerson, BsPersonFill, BsPersonFillGear, BsPersonGear, BsPersonLinesFill} from "react-icons/bs";
|
import {
|
||||||
|
BsArrowLeft,
|
||||||
|
BsBriefcaseFill,
|
||||||
|
BsGlobeCentralSouthAsia,
|
||||||
|
BsPerson,
|
||||||
|
BsPersonFill,
|
||||||
|
BsPersonFillGear,
|
||||||
|
BsPersonGear,
|
||||||
|
BsPersonLinesFill,
|
||||||
|
} from "react-icons/bs";
|
||||||
import UserCard from "@/components/UserCard";
|
import UserCard from "@/components/UserCard";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
import IconCard from "./IconCard";
|
import IconCard from "./IconCard";
|
||||||
|
import useFilterStore from "@/stores/listFilterStore";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OwnerDashboard({user}: Props) {
|
export default function AdminDashboard({user}: Props) {
|
||||||
const [page, setPage] = useState("");
|
const [page, setPage] = useState("");
|
||||||
const [selectedUser, setSelectedUser] = useState<User>();
|
const [selectedUser, setSelectedUser] = useState<User>();
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
@@ -25,6 +36,9 @@ export default function OwnerDashboard({user}: Props) {
|
|||||||
const {users, reload} = useUsers();
|
const {users, reload} = useUsers();
|
||||||
const {groups} = useGroups();
|
const {groups} = useGroups();
|
||||||
|
|
||||||
|
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowModal(!!selectedUser && page === "");
|
setShowModal(!!selectedUser && page === "");
|
||||||
}, [selectedUser, page]);
|
}, [selectedUser, page]);
|
||||||
@@ -35,7 +49,11 @@ export default function OwnerDashboard({user}: Props) {
|
|||||||
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
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" />
|
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
|
||||||
<div className="flex flex-col gap-1 items-start">
|
<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>
|
<span className="text-sm opacity-75">{displayUser.email}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -48,7 +66,7 @@ export default function OwnerDashboard({user}: Props) {
|
|||||||
? groups
|
? groups
|
||||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||||
.flatMap((g) => g.participants)
|
.flatMap((g) => g.participants)
|
||||||
.includes(x.id) || false
|
.includes(x.id)
|
||||||
: true);
|
: true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -63,7 +81,7 @@ export default function OwnerDashboard({user}: Props) {
|
|||||||
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
|
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UserList user={user} filter={filter} />
|
<UserList user={user} filters={[filter]} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -90,7 +108,27 @@ export default function OwnerDashboard({user}: Props) {
|
|||||||
<h2 className="text-2xl font-semibold">Teachers ({users.filter(filter).length})</h2>
|
<h2 className="text-2xl font-semibold">Teachers ({users.filter(filter).length})</h2>
|
||||||
</div>
|
</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,7 +145,7 @@ export default function OwnerDashboard({user}: Props) {
|
|||||||
<h2 className="text-2xl font-semibold">Corporate ({users.filter((x) => x.type === "corporate").length})</h2>
|
<h2 className="text-2xl font-semibold">Corporate ({users.filter((x) => x.type === "corporate").length})</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UserList user={user} filter={(x) => x.type === "corporate"} />
|
<UserList user={user} filters={[(x) => x.type === "corporate"]} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -126,7 +164,7 @@ export default function OwnerDashboard({user}: Props) {
|
|||||||
<h2 className="text-2xl font-semibold">Inactive Students ({users.filter(filter).length})</h2>
|
<h2 className="text-2xl font-semibold">Inactive Students ({users.filter(filter).length})</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UserList user={user} filter={filter} />
|
<UserList user={user} filters={[filter]} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -146,7 +184,7 @@ export default function OwnerDashboard({user}: Props) {
|
|||||||
<h2 className="text-2xl font-semibold">Inactive Corporate ({users.filter(filter).length})</h2>
|
<h2 className="text-2xl font-semibold">Inactive Corporate ({users.filter(filter).length})</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UserList user={user} filter={filter} />
|
<UserList user={user} filters={[filter]} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -175,6 +213,13 @@ export default function OwnerDashboard({user}: Props) {
|
|||||||
onClick={() => setPage("corporate")}
|
onClick={() => setPage("corporate")}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsBriefcaseFill}
|
||||||
|
label="Country Managers"
|
||||||
|
value={users.filter((x) => x.type === "agent").length}
|
||||||
|
onClick={() => setPage("agents")}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsGlobeCentralSouthAsia}
|
Icon={BsGlobeCentralSouthAsia}
|
||||||
label="Countries"
|
label="Countries"
|
||||||
@@ -338,9 +383,65 @@ export default function OwnerDashboard({user}: Props) {
|
|||||||
if (shouldReload) reload();
|
if (shouldReload) reload();
|
||||||
}}
|
}}
|
||||||
onViewStudents={
|
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}
|
user={selectedUser}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -350,6 +451,7 @@ export default function OwnerDashboard({user}: Props) {
|
|||||||
{page === "students" && <StudentsList />}
|
{page === "students" && <StudentsList />}
|
||||||
{page === "teachers" && <TeachersList />}
|
{page === "teachers" && <TeachersList />}
|
||||||
{page === "corporate" && <CorporateList />}
|
{page === "corporate" && <CorporateList />}
|
||||||
|
{page === "agents" && <AgentsList />}
|
||||||
{page === "inactiveStudents" && <InactiveStudentsList />}
|
{page === "inactiveStudents" && <InactiveStudentsList />}
|
||||||
{page === "inactiveCorporate" && <InactiveCorporateList />}
|
{page === "inactiveCorporate" && <InactiveCorporateList />}
|
||||||
{page === "" && <DefaultDashboard />}
|
{page === "" && <DefaultDashboard />}
|
||||||
183
src/dashboards/Agent.tsx
Normal file
183
src/dashboards/Agent.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/* 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,
|
||||||
|
BsClipboard2Data,
|
||||||
|
BsClipboard2DataFill,
|
||||||
|
BsClock,
|
||||||
|
BsGlobeCentralSouthAsia,
|
||||||
|
BsPaperclip,
|
||||||
|
BsPerson,
|
||||||
|
BsPersonAdd,
|
||||||
|
BsPersonFill,
|
||||||
|
BsPersonFillGear,
|
||||||
|
BsPersonGear,
|
||||||
|
BsPersonLinesFill,
|
||||||
|
} 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 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 = () => {
|
||||||
|
const filter = (x: User) => x.type === "corporate" && !!x.corporateInformation && x.corporateInformation.referralAgent === user.id;
|
||||||
|
|
||||||
|
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">Referred Corporate ({users.filter(filter).length})</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UserList user={user} filters={[filter]} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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">Referred 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="Referred Corporate"
|
||||||
|
value={users.filter(referredCorporateFilter).length}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
onClick={() => setPage("corporate")}
|
||||||
|
Icon={BsPersonFill}
|
||||||
|
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 Referred 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>
|
||||||
|
</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 === "" && <DefaultDashboard />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ import {Module} from "@/interfaces";
|
|||||||
import {groupByExam} from "@/utils/stats";
|
import {groupByExam} from "@/utils/stats";
|
||||||
import IconCard from "./IconCard";
|
import IconCard from "./IconCard";
|
||||||
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
||||||
|
import useFilterStore from "@/stores/listFilterStore";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -43,6 +45,9 @@ export default function CorporateDashboard({user}: Props) {
|
|||||||
const {users, reload} = useUsers();
|
const {users, reload} = useUsers();
|
||||||
const {groups} = useGroups(user.id);
|
const {groups} = useGroups(user.id);
|
||||||
|
|
||||||
|
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowModal(!!selectedUser && page === "");
|
setShowModal(!!selectedUser && page === "");
|
||||||
}, [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>
|
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
|
||||||
</div>
|
</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>
|
<h2 className="text-2xl font-semibold">Teachers ({users.filter(filter).length})</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UserList user={user} filter={filter} />
|
<UserList user={user} filters={[filter]} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -256,9 +261,45 @@ export default function CorporateDashboard({user}: Props) {
|
|||||||
if (shouldReload) reload();
|
if (shouldReload) reload();
|
||||||
}}
|
}}
|
||||||
onViewStudents={
|
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}
|
user={selectedUser}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
|
import PayPalPayment from "@/components/PayPalPayment";
|
||||||
import ProfileSummary from "@/components/ProfileSummary";
|
import ProfileSummary from "@/components/ProfileSummary";
|
||||||
import useAssignments from "@/hooks/useAssignments";
|
import useAssignments from "@/hooks/useAssignments";
|
||||||
import useStats from "@/hooks/useStats";
|
import useStats from "@/hooks/useStats";
|
||||||
@@ -9,12 +10,16 @@ import useExamStore from "@/stores/examStore";
|
|||||||
import {getExamById} from "@/utils/exams";
|
import {getExamById} from "@/utils/exams";
|
||||||
import {MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
|
import {MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
|
||||||
import {averageScore, groupBySession} from "@/utils/stats";
|
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 clsx from "clsx";
|
||||||
import {capitalize} from "lodash";
|
import {capitalize} from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {BsArrowRepeat, BsBook, BsClipboard, 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 {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
|
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UserList user={user} filter={filter} />
|
<UserList user={user} filters={[filter]} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,15 +19,28 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Level({exam, showSolutions = false, onFinish}: Props) {
|
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 [exerciseIndex, setExerciseIndex] = useState(0);
|
||||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
|
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
|
||||||
|
|
||||||
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
|
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) => {
|
const nextExercise = (solution?: UserSolution) => {
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
}
|
}
|
||||||
|
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.exercises.length) {
|
if (exerciseIndex + 1 < exam.exercises.length) {
|
||||||
setExerciseIndex((prev) => prev + 1);
|
setExerciseIndex((prev) => prev + 1);
|
||||||
@@ -70,7 +83,7 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
|
|||||||
<div className="flex flex-col h-full w-full gap-8 items-center">
|
<div className="flex flex-col h-full w-full gap-8 items-center">
|
||||||
<ModuleTitle
|
<ModuleTitle
|
||||||
minTimer={exam.minTimer}
|
minTimer={exam.minTimer}
|
||||||
exerciseIndex={exerciseIndex + 1}
|
exerciseIndex={exerciseIndex + 1 + questionIndex + currentQuestionIndex}
|
||||||
module="level"
|
module="level"
|
||||||
totalExercises={countExercises(exam.exercises)}
|
totalExercises={countExercises(exam.exercises)}
|
||||||
disableTimer={showSolutions}
|
disableTimer={showSolutions}
|
||||||
@@ -78,11 +91,11 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
|
|||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
exerciseIndex < exam.exercises.length &&
|
exerciseIndex < exam.exercises.length &&
|
||||||
!showSolutions &&
|
!showSolutions &&
|
||||||
renderExercise(getExercise(), nextExercise, previousExercise)}
|
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
exerciseIndex < exam.exercises.length &&
|
exerciseIndex < exam.exercises.length &&
|
||||||
showSolutions &&
|
showSolutions &&
|
||||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Listening({exam, showSolutions = false, onFinish}: 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 [exerciseIndex, setExerciseIndex] = useState(showSolutions ? 0 : -1);
|
||||||
const [partIndex, setPartIndex] = useState(0);
|
const [partIndex, setPartIndex] = useState(0);
|
||||||
const [timesListened, setTimesListened] = useState(0);
|
const [timesListened, setTimesListened] = useState(0);
|
||||||
@@ -33,6 +35,10 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
|||||||
}
|
}
|
||||||
}, [hasExamEnded, exerciseIndex]);
|
}, [hasExamEnded, exerciseIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentQuestionIndex(0);
|
||||||
|
}, [questionIndex]);
|
||||||
|
|
||||||
const confirmFinishModule = (keepGoing?: boolean) => {
|
const confirmFinishModule = (keepGoing?: boolean) => {
|
||||||
if (!keepGoing) {
|
if (!keepGoing) {
|
||||||
setShowBlankModal(false);
|
setShowBlankModal(false);
|
||||||
@@ -46,6 +52,7 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
|||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
}
|
}
|
||||||
|
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
||||||
setExerciseIndex((prev) => prev + 1);
|
setExerciseIndex((prev) => prev + 1);
|
||||||
@@ -130,7 +137,10 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
|||||||
.flatMap((x) => x.exercises)
|
.flatMap((x) => x.exercises)
|
||||||
.findIndex(
|
.findIndex(
|
||||||
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
|
(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}
|
minTimer={exam.minTimer}
|
||||||
module="listening"
|
module="listening"
|
||||||
@@ -141,11 +151,11 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
|||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||||
!showSolutions &&
|
!showSolutions &&
|
||||||
renderExercise(getExercise(), nextExercise, previousExercise)}
|
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||||
showSolutions &&
|
showSolutions &&
|
||||||
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
|
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||||
</div>
|
</div>
|
||||||
{exerciseIndex === -1 && partIndex > 0 && (
|
{exerciseIndex === -1 && partIndex > 0 && (
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
<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) {
|
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 [exerciseIndex, setExerciseIndex] = useState(showSolutions ? 0 : -1);
|
||||||
const [partIndex, setPartIndex] = useState(0);
|
const [partIndex, setPartIndex] = useState(0);
|
||||||
const [showTextModal, setShowTextModal] = useState(false);
|
const [showTextModal, setShowTextModal] = useState(false);
|
||||||
@@ -105,6 +107,10 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentQuestionIndex(0);
|
||||||
|
}, [questionIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded && exerciseIndex === -1) {
|
if (hasExamEnded && exerciseIndex === -1) {
|
||||||
setExerciseIndex((prev) => prev + 1);
|
setExerciseIndex((prev) => prev + 1);
|
||||||
@@ -124,6 +130,7 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
}
|
}
|
||||||
|
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
||||||
setExerciseIndex((prev) => prev + 1);
|
setExerciseIndex((prev) => prev + 1);
|
||||||
@@ -207,7 +214,10 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
.flatMap((x) => x.exercises)
|
.flatMap((x) => x.exercises)
|
||||||
.findIndex(
|
.findIndex(
|
||||||
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
|
(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"
|
module="reading"
|
||||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||||
@@ -219,11 +229,11 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||||
!showSolutions &&
|
!showSolutions &&
|
||||||
renderExercise(getExercise(), nextExercise, previousExercise)}
|
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||||
showSolutions &&
|
showSolutions &&
|
||||||
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
|
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||||
</div>
|
</div>
|
||||||
{exerciseIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
|
{exerciseIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -20,14 +20,21 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Speaking({exam, showSolutions = false, onFinish}: 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 [exerciseIndex, setExerciseIndex] = useState(0);
|
||||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
|
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
|
||||||
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
|
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentQuestionIndex(0);
|
||||||
|
}, [questionIndex]);
|
||||||
|
|
||||||
const nextExercise = (solution?: UserSolution) => {
|
const nextExercise = (solution?: UserSolution) => {
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
}
|
}
|
||||||
|
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.exercises.length) {
|
if (exerciseIndex + 1 < exam.exercises.length) {
|
||||||
setExerciseIndex((prev) => prev + 1);
|
setExerciseIndex((prev) => prev + 1);
|
||||||
@@ -71,7 +78,7 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
|||||||
<ModuleTitle
|
<ModuleTitle
|
||||||
label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
|
label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
|
||||||
minTimer={exam.minTimer}
|
minTimer={exam.minTimer}
|
||||||
exerciseIndex={exerciseIndex + 1}
|
exerciseIndex={exerciseIndex + 1 + questionIndex + currentQuestionIndex}
|
||||||
module="speaking"
|
module="speaking"
|
||||||
totalExercises={countExercises(exam.exercises)}
|
totalExercises={countExercises(exam.exercises)}
|
||||||
disableTimer={showSolutions}
|
disableTimer={showSolutions}
|
||||||
@@ -79,11 +86,11 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
|||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
exerciseIndex < exam.exercises.length &&
|
exerciseIndex < exam.exercises.length &&
|
||||||
!showSolutions &&
|
!showSolutions &&
|
||||||
renderExercise(getExercise(), nextExercise, previousExercise)}
|
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
exerciseIndex < exam.exercises.length &&
|
exerciseIndex < exam.exercises.length &&
|
||||||
showSolutions &&
|
showSolutions &&
|
||||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ const firebaseConfig = {
|
|||||||
storageBucket: process.env.FIREBASE_STORAGE_BUCKET || "",
|
storageBucket: process.env.FIREBASE_STORAGE_BUCKET || "",
|
||||||
messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID || "",
|
messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID || "",
|
||||||
appId: process.env.FIREBASE_APP_ID || "",
|
appId: process.env.FIREBASE_APP_ID || "",
|
||||||
measurementId: process.env.FIREBASE_MEASUREMENT_ID || "",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const app = initializeApp(firebaseConfig, Math.random().toString());
|
export const app = initializeApp(firebaseConfig, Math.random().toString());
|
||||||
|
|||||||
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};
|
||||||
|
}
|
||||||
@@ -3,13 +3,7 @@ import {Module} from ".";
|
|||||||
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
|
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
|
||||||
|
|
||||||
export interface ReadingExam {
|
export interface ReadingExam {
|
||||||
parts: {
|
parts: ReadingPart[];
|
||||||
text: {
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
};
|
|
||||||
exercises: Exercise[];
|
|
||||||
}[];
|
|
||||||
id: string;
|
id: string;
|
||||||
module: "reading";
|
module: "reading";
|
||||||
minTimer: number;
|
minTimer: number;
|
||||||
@@ -17,6 +11,14 @@ export interface ReadingExam {
|
|||||||
isDiagnostic: boolean;
|
isDiagnostic: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReadingPart {
|
||||||
|
text: {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
exercises: Exercise[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface LevelExam {
|
export interface LevelExam {
|
||||||
module: "level";
|
module: "level";
|
||||||
id: string;
|
id: string;
|
||||||
@@ -26,19 +28,21 @@ export interface LevelExam {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ListeningExam {
|
export interface ListeningExam {
|
||||||
parts: {
|
parts: ListeningPart[];
|
||||||
audio: {
|
|
||||||
source: string;
|
|
||||||
repeatableTimes: number; // *The amount of times the user is allowed to repeat the audio, 0 for unlimited
|
|
||||||
};
|
|
||||||
exercises: Exercise[];
|
|
||||||
}[];
|
|
||||||
id: string;
|
id: string;
|
||||||
module: "listening";
|
module: "listening";
|
||||||
minTimer: number;
|
minTimer: number;
|
||||||
isDiagnostic: boolean;
|
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 {
|
export interface UserSolution {
|
||||||
solutions: any[];
|
solutions: any[];
|
||||||
module?: Module;
|
module?: Module;
|
||||||
|
|||||||
35
src/interfaces/paypal.ts
Normal file
35
src/interfaces/paypal.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import {Module} from ".";
|
import {Module} from ".";
|
||||||
|
|
||||||
export interface User {
|
export type User = StudentUser | TeacherUser | CorporateUser | AgentUser | AdminUser | DeveloperUser;
|
||||||
|
|
||||||
|
export interface BasicUser {
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
profilePicture: string;
|
profilePicture: string;
|
||||||
id: string;
|
id: string;
|
||||||
experience: number;
|
|
||||||
isFirstLogin: boolean;
|
isFirstLogin: boolean;
|
||||||
focus: "academic" | "general";
|
focus: "academic" | "general";
|
||||||
levels: {[key in Module]: number};
|
levels: {[key in Module]: number};
|
||||||
@@ -13,22 +14,56 @@ export interface User {
|
|||||||
type: Type;
|
type: Type;
|
||||||
bio: string;
|
bio: string;
|
||||||
isVerified: boolean;
|
isVerified: boolean;
|
||||||
demographicInformation?: DemographicInformation;
|
|
||||||
corporateInformation?: CorporateInformation;
|
|
||||||
subscriptionExpirationDate?: null | Date;
|
subscriptionExpirationDate?: null | Date;
|
||||||
registrationDate?: Date;
|
registrationDate?: Date;
|
||||||
status: "active" | "disabled" | "paymentDue";
|
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 {
|
export interface CorporateInformation {
|
||||||
companyInformation: CompanyInformation;
|
companyInformation: CompanyInformation;
|
||||||
|
monthlyDuration: number;
|
||||||
payment?: {
|
payment?: {
|
||||||
value: number;
|
value: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
};
|
};
|
||||||
monthlyDuration: number;
|
|
||||||
referralAgent?: string;
|
referralAgent?: string;
|
||||||
allowedUserAmount?: number;
|
}
|
||||||
|
|
||||||
|
export interface AgentInformation {
|
||||||
|
companyName: string;
|
||||||
|
commercialRegistration: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompanyInformation {
|
export interface CompanyInformation {
|
||||||
@@ -43,6 +78,13 @@ export interface DemographicInformation {
|
|||||||
employment: EmploymentStatus;
|
employment: EmploymentStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DemographicCorporateInformation {
|
||||||
|
country: string;
|
||||||
|
phone: string;
|
||||||
|
gender: Gender;
|
||||||
|
position: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type Gender = "male" | "female" | "other";
|
export type Gender = "male" | "female" | "other";
|
||||||
export type EmploymentStatus = "employed" | "student" | "self-employed" | "unemployed" | "retired" | "other";
|
export type EmploymentStatus = "employed" | "student" | "self-employed" | "unemployed" | "retired" | "other";
|
||||||
export const EMPLOYMENT_STATUS: {status: EmploymentStatus; label: string}[] = [
|
export const EMPLOYMENT_STATUS: {status: EmploymentStatus; label: string}[] = [
|
||||||
@@ -80,5 +122,5 @@ export interface Group {
|
|||||||
disableEditing?: boolean;
|
disableEditing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Type = "student" | "teacher" | "corporate" | "owner" | "developer" | "agent";
|
export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent";
|
||||||
export const userTypes: Type[] = ["student", "teacher", "corporate", "owner", "developer", "agent"];
|
export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent"];
|
||||||
|
|||||||
@@ -14,5 +14,6 @@ export const sessionOptions: IronSessionOptions = {
|
|||||||
declare module "iron-session" {
|
declare module "iron-session" {
|
||||||
interface IronSessionData {
|
interface IronSessionData {
|
||||||
user?: User | null;
|
user?: User | null;
|
||||||
|
envVariables?: {[key: string]: string};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||||
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {Type, User} from "@/interfaces/user";
|
import {Type, User} from "@/interfaces/user";
|
||||||
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize} from "lodash";
|
import {capitalize} from "lodash";
|
||||||
@@ -17,6 +19,9 @@ export default function BatchCodeGenerator({user}: {user: User}) {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
|
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
|
||||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||||
|
const [type, setType] = useState<Type>("student");
|
||||||
|
|
||||||
|
const {users} = useUsers();
|
||||||
|
|
||||||
const {openFilePicker, filesContent} = useFilePicker({
|
const {openFilePicker, filesContent} = useFilePicker({
|
||||||
accept: ".txt",
|
accept: ".txt",
|
||||||
@@ -38,15 +43,18 @@ export default function BatchCodeGenerator({user}: {user: User}) {
|
|||||||
const file = filesContent[0];
|
const file = filesContent[0];
|
||||||
const emails = file.content
|
const emails = file.content
|
||||||
.split("\n")
|
.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) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setEmails([...new Set(emails)]);
|
setEmails([...new Set(emails)]);
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [filesContent]);
|
}, [filesContent]);
|
||||||
|
|
||||||
const generateCode = (type: Type) => {
|
const generateCode = (type: Type) => {
|
||||||
@@ -83,7 +91,7 @@ export default function BatchCodeGenerator({user}: {user: User}) {
|
|||||||
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
|
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
|
||||||
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
||||||
</Button>
|
</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">
|
<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>
|
<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>
|
<label className="font-normal text-base text-mti-gray-dim">Select the type of user they should be</label>
|
||||||
{user && (
|
{user && (
|
||||||
<div className="grid -md:grid-cols-2 md:grid-cols-1 xl:grid-cols-2 gap-4 place-items-center">
|
<select
|
||||||
<Button
|
defaultValue="student"
|
||||||
className="w-44 2xl:w-48"
|
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||||
variant="outline"
|
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">
|
||||||
onClick={() => generateCode("student")}
|
{Object.keys(USER_TYPE_LABELS).map((type) => (
|
||||||
disabled={emails.length === 0 || isLoading || !PERMISSIONS.generateCode.student.includes(user.type)}>
|
<option key={type} value={type}>
|
||||||
Student
|
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||||
</Button>
|
</option>
|
||||||
<Button
|
))}
|
||||||
className="w-44 2xl:w-48"
|
</select>
|
||||||
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)}>
|
|
||||||
Corporate
|
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
|
<Button onClick={() => generateCode(type)} disabled={emails.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}>
|
||||||
|
Generate & Send
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Button from "@/components/Low/Button";
|
|||||||
import Checkbox from "@/components/Low/Checkbox";
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||||
import {Type, User} from "@/interfaces/user";
|
import {Type, User} from "@/interfaces/user";
|
||||||
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize} from "lodash";
|
import {capitalize} from "lodash";
|
||||||
@@ -15,6 +16,7 @@ export default function CodeGenerator({user}: {user: User}) {
|
|||||||
const [generatedCode, setGeneratedCode] = useState<string>();
|
const [generatedCode, setGeneratedCode] = useState<string>();
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
|
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
|
||||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||||
|
const [type, setType] = useState<Type>("student");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user && (user.type === "corporate" || user.type === "teacher")) {
|
if (user && (user.type === "corporate" || user.type === "teacher")) {
|
||||||
@@ -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">
|
<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>
|
<label className="font-normal text-base text-mti-gray-dim">User Code Generator</label>
|
||||||
{user && (
|
{user && (
|
||||||
<div className="grid -md:grid-cols-2 md:grid-cols-1 place-items-center 2xl:grid-cols-2 gap-4">
|
<select
|
||||||
<Button
|
defaultValue="student"
|
||||||
className="w-44 md:w-48"
|
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||||
variant="outline"
|
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">
|
||||||
onClick={() => generateCode("student")}
|
{Object.keys(USER_TYPE_LABELS).map((type) => (
|
||||||
disabled={!PERMISSIONS.generateCode.student.includes(user.type) || (isExpiryDateEnabled && expiryDate === null)}>
|
<option key={type} value={type}>
|
||||||
Student
|
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||||
</Button>
|
</option>
|
||||||
<Button
|
))}
|
||||||
className="w-44 md:w-48"
|
</select>
|
||||||
variant="outline"
|
|
||||||
onClick={() => generateCode("teacher")}
|
|
||||||
disabled={!PERMISSIONS.generateCode.teacher.includes(user.type) || (isExpiryDateEnabled && expiryDate === null)}>
|
|
||||||
Teacher
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="w-44 md:w-48"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => generateCode("corporate")}
|
|
||||||
disabled={!PERMISSIONS.generateCode.corporate.includes(user.type) || (isExpiryDateEnabled && expiryDate === null)}>
|
|
||||||
Corporate
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="w-44 md:w-48"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => generateCode("owner")}
|
|
||||||
disabled={!PERMISSIONS.generateCode.owner.includes(user.type) || (isExpiryDateEnabled && expiryDate === null)}>
|
|
||||||
Owner
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{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">
|
<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>
|
<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>
|
<label className="font-normal text-base text-mti-gray-dim">Generated Code:</label>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export default function ExamList({user}: {user: User}) {
|
|||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<tr key={headerGroup.id}>
|
<tr key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => (
|
{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())}
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -51,7 +51,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 emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined);
|
||||||
const filteredUsers = emailUsers.filter(
|
const filteredUsers = emailUsers.filter(
|
||||||
(x) =>
|
(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")) ||
|
(x?.type === "student" || x?.type === "teacher")) ||
|
||||||
(user.type === "teacher" && x?.type === "student"),
|
(user.type === "teacher" && x?.type === "student"),
|
||||||
);
|
);
|
||||||
@@ -216,7 +216,7 @@ export default function GroupList({user}: {user: User}) {
|
|||||||
cell: ({row}: {row: {original: Group}}) => {
|
cell: ({row}: {row: {original: Group}}) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(user?.type === "developer" || user?.type === "owner" || user.id === row.original.admin) && (
|
{(user?.type === "developer" || user?.type === "admin" || user.id === row.original.admin) && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{editingID !== row.original.id && (
|
{editingID !== row.original.id && (
|
||||||
<div data-tip="Edit" className="cursor-pointer tooltip" onClick={() => setEditingID(row.original.id)}>
|
<div data-tip="Edit" className="cursor-pointer tooltip" onClick={() => setEditingID(row.original.id)}>
|
||||||
|
|||||||
@@ -16,17 +16,23 @@ import {countries, TCountries} from "countries-list";
|
|||||||
import countryCodes from "country-codes-list";
|
import countryCodes from "country-codes-list";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import UserCard from "@/components/UserCard";
|
import UserCard from "@/components/UserCard";
|
||||||
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
|
import useFilterStore from "@/stores/listFilterStore";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<User>();
|
const columnHelper = createColumnHelper<User>();
|
||||||
|
|
||||||
export default function UserList({user, filter}: {user: User; filter?: (user: User) => boolean}) {
|
export default function UserList({user, filters = []}: {user: User; filters?: ((user: User) => boolean)[]}) {
|
||||||
const [showDemographicInformation, setShowDemographicInformation] = useState(false);
|
const [showDemographicInformation, setShowDemographicInformation] = useState(false);
|
||||||
const [sorter, setSorter] = useState<string>();
|
const [sorter, setSorter] = useState<string>();
|
||||||
const [displayUsers, setDisplayUsers] = useState<User[]>([]);
|
const [displayUsers, setDisplayUsers] = useState<User[]>([]);
|
||||||
const [selectedUser, setSelectedUser] = useState<User>();
|
const [selectedUser, setSelectedUser] = useState<User>();
|
||||||
|
|
||||||
const {users, reload} = useUsers();
|
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 expirationDateColor = (date: Date) => {
|
||||||
const momentDate = moment(date);
|
const momentDate = moment(date);
|
||||||
@@ -41,11 +47,11 @@ export default function UserList({user, filter}: {user: User; filter?: (user: Us
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user && users) {
|
if (user && users) {
|
||||||
const filterUsers =
|
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.filter((u) => groups.flatMap((g) => g.participants).includes(u.id))
|
||||||
: users;
|
: users;
|
||||||
|
|
||||||
const filteredUsers = filter ? filterUsers.filter(filter) : filterUsers;
|
const filteredUsers = filters.reduce((d, f) => d.filter(f), filterUsers);
|
||||||
|
|
||||||
setDisplayUsers([...filteredUsers.sort(sortFunction)]);
|
setDisplayUsers([...filteredUsers.sort(sortFunction)]);
|
||||||
}
|
}
|
||||||
@@ -159,13 +165,13 @@ export default function UserList({user, filter}: {user: User; filter?: (user: Us
|
|||||||
onClick={() => updateAccountType(row.original, "corporate")}
|
onClick={() => updateAccountType(row.original, "corporate")}
|
||||||
className="text-sm !py-2 !px-4"
|
className="text-sm !py-2 !px-4"
|
||||||
disabled={row.original.type === "corporate" || !PERMISSIONS.generateCode["corporate"].includes(user.type)}>
|
disabled={row.original.type === "corporate" || !PERMISSIONS.generateCode["corporate"].includes(user.type)}>
|
||||||
Admin
|
Corporate
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => updateAccountType(row.original, "owner")}
|
onClick={() => updateAccountType(row.original, "admin")}
|
||||||
className="text-sm !py-2 !px-4"
|
className="text-sm !py-2 !px-4"
|
||||||
disabled={row.original.type === "owner" || !PERMISSIONS.generateCode["owner"].includes(user.type)}>
|
disabled={row.original.type === "admin" || !PERMISSIONS.generateCode["admin"].includes(user.type)}>
|
||||||
Owner
|
Admin
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Popover.Panel>
|
</Popover.Panel>
|
||||||
@@ -241,14 +247,15 @@ export default function UserList({user, filter}: {user: User; filter?: (user: Us
|
|||||||
cell: (info) => info.getValue() || "Not available",
|
cell: (info) => info.getValue() || "Not available",
|
||||||
enableSorting: true,
|
enableSorting: true,
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("demographicInformation.employment", {
|
columnHelper.accessor((x) => (x.type === "corporate" ? x.demographicInformation?.position : x.demographicInformation?.employment), {
|
||||||
|
id: "employment",
|
||||||
header: (
|
header: (
|
||||||
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "employment"))}>
|
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "employment"))}>
|
||||||
<span>Employment</span>
|
<span>Employment/Position</span>
|
||||||
<SorterArrow name="employment" />
|
<SorterArrow name="employment" />
|
||||||
</button>
|
</button>
|
||||||
) as any,
|
) 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,
|
enableSorting: true,
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("demographicInformation.gender", {
|
columnHelper.accessor("demographicInformation.gender", {
|
||||||
@@ -287,7 +294,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",
|
"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)}>
|
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>
|
</div>
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
@@ -316,7 +323,7 @@ export default function UserList({user, filter}: {user: User; filter?: (user: Us
|
|||||||
<SorterArrow name="type" />
|
<SorterArrow name="type" />
|
||||||
</button>
|
</button>
|
||||||
) as any,
|
) as any,
|
||||||
cell: (info) => capitalize(info.getValue()),
|
cell: (info) => USER_TYPE_LABELS[info.getValue()],
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("subscriptionExpirationDate", {
|
columnHelper.accessor("subscriptionExpirationDate", {
|
||||||
header: (
|
header: (
|
||||||
@@ -418,13 +425,14 @@ export default function UserList({user, filter}: {user: User; filter?: (user: Us
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sorter === "employment" || sorter === reverseString("employment")) {
|
if (sorter === "employment" || sorter === reverseString("employment")) {
|
||||||
if (!a.demographicInformation?.employment && b.demographicInformation?.employment) return sorter === "employment" ? -1 : 1;
|
const aSortingItem = a.type === "corporate" ? a.demographicInformation?.position : a.demographicInformation?.employment;
|
||||||
if (a.demographicInformation?.employment && !b.demographicInformation?.employment) return sorter === "employment" ? 1 : -1;
|
const bSortingItem = b.type === "corporate" ? b.demographicInformation?.position : b.demographicInformation?.employment;
|
||||||
if (!a.demographicInformation?.employment && !b.demographicInformation?.employment) return 0;
|
|
||||||
|
|
||||||
return sorter === "employment"
|
if (!aSortingItem && bSortingItem) return sorter === "employment" ? -1 : 1;
|
||||||
? a.demographicInformation!.employment.localeCompare(b.demographicInformation!.employment)
|
if (aSortingItem && !bSortingItem) return sorter === "employment" ? 1 : -1;
|
||||||
: b.demographicInformation!.employment.localeCompare(a.demographicInformation!.employment);
|
if (!aSortingItem && !bSortingItem) return 0;
|
||||||
|
|
||||||
|
return sorter === "employment" ? aSortingItem!.localeCompare(bSortingItem!) : bSortingItem!.localeCompare(aSortingItem!);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sorter === "gender" || sorter === reverseString("gender")) {
|
if (sorter === "gender" || sorter === reverseString("gender")) {
|
||||||
@@ -454,6 +462,66 @@ export default function UserList({user, filter}: {user: User; filter?: (user: Us
|
|||||||
<div className="w-full flex flex-col gap-8">
|
<div className="w-full flex flex-col gap-8">
|
||||||
<UserCard
|
<UserCard
|
||||||
loggedInUser={user}
|
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) => {
|
onClose={(shouldReload) => {
|
||||||
setSelectedUser(undefined);
|
setSelectedUser(undefined);
|
||||||
if (shouldReload) reload();
|
if (shouldReload) reload();
|
||||||
|
|||||||
@@ -259,15 +259,6 @@ export default function ExamPage({page}: Props) {
|
|||||||
|
|
||||||
return (
|
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 />
|
<ToastContainer />
|
||||||
{user && (
|
{user && (
|
||||||
<Layout
|
<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;
|
||||||
@@ -61,15 +61,14 @@ export default function RegisterCorporate({isLoading, setIsLoading, mutateUser,
|
|||||||
password,
|
password,
|
||||||
type: "corporate",
|
type: "corporate",
|
||||||
profilePicture: "/defaultAvatar.png",
|
profilePicture: "/defaultAvatar.png",
|
||||||
subscriptionExpirationDate: moment().add(1, "days").add(subscriptionDuration, "months").toISOString(),
|
subscriptionExpirationDate: moment().subtract(1, "days").toISOString(),
|
||||||
corporateInformation: {
|
corporateInformation: {
|
||||||
companyInformation: {
|
companyInformation: {
|
||||||
name: companyName,
|
name: companyName,
|
||||||
userAmount: companyUsers,
|
userAmount: companyUsers,
|
||||||
},
|
},
|
||||||
referralAgent,
|
|
||||||
allowedUserAmount: companyUsers,
|
|
||||||
monthlyDuration: subscriptionDuration,
|
monthlyDuration: subscriptionDuration,
|
||||||
|
referralAgent,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
@@ -126,8 +125,8 @@ export default function RegisterCorporate({isLoading, setIsLoading, mutateUser,
|
|||||||
type="text"
|
type="text"
|
||||||
name="companyName"
|
name="companyName"
|
||||||
onChange={(e) => setCompanyName(e)}
|
onChange={(e) => setCompanyName(e)}
|
||||||
placeholder="Institution name"
|
placeholder="Corporate name"
|
||||||
label="Institution name"
|
label="Corporate name"
|
||||||
defaultValue={companyName}
|
defaultValue={companyName}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -135,7 +134,7 @@ export default function RegisterCorporate({isLoading, setIsLoading, mutateUser,
|
|||||||
type="number"
|
type="number"
|
||||||
name="companyUsers"
|
name="companyUsers"
|
||||||
onChange={(e) => setCompanyUsers(parseInt(e))}
|
onChange={(e) => setCompanyUsers(parseInt(e))}
|
||||||
label="Amount of users"
|
label="Number of users"
|
||||||
defaultValue={companyUsers}
|
defaultValue={companyUsers}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import {sendEmailVerification} from "@/utils/email";
|
import {sendEmailVerification} from "@/utils/email";
|
||||||
@@ -21,6 +22,7 @@ export default function RegisterIndividual({queryCode, isLoading, setIsLoading,
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [code, setCode] = useState(queryCode || "");
|
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!");
|
const onSuccess = () => toast.success("An e-mail has been sent, please make sure to check your spam folder!");
|
||||||
|
|
||||||
@@ -88,12 +90,27 @@ export default function RegisterIndividual({queryCode, isLoading, setIsLoading,
|
|||||||
defaultValue={confirmPassword}
|
defaultValue={confirmPassword}
|
||||||
required
|
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
|
<Button
|
||||||
className="lg:mt-8 w-full"
|
className="lg:mt-8 w-full"
|
||||||
color="purple"
|
color="purple"
|
||||||
disabled={isLoading || !email || !name || !password || !confirmPassword || password !== confirmPassword || !code}>
|
disabled={isLoading || !email || !name || !password || !confirmPassword || password !== confirmPassword || (hasCode ? !code : false)}>
|
||||||
Create account
|
Create account
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</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() {
|
export default function Document() {
|
||||||
return (
|
return (
|
||||||
<Html lang="en">
|
<Html lang="en">
|
||||||
<Head />
|
<Head />
|
||||||
<body>
|
<body>
|
||||||
<Main />
|
<Main />
|
||||||
<NextScript />
|
<NextScript />
|
||||||
</body>
|
</body>
|
||||||
</Html>
|
</Html>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
// 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.session.user) {
|
|
||||||
res.status(401).json({ok: false});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.session.user.type !== "developer") {
|
|
||||||
res.status(403).json({ok: false});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {module} = req.query as {module: Module};
|
|
||||||
|
|
||||||
switch (module) {
|
|
||||||
case "reading":
|
|
||||||
const result = await axios.get(
|
|
||||||
`${process.env.BACKEND_URL}/reading_passage_1?topic=football manager video game&exercises=multipleChoice&exercises=trueFalse&exercises=fillBlanks&exercises=writeBlanks`,
|
|
||||||
{headers: {Authorization: `Bearer ${process.env.BACKEND_JWT}`}},
|
|
||||||
);
|
|
||||||
res.status(200).json(result.data);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(200).json({ok: true});
|
|
||||||
}
|
|
||||||
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,18 +1,26 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
import {app} from "@/firebase";
|
||||||
import {getFirestore, collection, getDocs, query, where} from "firebase/firestore";
|
import {getFirestore, collection, getDocs, query, where, setDoc, doc} from "firebase/firestore";
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {shuffle} from "lodash";
|
import {shuffle} from "lodash";
|
||||||
import {Exam} from "@/interfaces/exam";
|
import {Exam} from "@/interfaces/exam";
|
||||||
import {Stat} from "@/interfaces/user";
|
import {Stat} from "@/interfaces/user";
|
||||||
|
import {v4} from "uuid";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = getFirestore(app);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
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) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ok: false});
|
||||||
return;
|
return;
|
||||||
@@ -45,3 +53,21 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
res.status(200).json(exams);
|
res.status(200).json(exams);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function POST(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (!req.session.user) {
|
||||||
|
res.status(401).json({ok: false});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,6 +13,12 @@ const db = getFirestore(app);
|
|||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
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) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ok: false});
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ const db = getFirestore(app);
|
|||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.method === "GET") await get(req, res);
|
if (req.method === "GET") return await get(req, res);
|
||||||
if (req.method === "DELETE") await del(req, res);
|
if (req.method === "DELETE") return await del(req, res);
|
||||||
if (req.method === "PATCH") await patch(req, res);
|
if (req.method === "PATCH") return await patch(req, res);
|
||||||
|
|
||||||
res.status(404).json(undefined);
|
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 group = {...snapshot.data(), id: snapshot.id} as Group;
|
||||||
|
|
||||||
const user = req.session.user;
|
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);
|
await deleteDoc(snapshot.ref);
|
||||||
|
|
||||||
res.status(200).json({ok: true});
|
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 group = {...snapshot.data(), id: snapshot.id} as Group;
|
||||||
|
|
||||||
const user = req.session.user;
|
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});
|
await setDoc(snapshot.ref, req.body, {merge: true});
|
||||||
|
|
||||||
res.status(200).json({ok: true});
|
res.status(200).json({ok: true});
|
||||||
|
|||||||
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", "owner"].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});
|
||||||
|
}
|
||||||
76
src/pages/api/payments/[id].ts
Normal file
76
src/pages/api/payments/[id].ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// 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, getDoc, doc, deleteDoc, setDoc} from "firebase/firestore";
|
||||||
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
|
import {sessionOptions} from "@/lib/session";
|
||||||
|
import {Group} from "@/interfaces/user";
|
||||||
|
|
||||||
|
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 user = req.session.user;
|
||||||
|
if (user.type === "admin" || user.type === "developer") {
|
||||||
|
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});
|
||||||
|
}
|
||||||
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});
|
||||||
|
}
|
||||||
83
src/pages/api/paypal/approve.ts
Normal file
83
src/pages/api/paypal/approve.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
// 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 axios from "axios";
|
||||||
|
import {DurationUnit, TokenError, TokenSuccess} from "@/interfaces/paypal";
|
||||||
|
import {base64} from "@firebase/util";
|
||||||
|
import {v4} from "uuid";
|
||||||
|
import {OrderResponseBody} from "@paypal/paypal-js";
|
||||||
|
import {getAccessToken} from "@/utils/paypal";
|
||||||
|
import moment from "moment";
|
||||||
|
import {Group} from "@/interfaces/user";
|
||||||
|
|
||||||
|
const db = getFirestore(app);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method !== "POST") return res.status(404).json({ok: false, reason: "Method not supported!"});
|
||||||
|
if (!req.session.user) return res.status(401).json({ok: false});
|
||||||
|
|
||||||
|
const accessToken = await getAccessToken();
|
||||||
|
if (!accessToken) return res.status(401).json({ok: false, reason: "Authorization failed!"});
|
||||||
|
|
||||||
|
const {id, duration, duration_unit} = req.body as {id: string; duration: number; duration_unit: DurationUnit};
|
||||||
|
|
||||||
|
const request = await axios.post(
|
||||||
|
`${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders/${id}/capture`,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (request.data.status === "COMPLETED") {
|
||||||
|
const user = req.session.user;
|
||||||
|
const subscriptionExpirationDate = req.session.user.subscriptionExpirationDate;
|
||||||
|
const today = moment(new Date());
|
||||||
|
const dateToBeAddedTo = !subscriptionExpirationDate
|
||||||
|
? today
|
||||||
|
: moment(subscriptionExpirationDate).isAfter(today)
|
||||||
|
? moment(subscriptionExpirationDate)
|
||||||
|
: today;
|
||||||
|
|
||||||
|
const updatedExpirationDate = dateToBeAddedTo.add(duration, duration_unit);
|
||||||
|
await setDoc(
|
||||||
|
doc(db, "users", req.session.user.id),
|
||||||
|
{subscriptionExpirationDate: updatedExpirationDate.toISOString(), status: "active"},
|
||||||
|
{merge: true},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (user.type === "corporate") {
|
||||||
|
const snapshot = await getDocs(collection(db, "groups"));
|
||||||
|
const groups: Group[] = (
|
||||||
|
snapshot.docs.map((doc) => ({
|
||||||
|
id: doc.id,
|
||||||
|
...doc.data(),
|
||||||
|
})) as Group[]
|
||||||
|
).filter((x) => x.admin === user.id);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
groups
|
||||||
|
.flatMap((x) => x.participants)
|
||||||
|
.map(
|
||||||
|
async (x) =>
|
||||||
|
await setDoc(
|
||||||
|
doc(db, "users", x),
|
||||||
|
{subscriptionExpirationDate: updatedExpirationDate.toISOString(), status: "active"},
|
||||||
|
{merge: true},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({ok: true});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(404).json({ok: false, reason: "Order ID not found or purchase was not approved!"});
|
||||||
|
}
|
||||||
47
src/pages/api/paypal/index.ts
Normal file
47
src/pages/api/paypal/index.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// 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} from "firebase/firestore";
|
||||||
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
|
import {sessionOptions} from "@/lib/session";
|
||||||
|
import axios from "axios";
|
||||||
|
import {v4} from "uuid";
|
||||||
|
import {OrderResponseBody} from "@paypal/paypal-js";
|
||||||
|
import {getAccessToken} from "@/utils/paypal";
|
||||||
|
|
||||||
|
const db = getFirestore(app);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method !== "POST") return res.status(404).json({ok: false, reason: "Method not supported!"});
|
||||||
|
if (!req.session.user) return res.status(401).json({ok: false});
|
||||||
|
|
||||||
|
const accessToken = await getAccessToken();
|
||||||
|
if (!accessToken) return res.status(401).json({ok: false, reason: "Authorization failed!"});
|
||||||
|
|
||||||
|
const {currencyCode, price} = req.body as {currencyCode: string; price: number};
|
||||||
|
|
||||||
|
const request = await axios.post<OrderResponseBody>(
|
||||||
|
`${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders`,
|
||||||
|
{
|
||||||
|
purchase_units: [
|
||||||
|
{
|
||||||
|
amount: {
|
||||||
|
currency_code: currencyCode,
|
||||||
|
value: price.toString(),
|
||||||
|
},
|
||||||
|
reference_id: v4(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
intent: "CAPTURE",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(request.status).json(request.data);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {withIronSessionApiRoute} from "iron-session/next";
|
|||||||
import {getFirestore, doc, setDoc, query, collection, where, getDocs} from "firebase/firestore";
|
import {getFirestore, doc, setDoc, query, collection, where, getDocs} from "firebase/firestore";
|
||||||
import {CorporateInformation, DemographicInformation, Type} from "@/interfaces/user";
|
import {CorporateInformation, DemographicInformation, Type} from "@/interfaces/user";
|
||||||
import {addUserToGroupOnCreation} from "@/utils/registration";
|
import {addUserToGroupOnCreation} from "@/utils/registration";
|
||||||
|
import moment from "moment";
|
||||||
|
|
||||||
const auth = getAuth(app);
|
const auth = getAuth(app);
|
||||||
const db = getFirestore(app);
|
const db = getFirestore(app);
|
||||||
@@ -45,12 +46,12 @@ async function registerIndividual(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const codeQuery = query(collection(db, "codes"), where("code", "==", code));
|
const codeQuery = query(collection(db, "codes"), where("code", "==", code));
|
||||||
const codeDocs = (await getDocs(codeQuery)).docs.filter((x) => !Object.keys(x.data()).includes("userId"));
|
const codeDocs = (await getDocs(codeQuery)).docs.filter((x) => !Object.keys(x.data()).includes("userId"));
|
||||||
|
|
||||||
if (codeDocs.length === 0) {
|
if (code && code.length > 0 && codeDocs.length === 0) {
|
||||||
res.status(400).json({error: "Invalid Code!"});
|
res.status(400).json({error: "Invalid Code!"});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const codeData = codeDocs[0].data() as {code: string; type: Type; creator?: string; expiryDate: Date | null};
|
const codeData = codeDocs.length > 0 ? (codeDocs[0].data() as {code: string; type: Type; creator?: string; expiryDate: Date | null}) : undefined;
|
||||||
|
|
||||||
createUserWithEmailAndPassword(auth, email, password)
|
createUserWithEmailAndPassword(auth, email, password)
|
||||||
.then(async (userCredentials) => {
|
.then(async (userCredentials) => {
|
||||||
@@ -62,16 +63,20 @@ async function registerIndividual(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
desiredLevels: DEFAULT_DESIRED_LEVELS,
|
desiredLevels: DEFAULT_DESIRED_LEVELS,
|
||||||
levels: DEFAULT_LEVELS,
|
levels: DEFAULT_LEVELS,
|
||||||
bio: "",
|
bio: "",
|
||||||
isFirstLogin: codeData.type === "student",
|
isFirstLogin: codeData ? codeData.type === "student" : true,
|
||||||
focus: "academic",
|
focus: "academic",
|
||||||
type: codeData.type,
|
type: codeData ? codeData.type : "student",
|
||||||
subscriptionExpirationDate: codeData.expiryDate,
|
subscriptionExpirationDate: codeData ? codeData.expiryDate : moment().subtract(1, "days").toISOString(),
|
||||||
registrationDate: new Date(),
|
registrationDate: new Date(),
|
||||||
|
status: code ? "active" : "paymentDue",
|
||||||
};
|
};
|
||||||
|
|
||||||
await setDoc(doc(db, "users", userId), user);
|
await setDoc(doc(db, "users", userId), user);
|
||||||
await setDoc(codeDocs[0].ref, {userId: userId}, {merge: true});
|
|
||||||
if (codeData.creator) await addUserToGroupOnCreation(userId, codeData.type, codeData.creator);
|
if (codeDocs.length > 0 && codeData) {
|
||||||
|
await setDoc(codeDocs[0].ref, {userId: userId}, {merge: true});
|
||||||
|
if (codeData.creator) await addUserToGroupOnCreation(userId, codeData.type, codeData.creator);
|
||||||
|
}
|
||||||
|
|
||||||
req.session.user = {...user, id: userId};
|
req.session.user = {...user, id: userId};
|
||||||
await req.session.save();
|
await req.session.save();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {withIronSessionSsr} from "iron-session/next";
|
|||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
||||||
import ExamPage from "./(exam)/ExamPage";
|
import ExamPage from "./(exam)/ExamPage";
|
||||||
|
import Head from "next/head";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
@@ -36,5 +37,18 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
|||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return <ExamPage page="exams" />;
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Exams | 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>
|
||||||
|
<ExamPage page="exams" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {withIronSessionSsr} from "iron-session/next";
|
|||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
||||||
import ExamPage from "./(exam)/ExamPage";
|
import ExamPage from "./(exam)/ExamPage";
|
||||||
|
import Head from "next/head";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
@@ -36,5 +37,18 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
|||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return <ExamPage page="exercises" />;
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Exercises | 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>
|
||||||
|
<ExamPage page="exercises" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
125
src/pages/generation.tsx
Normal file
125
src/pages/generation.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import Head from "next/head";
|
||||||
|
import {withIronSessionSsr} from "iron-session/next";
|
||||||
|
import {sessionOptions} from "@/lib/session";
|
||||||
|
import useUser from "@/hooks/useUser";
|
||||||
|
import {toast, ToastContainer} from "react-toastify";
|
||||||
|
import Layout from "@/components/High/Layout";
|
||||||
|
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
||||||
|
import {useState} from "react";
|
||||||
|
import {Module} from "@/interfaces";
|
||||||
|
import {RadioGroup, Tab} from "@headlessui/react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||||
|
import {capitalize} from "lodash";
|
||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import {Exercise, ReadingPart} from "@/interfaces/exam";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
|
import axios from "axios";
|
||||||
|
import ReadingGeneration from "./(generation)/ReadingGeneration";
|
||||||
|
import ListeningGeneration from "./(generation)/ListeningGeneration";
|
||||||
|
import WritingGeneration from "./(generation)/WritingGeneration";
|
||||||
|
import LevelGeneration from "./(generation)/LevelGeneration";
|
||||||
|
import SpeakingGeneration from "./(generation)/SpeakingGeneration";
|
||||||
|
|
||||||
|
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||||
|
const user = req.session.user;
|
||||||
|
|
||||||
|
if (!user || !user.isVerified) {
|
||||||
|
res.setHeader("location", "/login");
|
||||||
|
res.statusCode = 302;
|
||||||
|
res.end();
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
user: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldRedirectHome(user) || user.type !== "developer") {
|
||||||
|
res.setHeader("location", "/");
|
||||||
|
res.statusCode = 302;
|
||||||
|
res.end();
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
user: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {user: req.session.user},
|
||||||
|
};
|
||||||
|
}, sessionOptions);
|
||||||
|
|
||||||
|
export default function Generation() {
|
||||||
|
const [module, setModule] = useState<Module>("reading");
|
||||||
|
|
||||||
|
const {user} = useUser({redirectTo: "/login"});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Exam Generation | 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 user={user} className="gap-6">
|
||||||
|
<h1 className="text-2xl font-semibold">Exam Generation</h1>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Module</label>
|
||||||
|
<RadioGroup
|
||||||
|
value={module}
|
||||||
|
onChange={setModule}
|
||||||
|
className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
|
||||||
|
{[...MODULE_ARRAY, "level"].map((x) => (
|
||||||
|
<RadioGroup.Option value={x} key={x}>
|
||||||
|
{({checked}) => (
|
||||||
|
<span
|
||||||
|
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",
|
||||||
|
x === "reading" &&
|
||||||
|
(!checked
|
||||||
|
? "bg-white border-mti-gray-platinum"
|
||||||
|
: "bg-ielts-reading/70 border-ielts-reading text-white"),
|
||||||
|
x === "listening" &&
|
||||||
|
(!checked
|
||||||
|
? "bg-white border-mti-gray-platinum"
|
||||||
|
: "bg-ielts-listening/70 border-ielts-listening text-white"),
|
||||||
|
x === "writing" &&
|
||||||
|
(!checked
|
||||||
|
? "bg-white border-mti-gray-platinum"
|
||||||
|
: "bg-ielts-writing/70 border-ielts-writing text-white"),
|
||||||
|
x === "speaking" &&
|
||||||
|
(!checked
|
||||||
|
? "bg-white border-mti-gray-platinum"
|
||||||
|
: "bg-ielts-speaking/70 border-ielts-speaking text-white"),
|
||||||
|
x === "level" &&
|
||||||
|
(!checked
|
||||||
|
? "bg-white border-mti-gray-platinum"
|
||||||
|
: "bg-ielts-level/70 border-ielts-level text-white"),
|
||||||
|
)}>
|
||||||
|
{capitalize(x)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</RadioGroup.Option>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
{module === "reading" && <ReadingGeneration />}
|
||||||
|
{module === "listening" && <ListeningGeneration />}
|
||||||
|
{module === "writing" && <WritingGeneration />}
|
||||||
|
{module === "speaking" && <SpeakingGeneration />}
|
||||||
|
{module === "level" && <LevelGeneration />}
|
||||||
|
</Layout>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,13 +23,24 @@ import Link from "next/link";
|
|||||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||||
import ProfileSummary from "@/components/ProfileSummary";
|
import ProfileSummary from "@/components/ProfileSummary";
|
||||||
import StudentDashboard from "@/dashboards/Student";
|
import StudentDashboard from "@/dashboards/Student";
|
||||||
import OwnerDashboard from "@/dashboards/Owner";
|
import AdminDashboard from "@/dashboards/Admin";
|
||||||
import CorporateDashboard from "@/dashboards/Corporate";
|
import CorporateDashboard from "@/dashboards/Corporate";
|
||||||
import TeacherDashboard from "@/dashboards/Teacher";
|
import TeacherDashboard from "@/dashboards/Teacher";
|
||||||
|
import AgentDashboard from "@/dashboards/Agent";
|
||||||
|
import PaymentDue from "./(status)/PaymentDue";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import {PayPalScriptProvider} from "@paypal/react-paypal-js";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
|
|
||||||
|
const envVariables: {[key: string]: string} = {};
|
||||||
|
Object.keys(process.env)
|
||||||
|
.filter((x) => x.startsWith("NEXT_PUBLIC"))
|
||||||
|
.forEach((x: string) => {
|
||||||
|
envVariables[x] = process.env[x]!;
|
||||||
|
});
|
||||||
|
|
||||||
if (!user || !user.isVerified) {
|
if (!user || !user.isVerified) {
|
||||||
res.setHeader("location", "/login");
|
res.setHeader("location", "/login");
|
||||||
res.statusCode = 302;
|
res.statusCode = 302;
|
||||||
@@ -37,24 +48,27 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
|||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
user: null,
|
user: null,
|
||||||
|
envVariables,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {user: req.session.user},
|
props: {user: req.session.user, envVariables},
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home({envVariables}: {envVariables: {[key: string]: string}}) {
|
||||||
const [showDiagnostics, setShowDiagnostics] = useState(false);
|
const [showDiagnostics, setShowDiagnostics] = useState(false);
|
||||||
const [showDemographicInput, setShowDemographicInput] = useState(false);
|
const [showDemographicInput, setShowDemographicInput] = useState(false);
|
||||||
const {user, mutateUser} = useUser({redirectTo: "/login"});
|
const {user, mutateUser} = useUser({redirectTo: "/login"});
|
||||||
|
const {stats} = useStats(user?.id);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
setShowDemographicInput(!user.demographicInformation);
|
setShowDemographicInput(!user.demographicInformation);
|
||||||
setShowDiagnostics(user.isFirstLogin);
|
setShowDiagnostics(user.isFirstLogin && user.type === "student");
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
@@ -67,7 +81,7 @@ export default function Home() {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (user && (user.status === "disabled" || checkIfUserExpired())) {
|
if (user && (user.status === "paymentDue" || user.status === "disabled" || checkIfUserExpired())) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
@@ -79,34 +93,23 @@ export default function Home() {
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<Layout user={user} navDisabled>
|
{user.status === "disabled" && (
|
||||||
<div className="flex flex-col items-center justify-center text-center w-full gap-4">
|
<Layout user={user} navDisabled>
|
||||||
{user.status === "disabled" ? (
|
<div className="flex flex-col items-center justify-center text-center w-full gap-4">
|
||||||
<>
|
<span className="font-bold text-lg">Your account has been disabled!</span>
|
||||||
<span className="font-bold text-lg">Your account has been disabled!</span>
|
<span>Please contact an administrator if you believe this to be a mistake.</span>
|
||||||
<span>Please contact an administrator if you believe this to be a mistake.</span>
|
</div>
|
||||||
</>
|
</Layout>
|
||||||
) : (
|
)}
|
||||||
<>
|
{(user.status === "paymentDue" || checkIfUserExpired()) && (
|
||||||
<span className="font-bold text-lg">Your subscription has expired!</span>
|
<PaymentDue
|
||||||
<div className="flex flex-col items-center">
|
key={envVariables["NEXT_PUBLIC_PAYPAL_CLIENT_ID"]}
|
||||||
<span>
|
hasExpired
|
||||||
Please purchase a new time pack{" "}
|
user={user}
|
||||||
<Link
|
reload={router.reload}
|
||||||
className="font-bold text-mti-purple-light underline hover:text-mti-purple-dark transition ease-in-out duration-300"
|
clientID={envVariables["NEXT_PUBLIC_PAYPAL_CLIENT_ID"] || ""}
|
||||||
href="https://encoach.com/join">
|
/>
|
||||||
here
|
)}
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</span>
|
|
||||||
<span className="max-w-md">
|
|
||||||
If you are not the one in charge of your subscription, please contact the one responsible to extend it.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -124,7 +127,7 @@ export default function Home() {
|
|||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<Layout user={user} navDisabled>
|
<Layout user={user} navDisabled>
|
||||||
<DemographicInformationInput mutateUser={mutateUser} />
|
<DemographicInformationInput mutateUser={mutateUser} user={user} />
|
||||||
</Layout>
|
</Layout>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -166,8 +169,9 @@ export default function Home() {
|
|||||||
{user.type === "student" && <StudentDashboard user={user} />}
|
{user.type === "student" && <StudentDashboard user={user} />}
|
||||||
{user.type === "teacher" && <TeacherDashboard user={user} />}
|
{user.type === "teacher" && <TeacherDashboard user={user} />}
|
||||||
{user.type === "corporate" && <CorporateDashboard user={user} />}
|
{user.type === "corporate" && <CorporateDashboard user={user} />}
|
||||||
{user.type === "owner" && <OwnerDashboard user={user} />}
|
{user.type === "agent" && <AgentDashboard user={user} />}
|
||||||
{user.type === "developer" && <OwnerDashboard user={user} />}
|
{user.type === "admin" && <AdminDashboard user={user} />}
|
||||||
|
{user.type === "developer" && <AdminDashboard user={user} />}
|
||||||
</Layout>
|
</Layout>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
80
src/pages/list/users.tsx
Normal file
80
src/pages/list/users.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import Layout from "@/components/High/Layout";
|
||||||
|
import useUser from "@/hooks/useUser";
|
||||||
|
import useUsers from "@/hooks/useUsers";
|
||||||
|
import {sessionOptions} from "@/lib/session";
|
||||||
|
import useFilterStore from "@/stores/listFilterStore";
|
||||||
|
import {withIronSessionSsr} from "iron-session/next";
|
||||||
|
import Head from "next/head";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import {useEffect} from "react";
|
||||||
|
import {BsArrowLeft} from "react-icons/bs";
|
||||||
|
import {ToastContainer} from "react-toastify";
|
||||||
|
import UserList from "../(admin)/Lists/UserList";
|
||||||
|
|
||||||
|
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||||
|
const user = req.session.user;
|
||||||
|
|
||||||
|
const envVariables: {[key: string]: string} = {};
|
||||||
|
Object.keys(process.env)
|
||||||
|
.filter((x) => x.startsWith("NEXT_PUBLIC"))
|
||||||
|
.forEach((x: string) => {
|
||||||
|
envVariables[x] = process.env[x]!;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.isVerified) {
|
||||||
|
res.setHeader("location", "/login");
|
||||||
|
res.statusCode = 302;
|
||||||
|
res.end();
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
user: null,
|
||||||
|
envVariables,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {user: req.session.user, envVariables},
|
||||||
|
};
|
||||||
|
}, sessionOptions);
|
||||||
|
|
||||||
|
export default function UsersListPage() {
|
||||||
|
const {user} = useUser();
|
||||||
|
const {users} = useUsers();
|
||||||
|
const [filters, clearFilters] = useFilterStore((state) => [state.userFilters, state.clearUserFilters]);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>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 user={user}>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
clearFilters();
|
||||||
|
router.back();
|
||||||
|
}}
|
||||||
|
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">Users ({filters.map((f) => f.filter).reduce((d, f) => d.filter(f), users).length})</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UserList user={user} filters={filters.map((f) => f.filter)} />
|
||||||
|
</Layout>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
417
src/pages/payment-record.tsx
Normal file
417
src/pages/payment-record.tsx
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import Head from "next/head";
|
||||||
|
import {withIronSessionSsr} from "iron-session/next";
|
||||||
|
import {sessionOptions} from "@/lib/session";
|
||||||
|
import useUser from "@/hooks/useUser";
|
||||||
|
import {toast, ToastContainer} from "react-toastify";
|
||||||
|
import Layout from "@/components/High/Layout";
|
||||||
|
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
||||||
|
import usePayments from "@/hooks/usePayments";
|
||||||
|
import {Payment} from "@/interfaces/paypal";
|
||||||
|
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
||||||
|
import {CURRENCIES} from "@/resources/paypal";
|
||||||
|
import {BsTrash} from "react-icons/bs";
|
||||||
|
import axios from "axios";
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
import {AgentUser, CorporateUser, User} from "@/interfaces/user";
|
||||||
|
import UserCard from "@/components/UserCard";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import useUsers from "@/hooks/useUsers";
|
||||||
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import Select from "react-select";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
|
import ReactDatePicker from "react-datepicker";
|
||||||
|
import moment from "moment";
|
||||||
|
|
||||||
|
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||||
|
const user = req.session.user;
|
||||||
|
|
||||||
|
if (!user || !user.isVerified) {
|
||||||
|
res.setHeader("location", "/login");
|
||||||
|
res.statusCode = 302;
|
||||||
|
res.end();
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
user: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldRedirectHome(user) || !["admin", "developer"].includes(user.type)) {
|
||||||
|
res.setHeader("location", "/");
|
||||||
|
res.statusCode = 302;
|
||||||
|
res.end();
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
user: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {user: req.session.user},
|
||||||
|
};
|
||||||
|
}, sessionOptions);
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<Payment>();
|
||||||
|
|
||||||
|
const PaymentCreator = ({onClose, reload}: {onClose: () => void; reload: () => void}) => {
|
||||||
|
const [corporate, setCorporate] = useState<CorporateUser>();
|
||||||
|
const [price, setPrice] = useState<number>(0);
|
||||||
|
const [currency, setCurrency] = useState<string>("EUR");
|
||||||
|
const [commission, setCommission] = useState<number>(0);
|
||||||
|
const [referralAgent, setReferralAgent] = useState<AgentUser>();
|
||||||
|
const [date, setDate] = useState<Date>(new Date());
|
||||||
|
|
||||||
|
const {users} = useUsers();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!corporate) return setReferralAgent(undefined);
|
||||||
|
if (!corporate.corporateInformation?.referralAgent) return setReferralAgent(undefined);
|
||||||
|
|
||||||
|
const referralAgent = users.find((u) => u.id === corporate.corporateInformation.referralAgent);
|
||||||
|
setReferralAgent(referralAgent as AgentUser | undefined);
|
||||||
|
}, [corporate, users]);
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
axios
|
||||||
|
.post(`/api/payments`, {
|
||||||
|
corporate: corporate?.id,
|
||||||
|
agent: referralAgent?.id,
|
||||||
|
agentCommission: commission,
|
||||||
|
agentValue: (commission / 100) * price,
|
||||||
|
currency,
|
||||||
|
value: price,
|
||||||
|
isPaid: false,
|
||||||
|
date: date.toISOString(),
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success("New payment has been created successfully!");
|
||||||
|
reload();
|
||||||
|
onClose();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Something went wrong, please try again later!");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<h1 className="text-2xl font-semibold">New Payment</h1>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Corporate account *</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={(users.filter((u) => u.type === "corporate") as CorporateUser[]).map((user) => ({
|
||||||
|
value: user.id,
|
||||||
|
meta: user,
|
||||||
|
label: `${user.corporateInformation.companyInformation.name || user.name} - ${user.email}`,
|
||||||
|
}))}
|
||||||
|
defaultValue={{value: "undefined", label: "Select an account"}}
|
||||||
|
onChange={(value) => setCorporate((value as any)?.meta ?? undefined)}
|
||||||
|
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">Price *</label>
|
||||||
|
<div className="w-full grid grid-cols-5 gap-2">
|
||||||
|
<Input
|
||||||
|
name="paymentValue"
|
||||||
|
onChange={(e) => setPrice(e ? parseInt(e) : 0)}
|
||||||
|
type="number"
|
||||||
|
defaultValue={0}
|
||||||
|
className="col-span-3"
|
||||||
|
/>
|
||||||
|
<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: "EUR", label: "Euro"}}
|
||||||
|
onChange={(value) => setCurrency(value?.value || "EUR")}
|
||||||
|
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 gap-4 w-full">
|
||||||
|
<div className="flex flex-col w-full gap-3">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Commission *</label>
|
||||||
|
<Input name="commission" onChange={(e) => setCommission(e ? parseInt(e) : 0)} type="number" defaultValue={0} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col w-full gap-3">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Commission Value*</label>
|
||||||
|
<Input
|
||||||
|
name="commissionValue"
|
||||||
|
value={`${(commission / 100) * price} ${CURRENCIES.find((c) => c.currency === currency)?.label}`}
|
||||||
|
onChange={() => null}
|
||||||
|
type="text"
|
||||||
|
defaultValue={0}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex flex-col w-full gap-3">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Date *</label>
|
||||||
|
<ReactDatePicker
|
||||||
|
className={clsx(
|
||||||
|
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
|
"hover:border-mti-purple tooltip",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
)}
|
||||||
|
dateFormat="dd/MM/yyyy"
|
||||||
|
selected={moment(date).toDate()}
|
||||||
|
onChange={(date) => setDate(date ?? new Date())}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col w-full gap-3">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Country Manager *</label>
|
||||||
|
<Input
|
||||||
|
name="referralAgent"
|
||||||
|
value={referralAgent ? `${referralAgent.name} - ${referralAgent.email}` : "No country manager"}
|
||||||
|
onChange={() => null}
|
||||||
|
type="text"
|
||||||
|
defaultValue={"No country manager"}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full justify-end items-center gap-8 mt-4">
|
||||||
|
<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={!corporate || !price}>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PaymentRecord() {
|
||||||
|
const [selectedUser, setSelectedUser] = useState<User>();
|
||||||
|
const [isCreatingPayment, setIsCreatingPayment] = useState(false);
|
||||||
|
|
||||||
|
const {user} = useUser({redirectTo: "/login"});
|
||||||
|
const {users} = useUsers();
|
||||||
|
const {payments, reload} = usePayments();
|
||||||
|
|
||||||
|
const updatePayment = (payment: Payment, key: string, value: any) => {
|
||||||
|
axios
|
||||||
|
.patch(`api/payments/${payment.id}`, {...payment, [key]: value})
|
||||||
|
.then(() => toast.success("Updated the payment"))
|
||||||
|
.finally(reload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deletePayment = (id: string) => {
|
||||||
|
if (!confirm(`Are you sure you want to delete this payment?`)) return;
|
||||||
|
|
||||||
|
axios
|
||||||
|
.delete(`/api/payments/${id}`)
|
||||||
|
.then(() => toast.success(`Deleted the "${id}" payment`))
|
||||||
|
.catch((reason) => {
|
||||||
|
if (reason.response.status === 404) {
|
||||||
|
toast.error("Exam 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("corporate", {
|
||||||
|
header: "Corporate",
|
||||||
|
cell: (info) => (
|
||||||
|
<div
|
||||||
|
className={clsx("underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer")}
|
||||||
|
onClick={() => setSelectedUser(users.find((x) => x.id === info.row.original.corporate))}>
|
||||||
|
{(users.find((x) => x.id === info.row.original.corporate) as CorporateUser)?.corporateInformation.companyInformation.name}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("date", {
|
||||||
|
header: "Date",
|
||||||
|
cell: (info) => <span>{moment(info.getValue()).format("DD/MM/YYYY")}</span>,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("value", {
|
||||||
|
header: "Amount",
|
||||||
|
cell: (info) => (
|
||||||
|
<span>
|
||||||
|
{info.getValue()} {CURRENCIES.find((x) => x.currency === info.row.original.currency)?.label}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("agent", {
|
||||||
|
header: "Country Manager",
|
||||||
|
cell: (info) => (
|
||||||
|
<div
|
||||||
|
className={clsx("underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer")}
|
||||||
|
onClick={() => setSelectedUser(users.find((x) => x.id === info.row.original.agent))}>
|
||||||
|
{(users.find((x) => x.id === info.row.original.agent) as AgentUser)?.name}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("agentCommission", {
|
||||||
|
header: "Commission",
|
||||||
|
cell: (info) => <>{info.getValue()}%</>,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("agentValue", {
|
||||||
|
header: "Commission Value",
|
||||||
|
cell: (info) => (
|
||||||
|
<span>
|
||||||
|
{info.getValue()} {CURRENCIES.find((x) => x.currency === info.row.original.currency)?.label}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("isPaid", {
|
||||||
|
header: "Paid",
|
||||||
|
cell: (info) => (
|
||||||
|
<Checkbox
|
||||||
|
isChecked={info.getValue()}
|
||||||
|
onChange={(e) => (user?.type !== "agent" ? updatePayment(info.row.original, "isPaid", e) : null)}>
|
||||||
|
<span></span>
|
||||||
|
</Checkbox>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
header: "",
|
||||||
|
id: "actions",
|
||||||
|
cell: ({row}: {row: {original: Payment}}) => {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{user?.type !== "agent" && (
|
||||||
|
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deletePayment(row.original.id)}>
|
||||||
|
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: (user?.type === "agent" ? payments.filter((p) => p.agent === user.id) : payments).sort((a, b) => moment(b.date).diff(moment(a.date))),
|
||||||
|
columns: defaultColumns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Payment Record | 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 user={user} className="gap-6">
|
||||||
|
<Modal isOpen={!!selectedUser} onClose={() => setSelectedUser(undefined)}>
|
||||||
|
<>
|
||||||
|
{selectedUser && (
|
||||||
|
<div className="w-full flex flex-col gap-8">
|
||||||
|
<UserCard
|
||||||
|
loggedInUser={user}
|
||||||
|
onClose={(shouldReload) => {
|
||||||
|
setSelectedUser(undefined);
|
||||||
|
if (shouldReload) reload();
|
||||||
|
}}
|
||||||
|
user={selectedUser}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal isOpen={isCreatingPayment} onClose={() => setIsCreatingPayment(false)}>
|
||||||
|
<PaymentCreator onClose={() => setIsCreatingPayment(false)} reload={reload} />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<div className="w-full flex flex-end justify-between p-2">
|
||||||
|
<h1 className="text-2xl font-semibold">Payment Record</h1>
|
||||||
|
{(user.type === "developer" || user.type === "admin") && (
|
||||||
|
<Button className="w-full max-w-[200px]" variant="outline" onClick={() => setIsCreatingPayment(true)}>
|
||||||
|
New Payment
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<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="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>
|
||||||
|
</Layout>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
src/pages/payment.tsx
Normal file
61
src/pages/payment.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import Head from "next/head";
|
||||||
|
import {withIronSessionSsr} from "iron-session/next";
|
||||||
|
import {sessionOptions} from "@/lib/session";
|
||||||
|
import useUser from "@/hooks/useUser";
|
||||||
|
import PaymentDue from "./(status)/PaymentDue";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
|
||||||
|
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||||
|
const user = req.session.user;
|
||||||
|
|
||||||
|
const envVariables: {[key: string]: string} = {};
|
||||||
|
Object.keys(process.env)
|
||||||
|
.filter((x) => x.startsWith("NEXT_PUBLIC"))
|
||||||
|
.forEach((x: string) => {
|
||||||
|
envVariables[x] = process.env[x]!;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.isVerified) {
|
||||||
|
res.setHeader("location", "/login");
|
||||||
|
res.statusCode = 302;
|
||||||
|
res.end();
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
user: null,
|
||||||
|
envVariables,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {user: req.session.user, envVariables},
|
||||||
|
};
|
||||||
|
}, sessionOptions);
|
||||||
|
|
||||||
|
export default function Home({envVariables}: {envVariables: {[key: string]: string}}) {
|
||||||
|
const {user, mutateUser} = useUser({redirectTo: "/login"});
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>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>
|
||||||
|
{user && (
|
||||||
|
<PaymentDue
|
||||||
|
key={envVariables["NEXT_PUBLIC_PAYPAL_CLIENT_ID"]}
|
||||||
|
clientID={envVariables["NEXT_PUBLIC_PAYPAL_CLIENT_ID"] || ""}
|
||||||
|
user={user}
|
||||||
|
reload={router.reload}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import CountrySelect from "@/components/Low/CountrySelect";
|
|||||||
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {BsCamera, BsCameraFill} from "react-icons/bs";
|
import {BsCamera, BsCameraFill} from "react-icons/bs";
|
||||||
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
@@ -62,6 +63,7 @@ export default function Home() {
|
|||||||
const [phone, setPhone] = useState<string>();
|
const [phone, setPhone] = useState<string>();
|
||||||
const [gender, setGender] = useState<Gender>();
|
const [gender, setGender] = useState<Gender>();
|
||||||
const [employment, setEmployment] = useState<EmploymentStatus>();
|
const [employment, setEmployment] = useState<EmploymentStatus>();
|
||||||
|
const [position, setPosition] = useState<string>();
|
||||||
|
|
||||||
const profilePictureInput = useRef(null);
|
const profilePictureInput = useRef(null);
|
||||||
|
|
||||||
@@ -85,7 +87,8 @@ export default function Home() {
|
|||||||
setCountry(user.demographicInformation?.country);
|
setCountry(user.demographicInformation?.country);
|
||||||
setPhone(user.demographicInformation?.phone);
|
setPhone(user.demographicInformation?.phone);
|
||||||
setGender(user.demographicInformation?.gender);
|
setGender(user.demographicInformation?.gender);
|
||||||
setEmployment(user.demographicInformation?.employment);
|
setEmployment(user.type === "corporate" ? undefined : user.demographicInformation?.employment);
|
||||||
|
setPosition(user.type === "corporate" ? user.demographicInformation?.position : undefined);
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
@@ -134,7 +137,8 @@ export default function Home() {
|
|||||||
demographicInformation: {
|
demographicInformation: {
|
||||||
phone,
|
phone,
|
||||||
country,
|
country,
|
||||||
employment,
|
employment: user?.type === "corporate" ? undefined : employment,
|
||||||
|
position: user?.type === "corporate" ? position : undefined,
|
||||||
gender,
|
gender,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -207,6 +211,29 @@ export default function Home() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{user.type === "agent" && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full">
|
||||||
|
<Input
|
||||||
|
label="Company Name"
|
||||||
|
type="text"
|
||||||
|
name="companyName"
|
||||||
|
onChange={() => null}
|
||||||
|
placeholder="Enter company name"
|
||||||
|
defaultValue={user.agentInformation.companyName}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Commercial Registration"
|
||||||
|
type="text"
|
||||||
|
name="commercialRegistration"
|
||||||
|
onChange={() => null}
|
||||||
|
placeholder="Enter company name"
|
||||||
|
defaultValue={user.agentInformation.commercialRegistration}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
||||||
@@ -223,30 +250,43 @@ export default function Home() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||||
<div className="relative flex flex-col gap-3 w-full">
|
{user.type === "corporate" && (
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Employment Status *</label>
|
<Input
|
||||||
<RadioGroup
|
name="position"
|
||||||
value={employment}
|
onChange={setPosition}
|
||||||
onChange={setEmployment}
|
defaultValue={position}
|
||||||
className="grid grid-cols-2 items-center gap-4 place-items-center">
|
type="text"
|
||||||
{EMPLOYMENT_STATUS.map(({status, label}) => (
|
label="Position"
|
||||||
<RadioGroup.Option value={status} key={status}>
|
placeholder="CEO, Head of Marketing..."
|
||||||
{({checked}) => (
|
required
|
||||||
<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",
|
{user.type !== "corporate" && (
|
||||||
"transition duration-300 ease-in-out",
|
<div className="relative flex flex-col gap-3 w-full">
|
||||||
!checked
|
<label className="font-normal text-base text-mti-gray-dim">Employment Status *</label>
|
||||||
? "bg-white border-mti-gray-platinum"
|
<RadioGroup
|
||||||
: "bg-mti-purple-light border-mti-purple-dark text-white",
|
value={employment}
|
||||||
)}>
|
onChange={setEmployment}
|
||||||
{label}
|
className="grid grid-cols-2 items-center gap-4 place-items-center">
|
||||||
</span>
|
{EMPLOYMENT_STATUS.map(({status, label}) => (
|
||||||
)}
|
<RadioGroup.Option value={status} key={status}>
|
||||||
</RadioGroup.Option>
|
{({checked}) => (
|
||||||
))}
|
<span
|
||||||
</RadioGroup>
|
className={clsx(
|
||||||
</div>
|
"px-6 py-4 w-40 md:w-48 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
!checked
|
||||||
|
? "bg-white border-mti-gray-platinum"
|
||||||
|
: "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||||
|
)}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</RadioGroup.Option>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex flex-col gap-8 w-full">
|
<div className="flex flex-col gap-8 w-full">
|
||||||
<div className="relative flex flex-col gap-3 w-full">
|
<div className="relative flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Gender *</label>
|
<label className="font-normal text-base text-mti-gray-dim">Gender *</label>
|
||||||
@@ -296,9 +336,9 @@ export default function Home() {
|
|||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
|
<label className="font-normal text-base text-mti-gray-dim">Expiry Date (click to purchase)</label>
|
||||||
<Link
|
<Link
|
||||||
href="https://encoach.com/join"
|
href="/payment"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"p-6 w-full flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
"p-6 w-full flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
@@ -334,6 +374,7 @@ export default function Home() {
|
|||||||
className="cursor-pointer text-mti-purple-light text-sm">
|
className="cursor-pointer text-mti-purple-light text-sm">
|
||||||
Change picture
|
Change picture
|
||||||
</span>
|
</span>
|
||||||
|
<h6 className="font-normal text-base text-mti-gray-taupe">{USER_TYPE_LABELS[user.type]}</h6>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 mt-8 mb-20">
|
<div className="flex flex-col gap-4 mt-8 mb-20">
|
||||||
|
|||||||
@@ -296,7 +296,7 @@ export default function History({user}: {user: User}) {
|
|||||||
<Layout user={user}>
|
<Layout user={user}>
|
||||||
<div className="w-full flex -xl:flex-col -xl:gap-4 justify-between items-center">
|
<div className="w-full flex -xl:flex-col -xl:gap-4 justify-between items-center">
|
||||||
<div className="xl:w-3/4">
|
<div className="xl:w-3/4">
|
||||||
{(user.type === "developer" || user.type === "owner") && (
|
{(user.type === "developer" || user.type === "admin") && (
|
||||||
<Select
|
<Select
|
||||||
options={users.map((x) => ({value: x.id, label: `${x.name} - ${x.email}`}))}
|
options={users.map((x) => ({value: x.id, label: `${x.name} - ${x.email}`}))}
|
||||||
defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}}
|
defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default function Register({code: queryCode}: {code: string}) {
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<main className="w-full min-h-[100vh] h-full flex bg-white text-black">
|
<main className="w-full h-[100vh] flex bg-white text-black">
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
<section className="h-full w-fit min-w-fit relative hidden lg:flex">
|
<section className="h-full w-fit min-w-fit relative hidden lg:flex">
|
||||||
<div className="absolute h-full w-full bg-mti-rose-light z-10 bg-opacity-50" />
|
<div className="absolute h-full w-full bg-mti-rose-light z-10 bg-opacity-50" />
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default function Admin() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>Admin Panel | EnCoach</title>
|
<title>Settings Panel | EnCoach</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||||
@@ -62,9 +62,13 @@ export default function Admin() {
|
|||||||
{user && (
|
{user && (
|
||||||
<Layout user={user} className="gap-6">
|
<Layout user={user} className="gap-6">
|
||||||
<section className="w-full flex -md:flex-col -xl:gap-2 gap-8 justify-between">
|
<section className="w-full flex -md:flex-col -xl:gap-2 gap-8 justify-between">
|
||||||
{user.email === "tiago.ribeiro@ecrop.dev" ? <ExamGenerator /> : <ExamLoader />}
|
<ExamLoader />
|
||||||
<CodeGenerator user={user} />
|
{user.type !== "teacher" && (
|
||||||
<BatchCodeGenerator user={user} />
|
<>
|
||||||
|
<CodeGenerator user={user} />
|
||||||
|
<BatchCodeGenerator user={user} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
<section className="w-full">
|
<section className="w-full">
|
||||||
<Lists user={user} />
|
<Lists user={user} />
|
||||||
@@ -169,7 +169,7 @@ export default function Stats() {
|
|||||||
<section className="flex flex-col gap-3">
|
<section className="flex flex-col gap-3">
|
||||||
<div className="w-full flex justify-between gap-8 items-center">
|
<div className="w-full flex justify-between gap-8 items-center">
|
||||||
<>
|
<>
|
||||||
{(user.type === "developer" || user.type === "owner") && (
|
{(user.type === "developer" || user.type === "admin") && (
|
||||||
<Select
|
<Select
|
||||||
className="w-full"
|
className="w-full"
|
||||||
options={users.map((x) => ({value: x.id, label: `${x.name} - ${x.email}`}))}
|
options={users.map((x) => ({value: x.id, label: `${x.name} - ${x.email}`}))}
|
||||||
|
|||||||
98
src/resources/paypal.ts
Normal file
98
src/resources/paypal.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
export const CURRENCIES: {label: string; currency: string}[] = [
|
||||||
|
{
|
||||||
|
label: "Australian dollar",
|
||||||
|
currency: "AUD",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Brazilian real 2",
|
||||||
|
currency: "BRL",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Canadian dollar",
|
||||||
|
currency: "CAD",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Chinese Renmenbi 3",
|
||||||
|
currency: "CNY",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Czech koruna",
|
||||||
|
currency: "CZK",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Danish krone",
|
||||||
|
currency: "DKK",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Euro",
|
||||||
|
currency: "EUR",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Hong Kong dollar",
|
||||||
|
currency: "HKD",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Hungarian forint 1",
|
||||||
|
currency: "HUF",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Israeli new shekel",
|
||||||
|
currency: "ILS",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Japanese yen 1",
|
||||||
|
currency: "JPY",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Malaysian ringgit 3",
|
||||||
|
currency: "MYR",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Mexican peso",
|
||||||
|
currency: "MXN",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "New Taiwan dollar 1",
|
||||||
|
currency: "TWD",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "New Zealand dollar",
|
||||||
|
currency: "NZD",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Norwegian krone",
|
||||||
|
currency: "NOK",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Philippine peso",
|
||||||
|
currency: "PHP",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Polish złoty",
|
||||||
|
currency: "PLN",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Pound sterling",
|
||||||
|
currency: "GBP",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Singapore dollar",
|
||||||
|
currency: "SGD",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Swedish krona",
|
||||||
|
currency: "SEK",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Swiss franc",
|
||||||
|
currency: "CHF",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Thai baht",
|
||||||
|
currency: "THB",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "United States dollar",
|
||||||
|
currency: "USD",
|
||||||
|
},
|
||||||
|
];
|
||||||
10
src/resources/user.ts
Normal file
10
src/resources/user.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import {Type} from "@/interfaces/user";
|
||||||
|
|
||||||
|
export const USER_TYPE_LABELS: {[key in Type]: string} = {
|
||||||
|
student: "Student",
|
||||||
|
teacher: "Teacher",
|
||||||
|
corporate: "Corporate",
|
||||||
|
agent: "Country Manager",
|
||||||
|
admin: "Admin",
|
||||||
|
developer: "Developer",
|
||||||
|
};
|
||||||
34
src/stores/listFilterStore.ts
Normal file
34
src/stores/listFilterStore.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import {Group, User} from "@/interfaces/user";
|
||||||
|
import {create} from "zustand";
|
||||||
|
|
||||||
|
export type Filter<T> = {id: string; filter: (x: T) => boolean};
|
||||||
|
|
||||||
|
export interface ListFilterState {
|
||||||
|
userFilters: Filter<User>[];
|
||||||
|
groupFilters: Filter<Group>[];
|
||||||
|
appendUserFilter: (filter: Filter<User>) => void;
|
||||||
|
removeUserFilter: (id: string) => void;
|
||||||
|
clearUserFilters: () => void;
|
||||||
|
appendGroupFilter: (filter: Filter<Group>) => void;
|
||||||
|
removeGroupFilter: (id: string) => void;
|
||||||
|
clearGroupFilters: () => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initialState = {
|
||||||
|
userFilters: [],
|
||||||
|
groupFilters: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const useFilterStore = create<ListFilterState>((set) => ({
|
||||||
|
...initialState,
|
||||||
|
appendUserFilter: (filter: Filter<User>) => set((state) => ({userFilters: [...state.userFilters.filter((f) => f.id !== filter.id), filter]})),
|
||||||
|
appendGroupFilter: (filter: Filter<Group>) => set((state) => ({groupFilters: [...state.groupFilters.filter((f) => f.id !== filter.id), filter]})),
|
||||||
|
removeUserFilter: (id: string) => set((state) => ({userFilters: state.userFilters.filter((x) => x.id !== id)})),
|
||||||
|
removeGroupFilter: (id: string) => set((state) => ({groupFilters: state.groupFilters.filter((x) => x.id !== id)})),
|
||||||
|
clearUserFilters: () => set({userFilters: []}),
|
||||||
|
clearGroupFilters: () => set({groupFilters: []}),
|
||||||
|
reset: () => set(() => initialState),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useFilterStore;
|
||||||
@@ -8,3 +8,7 @@ export function dateSorter(a: any, b: any, direction: "asc" | "desc", key: strin
|
|||||||
if (moment(b[key]).isAfter(a[key])) return direction === "asc" ? 1 : -1;
|
if (moment(b[key]).isAfter(a[key])) return direction === "asc" ? 1 : -1;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function env(key: string) {
|
||||||
|
return (window as any).__ENV[key];
|
||||||
|
}
|
||||||
|
|||||||
25
src/utils/paypal.ts
Normal file
25
src/utils/paypal.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import {TokenError, TokenSuccess} from "@/interfaces/paypal";
|
||||||
|
import {base64} from "@firebase/util";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export const getAccessToken = async () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append("grant_type", "client_credentials");
|
||||||
|
|
||||||
|
const auth = base64.encodeString(`${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_CLIENT_SECRET}`);
|
||||||
|
|
||||||
|
const request = await axios
|
||||||
|
.post<TokenSuccess>(`${process.env.PAYPAL_ACCESS_TOKEN_URL}/v1/oauth2/token`, params, {
|
||||||
|
headers: {Authorization: `Basic ${auth}`},
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.log(e);
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return request.data.access_token;
|
||||||
|
};
|
||||||
10
src/utils/sound.ts
Normal file
10
src/utils/sound.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import {Howl, Howler} from "howler";
|
||||||
|
|
||||||
|
export type Sound = "check" | "sent";
|
||||||
|
export const playSound = (path: Sound) => {
|
||||||
|
const sound = new Howl({
|
||||||
|
src: [`audio/${path}.mp3`],
|
||||||
|
});
|
||||||
|
|
||||||
|
sound.play();
|
||||||
|
};
|
||||||
124
yarn.lock
124
yarn.lock
@@ -64,6 +64,16 @@
|
|||||||
"@babel/helper-validator-identifier" "^7.22.20"
|
"@babel/helper-validator-identifier" "^7.22.20"
|
||||||
to-fast-properties "^2.0.0"
|
to-fast-properties "^2.0.0"
|
||||||
|
|
||||||
|
"@beam-australia/react-env@^3.1.1":
|
||||||
|
version "3.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@beam-australia/react-env/-/react-env-3.1.1.tgz#63cb8316861b8fbdb4b9c550a62139cd90675e40"
|
||||||
|
integrity sha512-LdWzgqmu116t9+sOvONyB21bBmI8dm8g8s3KhnJVzCcK93GrdSisuIOtOkQPMYgenmVGTWQwWnbLAgoka/jAFw==
|
||||||
|
dependencies:
|
||||||
|
cross-spawn "^6.0.5"
|
||||||
|
dotenv "^8.0.0"
|
||||||
|
dotenv-expand "^5.1.0"
|
||||||
|
minimist "^1.2.0"
|
||||||
|
|
||||||
"@emotion/babel-plugin@^11.11.0":
|
"@emotion/babel-plugin@^11.11.0":
|
||||||
version "11.11.0"
|
version "11.11.0"
|
||||||
resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz#c2d872b6a7767a9d176d007f5b31f7d504bb5d6c"
|
resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz#c2d872b6a7767a9d176d007f5b31f7d504bb5d6c"
|
||||||
@@ -885,6 +895,28 @@
|
|||||||
"@nodelib/fs.scandir" "2.1.5"
|
"@nodelib/fs.scandir" "2.1.5"
|
||||||
fastq "^1.6.0"
|
fastq "^1.6.0"
|
||||||
|
|
||||||
|
"@paypal/paypal-js@^7.0.0", "@paypal/paypal-js@^7.1.0":
|
||||||
|
version "7.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@paypal/paypal-js/-/paypal-js-7.1.0.tgz#93effa46dcc9358648ae5d9726550fbc32995461"
|
||||||
|
integrity sha512-Bp656jfxZF2Q1bz0v+YvA1nO5Tcyb6HmB2Yud+zx4ks5hWi1lLSNPLSaYcwuVJiqrxNs/GWKUOSyVetpzBEbrg==
|
||||||
|
dependencies:
|
||||||
|
promise-polyfill "^8.3.0"
|
||||||
|
|
||||||
|
"@paypal/react-paypal-js@^8.1.3":
|
||||||
|
version "8.1.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@paypal/react-paypal-js/-/react-paypal-js-8.1.3.tgz#2a46bd864eee02efada370ca50fab5a5bf38f4ff"
|
||||||
|
integrity sha512-hEm27iYP/UHS3XPBhDdiK2U4PH1FxrOD5O3f9tstAVLJd82l/laCjq751HiESSm63PVOoFeKE41Fe1mYGab+oA==
|
||||||
|
dependencies:
|
||||||
|
"@paypal/paypal-js" "^7.0.0"
|
||||||
|
"@paypal/sdk-constants" "^1.0.122"
|
||||||
|
|
||||||
|
"@paypal/sdk-constants@^1.0.122":
|
||||||
|
version "1.0.133"
|
||||||
|
resolved "https://registry.yarnpkg.com/@paypal/sdk-constants/-/sdk-constants-1.0.133.tgz#ee65c0bb574554becc8a5d3d0a834a944bbeb0e7"
|
||||||
|
integrity sha512-NWV0IWrHwQQrNLaUYxQ1GsytvHbDu+x63kRpNJfw1OQeDcUca4B0I4LoBktWQl5gICi090hD56n2Wg08dAl44g==
|
||||||
|
dependencies:
|
||||||
|
hi-base32 "^0.5.0"
|
||||||
|
|
||||||
"@peculiar/asn1-schema@^2.3.6":
|
"@peculiar/asn1-schema@^2.3.6":
|
||||||
version "2.3.6"
|
version "2.3.6"
|
||||||
resolved "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.6.tgz"
|
resolved "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.6.tgz"
|
||||||
@@ -1112,6 +1144,11 @@
|
|||||||
"@types/minimatch" "^5.1.2"
|
"@types/minimatch" "^5.1.2"
|
||||||
"@types/node" "*"
|
"@types/node" "*"
|
||||||
|
|
||||||
|
"@types/howler@^2.2.11":
|
||||||
|
version "2.2.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/howler/-/howler-2.2.11.tgz#a75c4ab5666aee5fcfbd5de15d35dbaaa3d3f070"
|
||||||
|
integrity sha512-7aBoUL6RbSIrqKnpEgfa1wSNUBK06mn08siP2QI0zYk7MXfEJAaORc4tohamQYqCqVESoDyRWSdQn2BOKWj2Qw==
|
||||||
|
|
||||||
"@types/http-assert@*":
|
"@types/http-assert@*":
|
||||||
version "1.5.3"
|
version "1.5.3"
|
||||||
resolved "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.3.tgz"
|
resolved "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.3.tgz"
|
||||||
@@ -1900,6 +1937,17 @@ country-flag-icons@^1.5.4:
|
|||||||
resolved "https://registry.yarnpkg.com/country-flag-icons/-/country-flag-icons-1.5.7.tgz#f1f2ddf14f3cbf01cba6746374aeba94db35d4b4"
|
resolved "https://registry.yarnpkg.com/country-flag-icons/-/country-flag-icons-1.5.7.tgz#f1f2ddf14f3cbf01cba6746374aeba94db35d4b4"
|
||||||
integrity sha512-AdvXhMcmSp7nBSkpGfW4qR/luAdRUutJqya9PuwRbsBzuoknThfultbv7Ib6fWsHXC43Es/4QJ8gzQQdBNm75A==
|
integrity sha512-AdvXhMcmSp7nBSkpGfW4qR/luAdRUutJqya9PuwRbsBzuoknThfultbv7Ib6fWsHXC43Es/4QJ8gzQQdBNm75A==
|
||||||
|
|
||||||
|
cross-spawn@^6.0.5:
|
||||||
|
version "6.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
|
||||||
|
integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==
|
||||||
|
dependencies:
|
||||||
|
nice-try "^1.0.4"
|
||||||
|
path-key "^2.0.1"
|
||||||
|
semver "^5.5.0"
|
||||||
|
shebang-command "^1.2.0"
|
||||||
|
which "^1.2.9"
|
||||||
|
|
||||||
cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3:
|
cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3:
|
||||||
version "7.0.3"
|
version "7.0.3"
|
||||||
resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz"
|
resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz"
|
||||||
@@ -1927,6 +1975,11 @@ csstype@^3.0.2:
|
|||||||
resolved "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz"
|
resolved "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz"
|
||||||
integrity sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==
|
integrity sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==
|
||||||
|
|
||||||
|
currency-symbol-map@^5.1.0:
|
||||||
|
version "5.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/currency-symbol-map/-/currency-symbol-map-5.1.0.tgz#59531fbe977ba95e8d358e90e3c9e9053efb75ad"
|
||||||
|
integrity sha512-LO/lzYRw134LMDVnLyAf1dHE5tyO6axEFkR3TXjQIOmMkAM9YL6QsiUwuXzZAmFnuDJcs4hayOgyIYtViXFrLw==
|
||||||
|
|
||||||
daisyui@^3.1.5:
|
daisyui@^3.1.5:
|
||||||
version "3.5.1"
|
version "3.5.1"
|
||||||
resolved "https://registry.npmjs.org/daisyui/-/daisyui-3.5.1.tgz"
|
resolved "https://registry.npmjs.org/daisyui/-/daisyui-3.5.1.tgz"
|
||||||
@@ -2072,6 +2125,16 @@ dom-helpers@^5.0.1:
|
|||||||
"@babel/runtime" "^7.8.7"
|
"@babel/runtime" "^7.8.7"
|
||||||
csstype "^3.0.2"
|
csstype "^3.0.2"
|
||||||
|
|
||||||
|
dotenv-expand@^5.1.0:
|
||||||
|
version "5.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-5.1.0.tgz#3fbaf020bfd794884072ea26b1e9791d45a629f0"
|
||||||
|
integrity sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==
|
||||||
|
|
||||||
|
dotenv@^8.0.0:
|
||||||
|
version "8.6.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.6.0.tgz#061af664d19f7f4d8fc6e4ff9b584ce237adcb8b"
|
||||||
|
integrity sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==
|
||||||
|
|
||||||
duplexify@^4.0.0:
|
duplexify@^4.0.0:
|
||||||
version "4.1.2"
|
version "4.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-4.1.2.tgz#18b4f8d28289132fa0b9573c898d9f903f81c7b0"
|
resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-4.1.2.tgz#18b4f8d28289132fa0b9573c898d9f903f81c7b0"
|
||||||
@@ -3079,6 +3142,11 @@ hexoid@^1.0.0:
|
|||||||
resolved "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz"
|
resolved "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz"
|
||||||
integrity sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==
|
integrity sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==
|
||||||
|
|
||||||
|
hi-base32@^0.5.0:
|
||||||
|
version "0.5.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/hi-base32/-/hi-base32-0.5.1.tgz#1279f2ddae2673219ea5870c2121d2a33132857e"
|
||||||
|
integrity sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA==
|
||||||
|
|
||||||
hoist-non-react-statics@^3.3.1:
|
hoist-non-react-statics@^3.3.1:
|
||||||
version "3.3.2"
|
version "3.3.2"
|
||||||
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
|
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
|
||||||
@@ -3086,6 +3154,11 @@ hoist-non-react-statics@^3.3.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react-is "^16.7.0"
|
react-is "^16.7.0"
|
||||||
|
|
||||||
|
howler@^2.2.4:
|
||||||
|
version "2.2.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/howler/-/howler-2.2.4.tgz#bd3df4a4f68a0118a51e4bd84a2bfc2e93e6e5a1"
|
||||||
|
integrity sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==
|
||||||
|
|
||||||
http-parser-js@>=0.5.1:
|
http-parser-js@>=0.5.1:
|
||||||
version "0.5.8"
|
version "0.5.8"
|
||||||
resolved "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz"
|
resolved "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz"
|
||||||
@@ -3931,6 +4004,11 @@ next@13.1.6:
|
|||||||
"@next/swc-win32-ia32-msvc" "13.1.6"
|
"@next/swc-win32-ia32-msvc" "13.1.6"
|
||||||
"@next/swc-win32-x64-msvc" "13.1.6"
|
"@next/swc-win32-x64-msvc" "13.1.6"
|
||||||
|
|
||||||
|
nice-try@^1.0.4:
|
||||||
|
version "1.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
|
||||||
|
integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
|
||||||
|
|
||||||
node-addon-api@^5.0.0:
|
node-addon-api@^5.0.0:
|
||||||
version "5.1.0"
|
version "5.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-5.1.0.tgz#49da1ca055e109a23d537e9de43c09cca21eb762"
|
resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-5.1.0.tgz#49da1ca055e109a23d537e9de43c09cca21eb762"
|
||||||
@@ -4151,6 +4229,11 @@ path-is-absolute@^1.0.0:
|
|||||||
resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz"
|
resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz"
|
||||||
integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==
|
integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==
|
||||||
|
|
||||||
|
path-key@^2.0.1:
|
||||||
|
version "2.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40"
|
||||||
|
integrity sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==
|
||||||
|
|
||||||
path-key@^3.1.0:
|
path-key@^3.1.0:
|
||||||
version "3.1.1"
|
version "3.1.1"
|
||||||
resolved "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz"
|
resolved "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz"
|
||||||
@@ -4279,6 +4362,11 @@ primereact@^9.2.3:
|
|||||||
"@types/react-transition-group" "^4.4.1"
|
"@types/react-transition-group" "^4.4.1"
|
||||||
react-transition-group "^4.4.1"
|
react-transition-group "^4.4.1"
|
||||||
|
|
||||||
|
promise-polyfill@^8.3.0:
|
||||||
|
version "8.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/promise-polyfill/-/promise-polyfill-8.3.0.tgz#9284810268138d103807b11f4e23d5e945a4db63"
|
||||||
|
integrity sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==
|
||||||
|
|
||||||
prop-types@15.7.2:
|
prop-types@15.7.2:
|
||||||
version "15.7.2"
|
version "15.7.2"
|
||||||
resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz"
|
resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz"
|
||||||
@@ -4431,6 +4519,11 @@ react-chartjs-2@^5.2.0:
|
|||||||
resolved "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz"
|
resolved "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz"
|
||||||
integrity sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==
|
integrity sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==
|
||||||
|
|
||||||
|
react-currency-input-field@^3.6.12:
|
||||||
|
version "3.6.12"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-currency-input-field/-/react-currency-input-field-3.6.12.tgz#6c59bec50b9a769459c971f94f9a67b7bf9046f7"
|
||||||
|
integrity sha512-92mVEo1u7tF8Lz5JeaEHpQY/p6ulmnfSk9r3dVMyykQNLoScvgQ7GczvV3uGDr81xkTF3czj7CTJ9Ekqq4+pIA==
|
||||||
|
|
||||||
react-datepicker@^4.18.0:
|
react-datepicker@^4.18.0:
|
||||||
version "4.18.0"
|
version "4.18.0"
|
||||||
resolved "https://registry.yarnpkg.com/react-datepicker/-/react-datepicker-4.18.0.tgz#d66301acc47833d31fa6f46f98781b084106da0e"
|
resolved "https://registry.yarnpkg.com/react-datepicker/-/react-datepicker-4.18.0.tgz#d66301acc47833d31fa6f46f98781b084106da0e"
|
||||||
@@ -4729,6 +4822,11 @@ seedrandom@^3.0.5:
|
|||||||
resolved "https://registry.yarnpkg.com/seedrandom/-/seedrandom-3.0.5.tgz#54edc85c95222525b0c7a6f6b3543d8e0b3aa0a7"
|
resolved "https://registry.yarnpkg.com/seedrandom/-/seedrandom-3.0.5.tgz#54edc85c95222525b0c7a6f6b3543d8e0b3aa0a7"
|
||||||
integrity sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==
|
integrity sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==
|
||||||
|
|
||||||
|
semver@^5.5.0:
|
||||||
|
version "5.7.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8"
|
||||||
|
integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==
|
||||||
|
|
||||||
semver@^6.0.0:
|
semver@^6.0.0:
|
||||||
version "6.3.1"
|
version "6.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
|
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
|
||||||
@@ -4758,6 +4856,13 @@ set-blocking@^2.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
|
resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
|
||||||
integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==
|
integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==
|
||||||
|
|
||||||
|
shebang-command@^1.2.0:
|
||||||
|
version "1.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"
|
||||||
|
integrity sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==
|
||||||
|
dependencies:
|
||||||
|
shebang-regex "^1.0.0"
|
||||||
|
|
||||||
shebang-command@^2.0.0:
|
shebang-command@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz"
|
resolved "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz"
|
||||||
@@ -4765,6 +4870,11 @@ shebang-command@^2.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
shebang-regex "^3.0.0"
|
shebang-regex "^3.0.0"
|
||||||
|
|
||||||
|
shebang-regex@^1.0.0:
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3"
|
||||||
|
integrity sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==
|
||||||
|
|
||||||
shebang-regex@^3.0.0:
|
shebang-regex@^3.0.0:
|
||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
resolved "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz"
|
resolved "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz"
|
||||||
@@ -5196,6 +5306,13 @@ typed-array-length@^1.0.4:
|
|||||||
for-each "^0.3.3"
|
for-each "^0.3.3"
|
||||||
is-typed-array "^1.1.9"
|
is-typed-array "^1.1.9"
|
||||||
|
|
||||||
|
types/@paypal/react-paypal-js:
|
||||||
|
version "8.1.3"
|
||||||
|
resolved "https://codeload.github.com/paypal/react-paypal-js/tar.gz/21bf270c7ce356616a9184dae7042f7ab3473e25"
|
||||||
|
dependencies:
|
||||||
|
"@paypal/paypal-js" "^7.0.0"
|
||||||
|
"@paypal/sdk-constants" "^1.0.122"
|
||||||
|
|
||||||
typescript@4.9.5:
|
typescript@4.9.5:
|
||||||
version "4.9.5"
|
version "4.9.5"
|
||||||
resolved "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz"
|
resolved "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz"
|
||||||
@@ -5361,6 +5478,13 @@ which-typed-array@^1.1.9:
|
|||||||
has-tostringtag "^1.0.0"
|
has-tostringtag "^1.0.0"
|
||||||
is-typed-array "^1.1.10"
|
is-typed-array "^1.1.10"
|
||||||
|
|
||||||
|
which@^1.2.9:
|
||||||
|
version "1.3.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
|
||||||
|
integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==
|
||||||
|
dependencies:
|
||||||
|
isexe "^2.0.0"
|
||||||
|
|
||||||
which@^2.0.1:
|
which@^2.0.1:
|
||||||
version "2.0.2"
|
version "2.0.2"
|
||||||
resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz"
|
resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz"
|
||||||
|
|||||||
Reference in New Issue
Block a user