Compare commits
66 Commits
feature/sp
...
feature-pa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a27fbd02f | ||
|
|
a86ed9f76c | ||
|
|
20b52d049d | ||
|
|
165e33b188 | ||
|
|
04cbcbc4cb | ||
|
|
2feb9223c1 | ||
|
|
02d2d07f6c | ||
|
|
ecd66d61f2 | ||
|
|
424b72efaf | ||
|
|
79e51d6294 | ||
|
|
773480875f | ||
|
|
96d1b85f56 | ||
|
|
cf20920fd8 | ||
|
|
4114971244 | ||
|
|
bee20388d9 | ||
|
|
bd97529658 | ||
|
|
d3c24d738c | ||
|
|
eac43a160d | ||
|
|
24c3f506c6 | ||
|
|
3e13ed5830 | ||
|
|
9b5ff70037 | ||
|
|
d7f1a4f6b2 | ||
|
|
b663e5c706 | ||
|
|
efb99b31f2 | ||
|
|
03882d2a7e | ||
|
|
a3087567ea | ||
|
|
e37afd5bbc | ||
|
|
cf5e827ca7 | ||
|
|
bfa9d039e2 | ||
|
|
62b915fbc1 | ||
|
|
cdfafb3eea | ||
|
|
29cae5c3d2 | ||
|
|
04f97b62c3 | ||
|
|
52d309e7f4 | ||
|
|
dbf5b17f64 | ||
|
|
703fb0df5f | ||
|
|
be4d2de76f | ||
|
|
44c61c2e5d | ||
|
|
764064bc28 | ||
|
|
d87de9fea9 | ||
|
|
b63ba3f316 | ||
|
|
64b1d9266e | ||
|
|
b7cd1fb141 | ||
|
|
30cb2f460c | ||
|
|
6a38b7a32e | ||
|
|
2a1b5236ee | ||
|
|
a99f6fd20e | ||
|
|
c0c9d22864 | ||
|
|
718782cfd5 | ||
|
|
f643430068 | ||
|
|
2823af7ef8 | ||
|
|
57116f50e8 | ||
|
|
e382a09ae8 | ||
|
|
b4c7c9a911 | ||
|
|
86e920f102 | ||
|
|
6f12a4a1db | ||
|
|
a27a3c1fb0 | ||
|
|
63618405bc | ||
|
|
7ab67fdf15 | ||
|
|
17ec004a59 | ||
|
|
417bd7fecb | ||
|
|
e82895351d | ||
|
|
4802310474 | ||
|
|
dc3373be6a | ||
|
|
2e894622d0 | ||
|
|
1895b9e183 |
@@ -1,4 +1,5 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
|
const websiteUrl = process.env.NODE_ENV === 'production' ? "https://encoach.com" : "http://localhost:3000";
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
@@ -8,7 +9,7 @@ const nextConfig = {
|
|||||||
source: "/api/packages",
|
source: "/api/packages",
|
||||||
headers: [
|
headers: [
|
||||||
{key: "Access-Control-Allow-Credentials", value: "false"},
|
{key: "Access-Control-Allow-Credentials", value: "false"},
|
||||||
{key: "Access-Control-Allow-Origin", value: "https://encoach.com"},
|
{key: "Access-Control-Allow-Origin", value: websiteUrl},
|
||||||
{
|
{
|
||||||
key: "Access-Control-Allow-Methods",
|
key: "Access-Control-Allow-Methods",
|
||||||
value: "GET",
|
value: "GET",
|
||||||
@@ -19,6 +20,36 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source: "/api/tickets",
|
||||||
|
headers: [
|
||||||
|
{key: "Access-Control-Allow-Credentials", value: "false"},
|
||||||
|
{key: "Access-Control-Allow-Origin", value: websiteUrl},
|
||||||
|
{
|
||||||
|
key: "Access-Control-Allow-Methods",
|
||||||
|
value: "POST,OPTIONS",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Access-Control-Allow-Headers",
|
||||||
|
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "/api/users/agents",
|
||||||
|
headers: [
|
||||||
|
{key: "Access-Control-Allow-Credentials", value: "false"},
|
||||||
|
{key: "Access-Control-Allow-Origin", value: websiteUrl},
|
||||||
|
{
|
||||||
|
key: "Access-Control-Allow-Methods",
|
||||||
|
value: "POST,OPTIONS",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Access-Control-Allow-Headers",
|
||||||
|
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export default function InteractiveSpeaking({
|
|||||||
onBack({
|
onBack({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||||
score: {correct: 1, total: 1, missing: 0},
|
score: {correct: 100, total: 100, missing: 0},
|
||||||
type,
|
type,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -96,7 +96,7 @@ export default function InteractiveSpeaking({
|
|||||||
onNext({
|
onNext({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||||
score: {correct: 1, total: 1, missing: 0},
|
score: {correct: 100, total: 100, missing: 0},
|
||||||
type,
|
type,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -131,7 +131,7 @@ export default function InteractiveSpeaking({
|
|||||||
onNext({
|
onNext({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||||
score: {correct: 1, total: 1, missing: 0},
|
score: {correct: 100, total: 100, missing: 0},
|
||||||
type,
|
type,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -176,7 +176,7 @@ export default function InteractiveSpeaking({
|
|||||||
{
|
{
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||||
score: {correct: 1, total: 1, missing: 0},
|
score: {correct: 100, total: 100, missing: 0},
|
||||||
module: "speaking",
|
module: "speaking",
|
||||||
exam: examID,
|
exam: examID,
|
||||||
type,
|
type,
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
|
|||||||
onNext({
|
onNext({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: storagePath ? [{id, solution: storagePath}] : [],
|
solutions: storagePath ? [{id, solution: storagePath}] : [],
|
||||||
score: {correct: 1, total: 1, missing: 0},
|
score: {correct: 100, total: 100, missing: 0},
|
||||||
type,
|
type,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -94,7 +94,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
|
|||||||
onBack({
|
onBack({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: storagePath ? [{id, solution: storagePath}] : [],
|
solutions: storagePath ? [{id, solution: storagePath}] : [],
|
||||||
score: {correct: 1, total: 1, missing: 0},
|
score: {correct: 100, total: 100, missing: 0},
|
||||||
type,
|
type,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default function Writing({
|
|||||||
if (inputText.length > 0 && saveTimer % 10 === 0) {
|
if (inputText.length > 0 && saveTimer % 10 === 0) {
|
||||||
setUserSolutions([
|
setUserSolutions([
|
||||||
...storeUserSolutions.filter((x) => x.exercise !== id),
|
...storeUserSolutions.filter((x) => x.exercise !== id),
|
||||||
{exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type},
|
{exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type, module: "writing"},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -64,7 +64,8 @@ export default function Writing({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded) onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type});
|
if (hasExamEnded)
|
||||||
|
onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type, module: "writing"});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [hasExamEnded]);
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
@@ -147,7 +148,9 @@ export default function Writing({
|
|||||||
<Button
|
<Button
|
||||||
color="purple"
|
color="purple"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onBack({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type})}
|
onClick={() =>
|
||||||
|
onBack({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type})
|
||||||
|
}
|
||||||
className="max-w-[200px] self-end w-full">
|
className="max-w-[200px] self-end w-full">
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
@@ -158,8 +161,9 @@ export default function Writing({
|
|||||||
onNext({
|
onNext({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: [{id, solution: inputText.replaceAll(/\s{2,}/g, " ")}],
|
solutions: [{id, solution: inputText.replaceAll(/\s{2,}/g, " ")}],
|
||||||
score: {correct: 1, total: 1, missing: 0},
|
score: {correct: 100, total: 100, missing: 0},
|
||||||
type,
|
type,
|
||||||
|
module: "writing",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="max-w-[200px] self-end w-full">
|
className="max-w-[200px] self-end w-full">
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export default function Layout({user, children, className, navDisabled = false,
|
|||||||
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
|
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
|
||||||
className="-md:hidden"
|
className="-md:hidden"
|
||||||
userType={user.type}
|
userType={user.type}
|
||||||
|
userId={user.id}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
|||||||
@@ -21,7 +21,11 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
|
|||||||
const [timer, setTimer] = useState(minTimer * 60);
|
const [timer, setTimer] = useState(minTimer * 60);
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [warningMode, setWarningMode] = useState(false);
|
const [warningMode, setWarningMode] = useState(false);
|
||||||
|
|
||||||
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
|
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
|
||||||
|
const {timeSpent} = useExamStore((state) => state);
|
||||||
|
|
||||||
|
useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!disableTimer) {
|
if (!disableTimer) {
|
||||||
|
|||||||
70
src/components/Medium/TopicModal.tsx
Normal file
70
src/components/Medium/TopicModal.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import topics from "@/resources/topics";
|
||||||
|
import {useState} from "react";
|
||||||
|
import {BsArrowLeft, BsArrowRight} from "react-icons/bs";
|
||||||
|
import Button from "../Low/Button";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
initialTopics: string[];
|
||||||
|
onClose: VoidFunction;
|
||||||
|
selectTopics: (topics: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TopicModal({isOpen, initialTopics, onClose, selectTopics}: Props) {
|
||||||
|
const [selectedTopics, setSelectedTopics] = useState([...initialTopics]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title="Preferred Topics">
|
||||||
|
<div className="flex flex-col w-full h-full gap-4 mt-4">
|
||||||
|
<div className="w-full h-full grid grid-cols-2 -md:gap-1 gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="border-b border-b-neutral-400/30">Available Topics</span>
|
||||||
|
<div className=" max-h-[500px] overflow-y-scroll scrollbar-hide">
|
||||||
|
{topics
|
||||||
|
.filter((x) => !selectedTopics.includes(x))
|
||||||
|
.map((x) => (
|
||||||
|
<div key={x} className="odd:bg-mti-purple-ultralight/40 p-2 flex justify-between items-center">
|
||||||
|
<span>{x}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedTopics((prev) => [...prev, x])}
|
||||||
|
className="border border-mti-purple-light cursor-pointer p-2 rounded-lg bg-white drop-shadow transition ease-in-out duration-300 hover:bg-mti-purple hover:text-white">
|
||||||
|
<BsArrowRight />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="border-b border-b-neutral-400/30">Preferred Topics ({selectedTopics.length || "All"})</span>
|
||||||
|
<div className=" max-h-[500px] overflow-y-scroll scrollbar-hide">
|
||||||
|
{selectedTopics.map((x) => (
|
||||||
|
<div key={x} className="odd:bg-mti-purple-ultralight/40 p-2 flex justify-between items-center text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedTopics((prev) => [...prev.filter((y) => y !== x)])}
|
||||||
|
className="border border-mti-purple-light cursor-pointer p-2 rounded-lg bg-white drop-shadow transition ease-in-out duration-300 hover:bg-mti-purple hover:text-white">
|
||||||
|
<BsArrowLeft />
|
||||||
|
</button>
|
||||||
|
<span>{x}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex gap-4 items-center justify-end">
|
||||||
|
<Button variant="outline" color="rose" className="w-full max-w-[200px]" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
onClick={() => {
|
||||||
|
selectTopics(selectedTopics);
|
||||||
|
onClose();
|
||||||
|
}}>
|
||||||
|
Select
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,21 +1,22 @@
|
|||||||
import { User } from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import { Dialog, Transition } from "@headlessui/react";
|
import {Dialog, Transition} from "@headlessui/react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import { Fragment } from "react";
|
import {Fragment} from "react";
|
||||||
import { BsXLg } from "react-icons/bs";
|
import {BsXLg} from "react-icons/bs";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
path: string;
|
path: string;
|
||||||
user: User;
|
user: User;
|
||||||
|
disableNavigation?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MobileMenu({ isOpen, onClose, path, user }: Props) {
|
export default function MobileMenu({isOpen, onClose, path, user, disableNavigation}: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
@@ -34,8 +35,7 @@ export default function MobileMenu({ isOpen, onClose, path, user }: Props) {
|
|||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="ease-in duration-200"
|
leave="ease-in duration-200"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0">
|
||||||
>
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
|
|
||||||
@@ -48,146 +48,105 @@ export default function MobileMenu({ isOpen, onClose, path, user }: Props) {
|
|||||||
enterTo="opacity-100 scale-100"
|
enterTo="opacity-100 scale-100"
|
||||||
leave="ease-in duration-200"
|
leave="ease-in duration-200"
|
||||||
leaveFrom="opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="opacity-0 scale-95"
|
leaveTo="opacity-0 scale-95">
|
||||||
>
|
|
||||||
<Dialog.Panel className="flex h-screen w-full transform flex-col gap-8 overflow-hidden bg-white text-left align-middle text-black shadow-xl transition-all">
|
<Dialog.Panel className="flex h-screen w-full transform flex-col gap-8 overflow-hidden bg-white text-left align-middle text-black shadow-xl transition-all">
|
||||||
<Dialog.Title
|
<Dialog.Title as="header" className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden">
|
||||||
as="header"
|
<Link href={disableNavigation ? "" : "/"}>
|
||||||
className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden"
|
<Image src="/logo_title.png" alt="EnCoach logo" width={69} height={69} />
|
||||||
>
|
|
||||||
<Link href="/">
|
|
||||||
<Image
|
|
||||||
src="/logo_title.png"
|
|
||||||
alt="EnCoach logo"
|
|
||||||
width={69}
|
|
||||||
height={69}
|
|
||||||
/>
|
|
||||||
</Link>
|
</Link>
|
||||||
<div
|
<div className="cursor-pointer" onClick={onClose} tabIndex={0}>
|
||||||
className="cursor-pointer"
|
<BsXLg className="text-mti-purple-light text-2xl" onClick={onClose} />
|
||||||
onClick={onClose}
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
<BsXLg
|
|
||||||
className="text-mti-purple-light text-2xl"
|
|
||||||
onClick={onClose}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<div className="flex h-full flex-col gap-6 px-8 text-lg">
|
<div className="flex h-full flex-col gap-6 px-8 text-lg">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href={disableNavigation ? "" : "/"}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-fit transition duration-300 ease-in-out",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/" &&
|
path === "/" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
)}>
|
||||||
)}
|
|
||||||
>
|
|
||||||
Dashboard
|
Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
{(user.type === "student" ||
|
{(user.type === "student" || user.type === "teacher" || user.type === "developer") && (
|
||||||
user.type === "teacher" ||
|
|
||||||
user.type === "developer") && (
|
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
href="/exam"
|
href={disableNavigation ? "" : "/exam"}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-fit transition duration-300 ease-in-out",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/exam" &&
|
path === "/exam" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
)}>
|
||||||
)}
|
|
||||||
>
|
|
||||||
Exams
|
Exams
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/exercises"
|
href={disableNavigation ? "" : "/exercises"}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-fit transition duration-300 ease-in-out",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/exercises" &&
|
path === "/exercises" &&
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
)}
|
)}>
|
||||||
>
|
|
||||||
Exercises
|
Exercises
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Link
|
<Link
|
||||||
href="/stats"
|
href={disableNavigation ? "" : "/stats"}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-fit transition duration-300 ease-in-out",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/stats" &&
|
path === "/stats" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
)}>
|
||||||
)}
|
|
||||||
>
|
|
||||||
Stats
|
Stats
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/record"
|
href={disableNavigation ? "" : "/record"}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-fit transition duration-300 ease-in-out",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/record" &&
|
path === "/record" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
)}>
|
||||||
)}
|
|
||||||
>
|
|
||||||
Record
|
Record
|
||||||
</Link>
|
</Link>
|
||||||
{["admin", "developer", "agent", "corporate"].includes(
|
{["admin", "developer", "agent", "corporate"].includes(user.type) && (
|
||||||
user.type,
|
|
||||||
) && (
|
|
||||||
<Link
|
<Link
|
||||||
href="/payment-record"
|
href={disableNavigation ? "" : "/payment-record"}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-fit transition duration-300 ease-in-out",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/payment-record" &&
|
path === "/payment-record" &&
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
)}
|
)}>
|
||||||
>
|
|
||||||
Payment Record
|
Payment Record
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{["admin", "developer", "corporate", "teacher"].includes(
|
{["admin", "developer", "corporate", "teacher"].includes(user.type) && (
|
||||||
user.type,
|
|
||||||
) && (
|
|
||||||
<Link
|
<Link
|
||||||
href="/settings"
|
href={disableNavigation ? "" : "/settings"}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-fit transition duration-300 ease-in-out",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/settings" &&
|
path === "/settings" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
)}>
|
||||||
)}
|
|
||||||
>
|
|
||||||
Settings
|
Settings
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{["admin", "developer", "agent"].includes(user.type) && (
|
{["admin", "developer", "agent"].includes(user.type) && (
|
||||||
<Link
|
<Link
|
||||||
href="/tickets"
|
href={disableNavigation ? "" : "/tickets"}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-fit transition duration-300 ease-in-out",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/tickets" &&
|
path === "/tickets" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
)}>
|
||||||
)}
|
|
||||||
>
|
|
||||||
Tickets
|
Tickets
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<Link
|
<Link
|
||||||
href="/profile"
|
href={disableNavigation ? "" : "/profile"}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-fit transition duration-300 ease-in-out",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
path === "/profile" &&
|
path === "/profile" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
)}>
|
||||||
)}
|
|
||||||
>
|
|
||||||
Profile
|
Profile
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
className={clsx(
|
className={clsx("w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out")}
|
||||||
"w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out",
|
onClick={logout}>
|
||||||
)}
|
|
||||||
onClick={logout}
|
|
||||||
>
|
|
||||||
Logout
|
Logout
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -62,7 +62,9 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
|
|||||||
<TicketSubmission user={user} page={window.location.href} onClose={() => setIsTicketOpen(false)} />
|
<TicketSubmission user={user} page={window.location.href} onClose={() => setIsTicketOpen(false)} />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{user && <MobileMenu path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />}
|
{user && (
|
||||||
|
<MobileMenu disableNavigation={disableNavigation} path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />
|
||||||
|
)}
|
||||||
<header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4">
|
<header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4">
|
||||||
<Link href={disableNavigation ? "" : "/"} className=" flex items-center gap-8 md:px-8">
|
<Link href={disableNavigation ? "" : "/"} className=" flex items-center gap-8 md:px-8">
|
||||||
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" />
|
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" />
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
import {DurationUnit} from "@/interfaces/paypal";
|
import { DurationUnit } from "@/interfaces/paypal";
|
||||||
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OnCancelledActions, OrderResponseBody} from "@paypal/paypal-js";
|
import {
|
||||||
import {PayPalButtons, PayPalScriptProvider, usePayPalScriptReducer} from "@paypal/react-paypal-js";
|
CreateOrderActions,
|
||||||
|
CreateOrderData,
|
||||||
|
OnApproveActions,
|
||||||
|
OnApproveData,
|
||||||
|
OnCancelledActions,
|
||||||
|
OrderResponseBody,
|
||||||
|
} from "@paypal/paypal-js";
|
||||||
|
import {
|
||||||
|
PayPalButtons,
|
||||||
|
PayPalScriptProvider,
|
||||||
|
usePayPalScriptReducer,
|
||||||
|
} from "@paypal/react-paypal-js";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {useEffect, useState} from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {toast} from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
clientID: string;
|
clientID: string;
|
||||||
@@ -14,20 +25,48 @@ interface Props {
|
|||||||
loadScript?: boolean;
|
loadScript?: boolean;
|
||||||
setIsLoading: (isLoading: boolean) => void;
|
setIsLoading: (isLoading: boolean) => void;
|
||||||
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
|
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
|
||||||
|
trackingId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PayPalPayment({clientID, price, currency, duration, duration_unit, loadScript, setIsLoading, onSuccess}: Props) {
|
export default function PayPalPayment({
|
||||||
const createOrder = async (data: CreateOrderData, actions: CreateOrderActions): Promise<string> => {
|
clientID,
|
||||||
|
price,
|
||||||
|
currency,
|
||||||
|
duration,
|
||||||
|
duration_unit,
|
||||||
|
loadScript,
|
||||||
|
setIsLoading,
|
||||||
|
onSuccess,
|
||||||
|
trackingId,
|
||||||
|
}: Props) {
|
||||||
|
const createOrder = async (
|
||||||
|
data: CreateOrderData,
|
||||||
|
actions: CreateOrderActions
|
||||||
|
): Promise<string> => {
|
||||||
|
if (!trackingId) {
|
||||||
|
throw new Error("trackingId is not set");
|
||||||
|
}
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
return axios
|
return axios
|
||||||
.post<OrderResponseBody>("/api/paypal", {currencyCode: currency, price})
|
.post<OrderResponseBody>("/api/paypal", {
|
||||||
|
currencyCode: currency,
|
||||||
|
price,
|
||||||
|
trackingId,
|
||||||
|
})
|
||||||
.then((response) => response.data)
|
.then((response) => response.data)
|
||||||
.then((data) => data.id);
|
.then((data) => data.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onApprove = async (data: OnApproveData, actions: OnApproveActions) => {
|
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 (!trackingId) {
|
||||||
|
throw new Error("trackingId is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = await axios.post<{ ok: boolean; reason?: string }>(
|
||||||
|
"/api/paypal/approve",
|
||||||
|
{ id: data.orderID, duration, duration_unit, trackingId }
|
||||||
|
);
|
||||||
|
|
||||||
if (request.status !== 200) {
|
if (request.status !== 200) {
|
||||||
toast.error("Something went wrong, please try again later");
|
toast.error("Something went wrong, please try again later");
|
||||||
@@ -42,10 +81,14 @@ export default function PayPalPayment({clientID, price, currency, duration, dura
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onCancel = async (data: Record<string, unknown>, actions: OnCancelledActions) => {
|
const onCancel = async (
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
actions: OnCancelledActions
|
||||||
|
) => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (trackingId) {
|
||||||
return loadScript ? (
|
return loadScript ? (
|
||||||
<PayPalScriptProvider
|
<PayPalScriptProvider
|
||||||
options={{
|
options={{
|
||||||
@@ -54,10 +97,11 @@ export default function PayPalPayment({clientID, price, currency, duration, dura
|
|||||||
intent: "capture",
|
intent: "capture",
|
||||||
commit: true,
|
commit: true,
|
||||||
vault: true,
|
vault: true,
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<PayPalButtons
|
<PayPalButtons
|
||||||
className="w-full"
|
className="w-full"
|
||||||
style={{layout: "vertical"}}
|
style={{ layout: "vertical" }}
|
||||||
createOrder={createOrder}
|
createOrder={createOrder}
|
||||||
onApprove={onApprove}
|
onApprove={onApprove}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
@@ -67,11 +111,14 @@ export default function PayPalPayment({clientID, price, currency, duration, dura
|
|||||||
) : (
|
) : (
|
||||||
<PayPalButtons
|
<PayPalButtons
|
||||||
className="w-full"
|
className="w-full"
|
||||||
style={{layout: "vertical"}}
|
style={{ layout: "vertical" }}
|
||||||
createOrder={createOrder}
|
createOrder={createOrder}
|
||||||
onApprove={onApprove}
|
onApprove={onApprove}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
onError={onError}
|
onError={onError}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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 {
|
import {
|
||||||
BsFileEarmarkText,
|
BsFileEarmarkText,
|
||||||
BsClockHistory,
|
BsClockHistory,
|
||||||
@@ -13,17 +13,18 @@ import {
|
|||||||
BsCurrencyDollar,
|
BsCurrencyDollar,
|
||||||
BsClipboardData,
|
BsClipboardData,
|
||||||
} from "react-icons/bs";
|
} 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";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import FocusLayer from "@/components/FocusLayer";
|
import FocusLayer from "@/components/FocusLayer";
|
||||||
import { preventNavigation } from "@/utils/navigation.disabled";
|
import {preventNavigation} from "@/utils/navigation.disabled";
|
||||||
import { useState } from "react";
|
import {useEffect, useState} from "react";
|
||||||
import usePreferencesStore from "@/stores/preferencesStore";
|
import usePreferencesStore from "@/stores/preferencesStore";
|
||||||
import { Type } from "@/interfaces/user";
|
import {Type} from "@/interfaces/user";
|
||||||
|
import useTicketsListener from "@/hooks/useTicketsListener";
|
||||||
interface Props {
|
interface Props {
|
||||||
path: string;
|
path: string;
|
||||||
navDisabled?: boolean;
|
navDisabled?: boolean;
|
||||||
@@ -31,6 +32,7 @@ interface Props {
|
|||||||
onFocusLayerMouseEnter?: () => void;
|
onFocusLayerMouseEnter?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
userType?: Type;
|
userType?: Type;
|
||||||
|
userId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavProps {
|
interface NavProps {
|
||||||
@@ -40,47 +42,44 @@ interface NavProps {
|
|||||||
keyPath: string;
|
keyPath: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isMinimized?: boolean;
|
isMinimized?: boolean;
|
||||||
|
badge?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Nav = ({
|
const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false, badge}: NavProps) => {
|
||||||
Icon,
|
return (
|
||||||
label,
|
|
||||||
path,
|
|
||||||
keyPath,
|
|
||||||
disabled = false,
|
|
||||||
isMinimized = false,
|
|
||||||
}: NavProps) => (
|
|
||||||
<Link
|
<Link
|
||||||
href={!disabled ? keyPath : ""}
|
href={!disabled ? keyPath : ""}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white",
|
"flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white",
|
||||||
"transition-all duration-300 ease-in-out",
|
"transition-all duration-300 ease-in-out relative",
|
||||||
disabled
|
disabled ? "hover:bg-mti-gray-dim cursor-not-allowed" : "hover:bg-mti-purple-light cursor-pointer",
|
||||||
? "hover:bg-mti-gray-dim cursor-not-allowed"
|
|
||||||
: "hover:bg-mti-purple-light cursor-pointer",
|
|
||||||
path === keyPath && "bg-mti-purple-light text-white",
|
path === keyPath && "bg-mti-purple-light text-white",
|
||||||
isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]",
|
isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]",
|
||||||
)}
|
)}>
|
||||||
>
|
|
||||||
<Icon size={24} />
|
<Icon size={24} />
|
||||||
{!isMinimized && <span className="text-lg font-semibold">{label}</span>}
|
{!isMinimized && <span className="text-lg font-semibold">{label}</span>}
|
||||||
|
{!!badge && badge > 0 && (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-light h-5 w-5 text-xs rounded-full flex items-center justify-center text-white",
|
||||||
|
"transition ease-in-out duration-300",
|
||||||
|
isMinimized && "absolute right-0 top-0",
|
||||||
|
)}>
|
||||||
|
{badge}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default function Sidebar({
|
export default function Sidebar({path, navDisabled = false, focusMode = false, userType, onFocusLayerMouseEnter, className, userId}: Props) {
|
||||||
path,
|
|
||||||
navDisabled = false,
|
|
||||||
focusMode = false,
|
|
||||||
userType,
|
|
||||||
onFocusLayerMouseEnter,
|
|
||||||
className,
|
|
||||||
}: Props) {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [
|
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
|
||||||
state.isSidebarMinimized,
|
|
||||||
state.toggleSidebarMinimized,
|
const {totalAssignedTickets} = useTicketsListener(userId);
|
||||||
]);
|
|
||||||
|
useEffect(() => console.log(totalAssignedTickets), [totalAssignedTickets]);
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
axios.post("/api/logout").finally(() => {
|
axios.post("/api/logout").finally(() => {
|
||||||
@@ -96,20 +95,10 @@ export default function Sidebar({
|
|||||||
"relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8",
|
"relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8",
|
||||||
isMinimized ? "w-fit" : "-xl:w-fit w-1/6",
|
isMinimized ? "w-fit" : "-xl:w-fit w-1/6",
|
||||||
className,
|
className,
|
||||||
)}
|
)}>
|
||||||
>
|
|
||||||
<div className="-xl:hidden flex-col gap-3 xl:flex">
|
<div className="-xl:hidden flex-col gap-3 xl:flex">
|
||||||
<Nav
|
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
|
||||||
disabled={disableNavigation}
|
{(userType === "student" || userType === "teacher" || userType === "developer") && (
|
||||||
Icon={MdSpaceDashboard}
|
|
||||||
label="Dashboard"
|
|
||||||
path={path}
|
|
||||||
keyPath="/"
|
|
||||||
isMinimized={isMinimized}
|
|
||||||
/>
|
|
||||||
{(userType === "student" ||
|
|
||||||
userType === "teacher" ||
|
|
||||||
userType === "developer") && (
|
|
||||||
<>
|
<>
|
||||||
<Nav
|
<Nav
|
||||||
disabled={disableNavigation}
|
disabled={disableNavigation}
|
||||||
@@ -129,25 +118,9 @@ export default function Sidebar({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Nav
|
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
|
||||||
disabled={disableNavigation}
|
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
|
||||||
Icon={BsGraphUp}
|
{["admin", "developer", "agent", "corporate"].includes(userType || "") && (
|
||||||
label="Stats"
|
|
||||||
path={path}
|
|
||||||
keyPath="/stats"
|
|
||||||
isMinimized={isMinimized}
|
|
||||||
/>
|
|
||||||
<Nav
|
|
||||||
disabled={disableNavigation}
|
|
||||||
Icon={BsClockHistory}
|
|
||||||
label="Record"
|
|
||||||
path={path}
|
|
||||||
keyPath="/record"
|
|
||||||
isMinimized={isMinimized}
|
|
||||||
/>
|
|
||||||
{["admin", "developer", "agent", "corporate"].includes(
|
|
||||||
userType || "",
|
|
||||||
) && (
|
|
||||||
<Nav
|
<Nav
|
||||||
disabled={disableNavigation}
|
disabled={disableNavigation}
|
||||||
Icon={BsCurrencyDollar}
|
Icon={BsCurrencyDollar}
|
||||||
@@ -157,9 +130,7 @@ export default function Sidebar({
|
|||||||
isMinimized={isMinimized}
|
isMinimized={isMinimized}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{["admin", "developer", "corporate", "teacher"].includes(
|
{["admin", "developer", "corporate", "teacher"].includes(userType || "") && (
|
||||||
userType || "",
|
|
||||||
) && (
|
|
||||||
<Nav
|
<Nav
|
||||||
disabled={disableNavigation}
|
disabled={disableNavigation}
|
||||||
Icon={BsShieldFill}
|
Icon={BsShieldFill}
|
||||||
@@ -177,6 +148,7 @@ export default function Sidebar({
|
|||||||
path={path}
|
path={path}
|
||||||
keyPath="/tickets"
|
keyPath="/tickets"
|
||||||
isMinimized={isMinimized}
|
isMinimized={isMinimized}
|
||||||
|
badge={totalAssignedTickets}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{userType === "developer" && (
|
{userType === "developer" && (
|
||||||
@@ -191,65 +163,16 @@ export default function Sidebar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="-xl:flex flex-col gap-3 xl:hidden">
|
<div className="-xl:flex flex-col gap-3 xl:hidden">
|
||||||
<Nav
|
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} />
|
||||||
disabled={disableNavigation}
|
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} />
|
||||||
Icon={MdSpaceDashboard}
|
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
|
||||||
label="Dashboard"
|
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
|
||||||
path={path}
|
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
|
||||||
keyPath="/"
|
|
||||||
isMinimized={true}
|
|
||||||
/>
|
|
||||||
<Nav
|
|
||||||
disabled={disableNavigation}
|
|
||||||
Icon={BsFileEarmarkText}
|
|
||||||
label="Exams"
|
|
||||||
path={path}
|
|
||||||
keyPath="/exam"
|
|
||||||
isMinimized={true}
|
|
||||||
/>
|
|
||||||
<Nav
|
|
||||||
disabled={disableNavigation}
|
|
||||||
Icon={BsPencil}
|
|
||||||
label="Exercises"
|
|
||||||
path={path}
|
|
||||||
keyPath="/exercises"
|
|
||||||
isMinimized={true}
|
|
||||||
/>
|
|
||||||
<Nav
|
|
||||||
disabled={disableNavigation}
|
|
||||||
Icon={BsGraphUp}
|
|
||||||
label="Stats"
|
|
||||||
path={path}
|
|
||||||
keyPath="/stats"
|
|
||||||
isMinimized={true}
|
|
||||||
/>
|
|
||||||
<Nav
|
|
||||||
disabled={disableNavigation}
|
|
||||||
Icon={BsClockHistory}
|
|
||||||
label="Record"
|
|
||||||
path={path}
|
|
||||||
keyPath="/record"
|
|
||||||
isMinimized={true}
|
|
||||||
/>
|
|
||||||
{userType !== "student" && (
|
{userType !== "student" && (
|
||||||
<Nav
|
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized={true} />
|
||||||
disabled={disableNavigation}
|
|
||||||
Icon={BsShieldFill}
|
|
||||||
label="Settings"
|
|
||||||
path={path}
|
|
||||||
keyPath="/settings"
|
|
||||||
isMinimized={true}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{userType === "developer" && (
|
{userType === "developer" && (
|
||||||
<Nav
|
<Nav disabled={disableNavigation} Icon={BsCloudFill} label="Generation" path={path} keyPath="/generation" isMinimized={true} />
|
||||||
disabled={disableNavigation}
|
|
||||||
Icon={BsCloudFill}
|
|
||||||
label="Generation"
|
|
||||||
path={path}
|
|
||||||
keyPath="/generation"
|
|
||||||
isMinimized={true}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -261,16 +184,9 @@ export default function Sidebar({
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"hover:text-mti-rose -xl:hidden flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
|
"hover:text-mti-rose -xl:hidden flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
|
||||||
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
||||||
)}
|
)}>
|
||||||
>
|
{isMinimized ? <BsChevronBarRight size={24} /> : <BsChevronBarLeft size={24} />}
|
||||||
{isMinimized ? (
|
{!isMinimized && <span className="text-lg font-medium">Minimize</span>}
|
||||||
<BsChevronBarRight size={24} />
|
|
||||||
) : (
|
|
||||||
<BsChevronBarLeft size={24} />
|
|
||||||
)}
|
|
||||||
{!isMinimized && (
|
|
||||||
<span className="text-lg font-medium">Minimize</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
@@ -279,17 +195,12 @@ export default function Sidebar({
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
|
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
|
||||||
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
||||||
)}
|
)}>
|
||||||
>
|
|
||||||
<RiLogoutBoxFill size={24} />
|
<RiLogoutBoxFill size={24} />
|
||||||
{!isMinimized && (
|
{!isMinimized && <span className="-xl:hidden text-lg font-medium">Log Out</span>}
|
||||||
<span className="-xl:hidden text-lg font-medium">Log Out</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{focusMode && (
|
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
||||||
<FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,9 +224,9 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
defaultValue={monthlyDuration}
|
defaultValue={monthlyDuration}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-3 w-full lg:col-span-2">
|
<div className="flex flex-col gap-3 w-full lg:col-span-3">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Pricing</label>
|
<label className="font-normal text-base text-mti-gray-dim">Pricing</label>
|
||||||
<div className="w-full grid grid-cols-5 gap-2">
|
<div className="w-full grid grid-cols-6 gap-2">
|
||||||
<Input
|
<Input
|
||||||
name="paymentValue"
|
name="paymentValue"
|
||||||
onChange={(e) => setPaymentValue(e ? parseInt(e) : undefined)}
|
onChange={(e) => setPaymentValue(e ? parseInt(e) : undefined)}
|
||||||
@@ -237,7 +237,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
|
"px-4 py-4 col-span-3 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
|
||||||
disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
|
disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
options={CURRENCIES_OPTIONS}
|
options={CURRENCIES_OPTIONS}
|
||||||
|
|||||||
@@ -13,11 +13,14 @@ import {
|
|||||||
BsPen,
|
BsPen,
|
||||||
} from "react-icons/bs";
|
} from "react-icons/bs";
|
||||||
import { usePDFDownload } from "@/hooks/usePDFDownload";
|
import { usePDFDownload } from "@/hooks/usePDFDownload";
|
||||||
|
import { useAssignmentArchive } from "@/hooks/useAssignmentArchive";
|
||||||
import { uniqBy } from "lodash";
|
import { uniqBy } from "lodash";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
allowDownload?: boolean;
|
allowDownload?: boolean;
|
||||||
|
reload?: Function;
|
||||||
|
allowArchive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AssignmentCard({
|
export default function AssignmentCard({
|
||||||
@@ -29,11 +32,14 @@ export default function AssignmentCard({
|
|||||||
assignees,
|
assignees,
|
||||||
results,
|
results,
|
||||||
exams,
|
exams,
|
||||||
|
archived,
|
||||||
onClick,
|
onClick,
|
||||||
allowDownload,
|
allowDownload,
|
||||||
|
reload,
|
||||||
|
allowArchive,
|
||||||
}: Assignment & Props) {
|
}: Assignment & Props) {
|
||||||
const { users } = useUsers();
|
|
||||||
const renderPdfIcon = usePDFDownload("assignments");
|
const renderPdfIcon = usePDFDownload("assignments");
|
||||||
|
const renderArchiveIcon = useAssignmentArchive(id, reload);
|
||||||
|
|
||||||
const calculateAverageModuleScore = (module: Module) => {
|
const calculateAverageModuleScore = (module: Module) => {
|
||||||
const resultModuleBandScores = results.map((r) => {
|
const resultModuleBandScores = results.map((r) => {
|
||||||
@@ -41,11 +47,11 @@ export default function AssignmentCard({
|
|||||||
|
|
||||||
const correct = moduleStats.reduce(
|
const correct = moduleStats.reduce(
|
||||||
(acc, curr) => acc + curr.score.correct,
|
(acc, curr) => acc + curr.score.correct,
|
||||||
0,
|
0
|
||||||
);
|
);
|
||||||
const total = moduleStats.reduce(
|
const total = moduleStats.reduce(
|
||||||
(acc, curr) => acc + curr.score.total,
|
(acc, curr) => acc + curr.score.total,
|
||||||
0,
|
0
|
||||||
);
|
);
|
||||||
return calculateBandScore(correct, total, module, r.type);
|
return calculateBandScore(correct, total, module, r.type);
|
||||||
});
|
});
|
||||||
@@ -64,8 +70,13 @@ export default function AssignmentCard({
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex flex-row justify-between">
|
<div className="flex flex-row justify-between">
|
||||||
<h3 className="text-xl font-semibold">{name}</h3>
|
<h3 className="text-xl font-semibold">{name}</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
{allowDownload &&
|
{allowDownload &&
|
||||||
renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
|
renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
|
||||||
|
{allowArchive &&
|
||||||
|
!archived &&
|
||||||
|
renderArchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
color={results.length / assignees.length < 0.5 ? "red" : "purple"}
|
color={results.length / assignees.length < 0.5 ? "red" : "purple"}
|
||||||
@@ -94,7 +105,7 @@ export default function AssignmentCard({
|
|||||||
module === "listening" && "bg-ielts-listening",
|
module === "listening" && "bg-ielts-listening",
|
||||||
module === "writing" && "bg-ielts-writing",
|
module === "writing" && "bg-ielts-writing",
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
module === "level" && "bg-ielts-level",
|
module === "level" && "bg-ielts-level"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||||
|
|||||||
@@ -1,25 +1,22 @@
|
|||||||
|
import Button from "@/components/Low/Button";
|
||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import { Module } from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import { Assignment } from "@/interfaces/results";
|
import {Assignment} from "@/interfaces/results";
|
||||||
import { Stat, User } from "@/interfaces/user";
|
import {Stat, User} from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import { getExamById } from "@/utils/exams";
|
import {getExamById} from "@/utils/exams";
|
||||||
import { sortByModule } from "@/utils/moduleUtils";
|
import {sortByModule} from "@/utils/moduleUtils";
|
||||||
import { calculateBandScore } from "@/utils/score";
|
import {calculateBandScore} from "@/utils/score";
|
||||||
import { convertToUserSolutions } from "@/utils/stats";
|
import {convertToUserSolutions} from "@/utils/stats";
|
||||||
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { capitalize, uniqBy } from "lodash";
|
import {capitalize, uniqBy} from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { useRouter } from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {
|
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||||
BsBook,
|
import {toast} from "react-toastify";
|
||||||
BsClipboard,
|
|
||||||
BsHeadphones,
|
|
||||||
BsMegaphone,
|
|
||||||
BsPen,
|
|
||||||
} from "react-icons/bs";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -27,8 +24,8 @@ interface Props {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
||||||
const { users } = useUsers();
|
const {users} = useUsers();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const setExams = useExamStore((state) => state.setExams);
|
const setExams = useExamStore((state) => state.setExams);
|
||||||
@@ -36,6 +33,16 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
|||||||
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||||
|
|
||||||
|
const deleteAssignment = async () => {
|
||||||
|
if (!confirm("Are you sure you want to delete this assignment?")) return;
|
||||||
|
|
||||||
|
axios
|
||||||
|
.delete(`/api/assignments/${assignment?.id}`)
|
||||||
|
.then(() => toast.success(`Successfully deleted the assignment "${assignment?.name}".`))
|
||||||
|
.catch(() => toast.error("Something went wrong, please try again later."))
|
||||||
|
.finally(onClose);
|
||||||
|
};
|
||||||
|
|
||||||
const formatTimestamp = (timestamp: string) => {
|
const formatTimestamp = (timestamp: string) => {
|
||||||
const date = moment(parseInt(timestamp));
|
const date = moment(parseInt(timestamp));
|
||||||
const formatter = "YYYY/MM/DD - HH:mm";
|
const formatter = "YYYY/MM/DD - HH:mm";
|
||||||
@@ -49,28 +56,17 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
|||||||
const resultModuleBandScores = assignment.results.map((r) => {
|
const resultModuleBandScores = assignment.results.map((r) => {
|
||||||
const moduleStats = r.stats.filter((s) => s.module === module);
|
const moduleStats = r.stats.filter((s) => s.module === module);
|
||||||
|
|
||||||
const correct = moduleStats.reduce(
|
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
|
||||||
(acc, curr) => acc + curr.score.correct,
|
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
|
||||||
0,
|
|
||||||
);
|
|
||||||
const total = moduleStats.reduce(
|
|
||||||
(acc, curr) => acc + curr.score.total,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
return calculateBandScore(correct, total, module, r.type);
|
return calculateBandScore(correct, total, module, r.type);
|
||||||
});
|
});
|
||||||
|
|
||||||
return resultModuleBandScores.length === 0
|
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length;
|
||||||
? -1
|
|
||||||
: resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
|
|
||||||
assignment.results.length;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const aggregateScoresByModule = (
|
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => {
|
||||||
stats: Stat[],
|
|
||||||
): { module: Module; total: number; missing: number; correct: number }[] => {
|
|
||||||
const scores: {
|
const scores: {
|
||||||
[key in Module]: { total: number; missing: number; correct: number };
|
[key in Module]: {total: number; missing: number; correct: number};
|
||||||
} = {
|
} = {
|
||||||
reading: {
|
reading: {
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -109,25 +105,13 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
|||||||
|
|
||||||
return Object.keys(scores)
|
return Object.keys(scores)
|
||||||
.filter((x) => scores[x as Module].total > 0)
|
.filter((x) => scores[x as Module].total > 0)
|
||||||
.map((x) => ({ module: x as Module, ...scores[x as Module] }));
|
.map((x) => ({module: x as Module, ...scores[x as Module]}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const customContent = (
|
const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => {
|
||||||
stats: Stat[],
|
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
|
||||||
user: string,
|
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
|
||||||
focus: "academic" | "general",
|
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
|
||||||
) => {
|
|
||||||
const correct = stats.reduce(
|
|
||||||
(accumulator, current) => accumulator + current.score.correct,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const total = stats.reduce(
|
|
||||||
(accumulator, current) => accumulator + current.score.total,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const aggregatedScores = aggregateScoresByModule(stats).filter(
|
|
||||||
(x) => x.total > 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
const aggregatedLevels = aggregatedScores.map((x) => ({
|
const aggregatedLevels = aggregatedScores.map((x) => ({
|
||||||
module: x.module,
|
module: x.module,
|
||||||
@@ -137,9 +121,7 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
|||||||
const timeSpent = stats[0].timeSpent;
|
const timeSpent = stats[0].timeSpent;
|
||||||
|
|
||||||
const selectExam = () => {
|
const selectExam = () => {
|
||||||
const examPromises = uniqBy(stats, "exam").map((stat) =>
|
const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam));
|
||||||
getExamById(stat.module, stat.exam),
|
|
||||||
);
|
|
||||||
|
|
||||||
Promise.all(examPromises).then((exams) => {
|
Promise.all(examPromises).then((exams) => {
|
||||||
if (exams.every((x) => !!x)) {
|
if (exams.every((x) => !!x)) {
|
||||||
@@ -161,15 +143,11 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
|||||||
<>
|
<>
|
||||||
<div className="-md:items-center flex w-full justify-between 2xl:items-center">
|
<div className="-md:items-center flex w-full justify-between 2xl:items-center">
|
||||||
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
|
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
|
||||||
<span className="font-medium">
|
<span className="font-medium">{formatTimestamp(stats[0].date.toString())}</span>
|
||||||
{formatTimestamp(stats[0].date.toString())}
|
|
||||||
</span>
|
|
||||||
{timeSpent && (
|
{timeSpent && (
|
||||||
<>
|
<>
|
||||||
<span className="md:hidden 2xl:flex">• </span>
|
<span className="md:hidden 2xl:flex">• </span>
|
||||||
<span className="text-sm">
|
<span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span>
|
||||||
{Math.floor(timeSpent / 60)} minutes
|
|
||||||
</span>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -178,21 +156,15 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
|||||||
correct / total >= 0.7 && "text-mti-purple",
|
correct / total >= 0.7 && "text-mti-purple",
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
||||||
correct / total < 0.3 && "text-mti-rose",
|
correct / total < 0.3 && "text-mti-rose",
|
||||||
)}
|
)}>
|
||||||
>
|
|
||||||
Level{" "}
|
Level{" "}
|
||||||
{(
|
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
|
||||||
aggregatedLevels.reduce(
|
|
||||||
(accumulator, current) => accumulator + current.level,
|
|
||||||
0,
|
|
||||||
) / aggregatedLevels.length
|
|
||||||
).toFixed(1)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full flex-col gap-1">
|
<div className="flex w-full flex-col gap-1">
|
||||||
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
||||||
{aggregatedLevels.map(({ module, level }) => (
|
{aggregatedLevels.map(({module, level}) => (
|
||||||
<div
|
<div
|
||||||
key={module}
|
key={module}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -202,8 +174,7 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
|||||||
module === "writing" && "bg-ielts-writing",
|
module === "writing" && "bg-ielts-writing",
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
module === "level" && "bg-ielts-level",
|
module === "level" && "bg-ielts-level",
|
||||||
)}
|
)}>
|
||||||
>
|
|
||||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||||
@@ -230,14 +201,11 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
|
"border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
|
||||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||||
correct / total >= 0.3 &&
|
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
||||||
correct / total < 0.7 &&
|
|
||||||
"hover:border-mti-red",
|
|
||||||
correct / total < 0.3 && "hover:border-mti-rose",
|
correct / total < 0.3 && "hover:border-mti-rose",
|
||||||
)}
|
)}
|
||||||
onClick={selectExam}
|
onClick={selectExam}
|
||||||
role="button"
|
role="button">
|
||||||
>
|
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -245,14 +213,11 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden",
|
"border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden",
|
||||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||||
correct / total >= 0.3 &&
|
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
||||||
correct / total < 0.7 &&
|
|
||||||
"hover:border-mti-red",
|
|
||||||
correct / total < 0.3 && "hover:border-mti-rose",
|
correct / total < 0.3 && "hover:border-mti-rose",
|
||||||
)}
|
)}
|
||||||
data-tip="Your screen size is too small to view previous exams."
|
data-tip="Your screen size is too small to view previous exams."
|
||||||
role="button"
|
role="button">
|
||||||
>
|
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -267,27 +232,14 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
|||||||
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
|
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
|
||||||
className="h-6"
|
className="h-6"
|
||||||
textClassName={
|
textClassName={
|
||||||
(assignment?.results.length || 0) /
|
(assignment?.results.length || 0) / (assignment?.assignees.length || 1) < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"
|
||||||
(assignment?.assignees.length || 1) <
|
|
||||||
0.5
|
|
||||||
? "!text-mti-gray-dim font-light"
|
|
||||||
: "text-white"
|
|
||||||
}
|
|
||||||
percentage={
|
|
||||||
((assignment?.results.length || 0) /
|
|
||||||
(assignment?.assignees.length || 1)) *
|
|
||||||
100
|
|
||||||
}
|
}
|
||||||
|
percentage={((assignment?.results.length || 0) / (assignment?.assignees.length || 1)) * 100}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-start gap-8">
|
<div className="flex items-start gap-8">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span>
|
<span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
Start Date:{" "}
|
<span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
{moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<span>
|
<span>
|
||||||
Assignees:{" "}
|
Assignees:{" "}
|
||||||
@@ -301,7 +253,7 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
|||||||
<span className="text-xl font-bold">Average Scores</span>
|
<span className="text-xl font-bold">Average Scores</span>
|
||||||
<div className="-md:mt-2 flex w-full items-center gap-4">
|
<div className="-md:mt-2 flex w-full items-center gap-4">
|
||||||
{assignment &&
|
{assignment &&
|
||||||
uniqBy(assignment.exams, (x) => x.module).map(({ module }) => (
|
uniqBy(assignment.exams, (x) => x.module).map(({module}) => (
|
||||||
<div
|
<div
|
||||||
data-tip={capitalize(module)}
|
data-tip={capitalize(module)}
|
||||||
key={module}
|
key={module}
|
||||||
@@ -312,19 +264,14 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
|||||||
module === "writing" && "bg-ielts-writing",
|
module === "writing" && "bg-ielts-writing",
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
module === "level" && "bg-ielts-level",
|
module === "level" && "bg-ielts-level",
|
||||||
)}
|
)}>
|
||||||
>
|
|
||||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||||
{module === "listening" && (
|
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||||
<BsHeadphones className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||||
{calculateAverageModuleScore(module) > -1 && (
|
{calculateAverageModuleScore(module) > -1 && (
|
||||||
<span className="text-sm">
|
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
|
||||||
{calculateAverageModuleScore(module).toFixed(1)}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -332,22 +279,28 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xl font-bold">
|
<span className="text-xl font-bold">
|
||||||
Results ({assignment?.results.length}/{assignment?.assignees.length}
|
Results ({assignment?.results.length}/{assignment?.assignees.length})
|
||||||
)
|
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
{assignment && assignment?.results.length > 0 && (
|
{assignment && assignment?.results.length > 0 && (
|
||||||
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
|
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
|
||||||
{assignment.results.map((r) =>
|
{assignment.results.map((r) => customContent(r.stats, r.user, r.type))}
|
||||||
customContent(r.stats, r.user, r.type),
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{assignment && assignment?.results.length === 0 && (
|
{assignment && assignment?.results.length === 0 && <span className="ml-1 font-semibold">No results yet...</span>}
|
||||||
<span className="ml-1 font-semibold">No results yet...</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 w-full items-center justify-end">
|
||||||
|
{assignment && (assignment.results.length === assignment.assignees.length || moment().isAfter(moment(assignment.endDate))) && (
|
||||||
|
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={deleteAssignment}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={onClose} className="w-full max-w-[200px]">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import useExamStore from "@/stores/examStore";
|
|||||||
import {getExamById} from "@/utils/exams";
|
import {getExamById} from "@/utils/exams";
|
||||||
import {getUserCorporate} from "@/utils/groups";
|
import {getUserCorporate} from "@/utils/groups";
|
||||||
import {MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
|
import {MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
|
||||||
|
import {getLevelLabel, getLevelScore} from "@/utils/score";
|
||||||
import {averageScore, groupBySession} from "@/utils/stats";
|
import {averageScore, groupBySession} from "@/utils/stats";
|
||||||
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js";
|
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js";
|
||||||
import {PayPalButtons} from "@paypal/react-paypal-js";
|
import {PayPalButtons} from "@paypal/react-paypal-js";
|
||||||
@@ -86,16 +87,19 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
icon: <BsFileEarmarkText className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
|
icon: <BsFileEarmarkText className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
|
||||||
value: Object.keys(groupBySession(stats)).length,
|
value: Object.keys(groupBySession(stats)).length,
|
||||||
label: "Exams",
|
label: "Exams",
|
||||||
|
tooltip: "Number of all conducted completed exams",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BsPencil className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
|
icon: <BsPencil className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
|
||||||
value: stats.length,
|
value: stats.length,
|
||||||
label: "Exercises",
|
label: "Exercises",
|
||||||
|
tooltip: "Number of all conducted exercises including Level Test",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BsStar className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
|
icon: <BsStar className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
|
||||||
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
|
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
|
||||||
label: "Average Score",
|
label: "Average Score",
|
||||||
|
tooltip: "Average success rate for questions responded",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -224,7 +228,10 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
<section className="flex flex-col gap-3">
|
<section className="flex flex-col gap-3">
|
||||||
<span className="text-lg font-bold">Score History</span>
|
<span className="text-lg font-bold">Score History</span>
|
||||||
<div className="-md:grid-rows-4 grid gap-6 md:grid-cols-2">
|
<div className="-md:grid-rows-4 grid gap-6 md:grid-cols-2">
|
||||||
{MODULE_ARRAY.map((module) => (
|
{MODULE_ARRAY.map((module) => {
|
||||||
|
const desiredLevel = user.desiredLevels[module] || 9;
|
||||||
|
const level = user.levels[module] || 0;
|
||||||
|
return (
|
||||||
<div className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4" key={module}>
|
<div className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4" key={module}>
|
||||||
<div className="flex items-center gap-2 md:gap-3">
|
<div className="flex items-center gap-2 md:gap-3">
|
||||||
<div className="bg-mti-gray-smoke flex h-8 w-8 items-center justify-center rounded-lg md:h-12 md:w-12 md:rounded-xl">
|
<div className="bg-mti-gray-smoke flex h-8 w-8 items-center justify-center rounded-lg md:h-12 md:w-12 md:rounded-xl">
|
||||||
@@ -237,7 +244,8 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
<div className="flex w-full justify-between">
|
<div className="flex w-full justify-between">
|
||||||
<span className="text-sm font-bold md:font-extrabold">{capitalize(module)}</span>
|
<span className="text-sm font-bold md:font-extrabold">{capitalize(module)}</span>
|
||||||
<span className="text-mti-gray-dim text-sm font-normal">
|
<span className="text-mti-gray-dim text-sm font-normal">
|
||||||
Level {user.levels[module] || 0} / Level 9 (Desired Level: {user.desiredLevels[module] || 9})
|
{module === "level" && `English Level: ${getLevelLabel(level).join(" / ")}`}
|
||||||
|
{module !== "level" && `Level ${level} / Level 9 (Desired Level: ${desiredLevel})`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,14 +253,15 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
<ProgressBar
|
<ProgressBar
|
||||||
color={module}
|
color={module}
|
||||||
label=""
|
label=""
|
||||||
mark={Math.round((user.desiredLevels[module] * 100) / 9)}
|
mark={Math.round((desiredLevel * 100) / 9)}
|
||||||
markLabel={`Desired Level: ${user.desiredLevels[module]}`}
|
markLabel={`Desired Level: ${desiredLevel}`}
|
||||||
percentage={Math.round((user.levels[module] * 100) / 9)}
|
percentage={Math.round((level * 100) / 9)}
|
||||||
className="h-2 w-full"
|
className="h-2 w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -151,9 +151,8 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AssignmentsPage = () => {
|
const AssignmentsPage = () => {
|
||||||
const activeFilter = (a: Assignment) =>
|
const activeFilter = (a: Assignment) => moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()) && a.assignees.length > a.results.length;
|
||||||
moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()) && a.assignees.length > a.results.length;
|
const pastFilter = (a: Assignment) => (moment(a.endDate).isBefore(moment()) || a.assignees.length === a.results.length) && !a.archived;
|
||||||
const pastFilter = (a: Assignment) => moment(a.endDate).isBefore(moment()) || a.assignees.length === a.results.length;
|
|
||||||
const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
|
const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -163,6 +162,7 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
onClose={() => {
|
onClose={() => {
|
||||||
setSelectedAssignment(undefined);
|
setSelectedAssignment(undefined);
|
||||||
setIsCreatingAssignment(false);
|
setIsCreatingAssignment(false);
|
||||||
|
reloadAssignments();
|
||||||
}}
|
}}
|
||||||
assignment={selectedAssignment}
|
assignment={selectedAssignment}
|
||||||
/>
|
/>
|
||||||
@@ -234,7 +234,7 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
|
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{assignments.filter(pastFilter).map((a) => (
|
{assignments.filter(pastFilter).map((a) => (
|
||||||
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} allowDownload />
|
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} allowDownload reload={reloadAssignments} allowArchive/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -280,7 +280,7 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
||||||
<span className="flex flex-col gap-1 items-center text-xl">
|
<span className="flex flex-col gap-1 items-center text-xl">
|
||||||
<span className="text-lg">Assignments</span>
|
<span className="text-lg">Assignments</span>
|
||||||
<span className="font-semibold text-mti-purple-light">{assignments.length}</span>
|
<span className="font-semibold text-mti-purple-light">{assignments.filter((a) => !a.archived).length}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
35
src/email/templates/ticketStatusCompleted.handlebars
Normal file
35
src/email/templates/ticketStatusCompleted.handlebars
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<div style="background-color: #ffffff; color: #353338;"
|
||||||
|
class="h-full min-h-screen w-full flex flex-col p-8 gap-16 text-base">
|
||||||
|
<img src="/logo_title.png" class="w-48 h-48 self-center" />
|
||||||
|
<div>
|
||||||
|
<span>Your ticket has been completed!</span>
|
||||||
|
<br/>
|
||||||
|
<span>Here is the ticket's information:</span>
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
<span><b>ID:</b> {{id}}</span><br/>
|
||||||
|
<span><b>Subject:</b> {{subject}}</span><br/>
|
||||||
|
<span><b>Reporter:</b> {{reporter.name}} - {{reporter.email}}</span><br/>
|
||||||
|
<span><b>Date:</b> {{date}}</span><br/>
|
||||||
|
<span><b>Type:</b> {{type}}</span><br/>
|
||||||
|
<span><b>Page:</b> {{reportedFrom}}</span>
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
<span><b>Description:</b> {{description}}</span><br/>
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<div>
|
||||||
|
<span>Thanks, <br /> Your EnCoach team</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -16,7 +16,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const INSTRUCTIONS_AUDIO_SRC =
|
const INSTRUCTIONS_AUDIO_SRC =
|
||||||
"https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_recordings%2Fgeneric_intro.mp3?alt=media&token=9b9cfdb8-e90d-40d1-854b-51c4378a5c4b";
|
"https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/generic_listening_intro_v2.mp3?alt=media&token=16769f5f-1e9b-4a72-86a9-45a6f0fa9f82";
|
||||||
|
|
||||||
export default function Listening({exam, showSolutions = false, onFinish}: Props) {
|
export default function Listening({exam, showSolutions = false, onFinish}: Props) {
|
||||||
const [questionIndex, setQuestionIndex] = useState(0);
|
const [questionIndex, setQuestionIndex] = useState(0);
|
||||||
|
|||||||
@@ -66,26 +66,31 @@ export default function Selection({user, page, onStart, disableSelection = false
|
|||||||
icon: <BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />,
|
icon: <BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />,
|
||||||
label: "Reading",
|
label: "Reading",
|
||||||
value: totalExamsByModule(stats, "reading"),
|
value: totalExamsByModule(stats, "reading"),
|
||||||
|
tooltip: "The amount of reading exams performed.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />,
|
icon: <BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />,
|
||||||
label: "Listening",
|
label: "Listening",
|
||||||
value: totalExamsByModule(stats, "listening"),
|
value: totalExamsByModule(stats, "listening"),
|
||||||
|
tooltip: "The amount of listening exams performed.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />,
|
icon: <BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />,
|
||||||
label: "Writing",
|
label: "Writing",
|
||||||
value: totalExamsByModule(stats, "writing"),
|
value: totalExamsByModule(stats, "writing"),
|
||||||
|
tooltip: "The amount of writing exams performed.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />,
|
icon: <BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />,
|
||||||
label: "Speaking",
|
label: "Speaking",
|
||||||
value: totalExamsByModule(stats, "speaking"),
|
value: totalExamsByModule(stats, "speaking"),
|
||||||
|
tooltip: "The amount of speaking exams performed.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />,
|
icon: <BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />,
|
||||||
label: "Level",
|
label: "Level",
|
||||||
value: totalExamsByModule(stats, "level"),
|
value: totalExamsByModule(stats, "level"),
|
||||||
|
tooltip: "The amount of level exams performed.",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
26
src/hooks/useAcceptedTerms.tsx
Normal file
26
src/hooks/useAcceptedTerms.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
|
|
||||||
|
const useAcceptedTerms = () => {
|
||||||
|
const [acceptedTerms, setAcceptedTerms] = React.useState(false);
|
||||||
|
|
||||||
|
const renderCheckbox = () => (
|
||||||
|
<Checkbox isChecked={acceptedTerms} onChange={setAcceptedTerms}>
|
||||||
|
I agree to the
|
||||||
|
<Link href={`https://encoach.com/terms`} className="text-mti-purple-light">
|
||||||
|
{" "}
|
||||||
|
Terms and Conditions
|
||||||
|
</Link>{" "}
|
||||||
|
and
|
||||||
|
<Link href={`https://encoach.com/privacy-policy`} className="text-mti-purple-light">
|
||||||
|
{" "}
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
</Checkbox>
|
||||||
|
);
|
||||||
|
|
||||||
|
return {acceptedTerms, renderCheckbox};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useAcceptedTerms;
|
||||||
45
src/hooks/useAssignmentArchive.tsx
Normal file
45
src/hooks/useAssignmentArchive.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import React from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { BsArchive } from "react-icons/bs";
|
||||||
|
|
||||||
|
export const useAssignmentArchive = (
|
||||||
|
assignmentId: string,
|
||||||
|
reload?: Function
|
||||||
|
) => {
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const archive = () => {
|
||||||
|
// archive assignment
|
||||||
|
setLoading(true);
|
||||||
|
axios
|
||||||
|
.post(`/api/assignments/${assignmentId}/archive`)
|
||||||
|
.then((res) => {
|
||||||
|
toast.success("Assignment archived!");
|
||||||
|
if(reload) reload();
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error("Failed to archive the assignment!");
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderIcon = (downloadClasses: string, loadingClasses: string) => {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<span className={`${loadingClasses} loading loading-infinity w-6`} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<BsArchive
|
||||||
|
className={`${downloadClasses} text-2xl cursor-pointer`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
archive();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return renderIcon;
|
||||||
|
};
|
||||||
23
src/hooks/usePaypalPayments.tsx
Normal file
23
src/hooks/usePaypalPayments.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import {PaypalPayment} from "@/interfaces/paypal";
|
||||||
|
import axios from "axios";
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
|
||||||
|
export default function usePaypalPayments() {
|
||||||
|
const [payments, setPayments] = useState<PaypalPayment[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isError, setIsError] = useState(false);
|
||||||
|
|
||||||
|
const getData = () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
axios
|
||||||
|
.get<PaypalPayment[]>("/api/payments/paypal")
|
||||||
|
.then((response) => {
|
||||||
|
return setPayments(response.data);
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(getData, []);
|
||||||
|
|
||||||
|
return {payments, isLoading, isError, reload: getData};
|
||||||
|
}
|
||||||
20
src/hooks/usePaypalTracking.tsx
Normal file
20
src/hooks/usePaypalTracking.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export const usePaypalTracking = () => {
|
||||||
|
const [trackingId, setTrackingId] = useState<string>();
|
||||||
|
useEffect(() => {
|
||||||
|
axios
|
||||||
|
.put<{ ok: boolean; trackingId: string }>("/api/paypal/raas")
|
||||||
|
.then((response) => {
|
||||||
|
if (response.data.ok) {
|
||||||
|
setTrackingId(response.data.trackingId);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return trackingId;
|
||||||
|
};
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
import { Ticket } from "@/interfaces/ticket";
|
import { TicketWithCorporate } from "@/interfaces/ticket";
|
||||||
import { Code, Group, User } from "@/interfaces/user";
|
import { Code, Group, User } from "@/interfaces/user";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
|
||||||
export default function useTickets() {
|
export default function useTickets() {
|
||||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
const [tickets, setTickets] = useState<TicketWithCorporate[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isError, setIsError] = useState(false);
|
const [isError, setIsError] = useState(false);
|
||||||
|
|
||||||
const getData = () => {
|
const getData = useCallback(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.get<Ticket[]>(`/api/tickets`)
|
.get<TicketWithCorporate[]>(`/api/tickets`)
|
||||||
.then((response) => setTickets(response.data))
|
.then((response) => setTickets(response.data))
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
useEffect(getData, []);
|
useEffect(getData, [getData]);
|
||||||
|
|
||||||
return { tickets, isLoading, isError, reload: getData };
|
return { tickets, isLoading, isError, reload: getData };
|
||||||
}
|
}
|
||||||
|
|||||||
29
src/hooks/useTicketsListener.tsx
Normal file
29
src/hooks/useTicketsListener.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import React from "react";
|
||||||
|
import useTickets from "./useTickets";
|
||||||
|
|
||||||
|
const useTicketsListener = (userId?: string) => {
|
||||||
|
const { tickets, reload } = useTickets();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
reload();
|
||||||
|
}, 60 * 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
const assignedTickets = tickets.filter(
|
||||||
|
(ticket) => ticket.assignedTo === userId && ticket.status === "submitted"
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
assignedTickets,
|
||||||
|
totalAssignedTickets: assignedTickets.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useTicketsListener;
|
||||||
@@ -3,6 +3,7 @@ import {Module} from ".";
|
|||||||
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
|
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
|
||||||
export type Variant = "full" | "partial";
|
export type Variant = "full" | "partial";
|
||||||
export type InstructorGender = "male" | "female" | "varied";
|
export type InstructorGender = "male" | "female" | "varied";
|
||||||
|
export type Difficulty = "easy" | "medium" | "hard";
|
||||||
|
|
||||||
export interface ReadingExam {
|
export interface ReadingExam {
|
||||||
parts: ReadingPart[];
|
parts: ReadingPart[];
|
||||||
@@ -12,6 +13,7 @@ export interface ReadingExam {
|
|||||||
type: "academic" | "general";
|
type: "academic" | "general";
|
||||||
isDiagnostic: boolean;
|
isDiagnostic: boolean;
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
|
difficulty?: Difficulty;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReadingPart {
|
export interface ReadingPart {
|
||||||
@@ -29,6 +31,7 @@ export interface LevelExam {
|
|||||||
minTimer: number;
|
minTimer: number;
|
||||||
isDiagnostic: boolean;
|
isDiagnostic: boolean;
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
|
difficulty?: Difficulty;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListeningExam {
|
export interface ListeningExam {
|
||||||
@@ -38,6 +41,7 @@ export interface ListeningExam {
|
|||||||
minTimer: number;
|
minTimer: number;
|
||||||
isDiagnostic: boolean;
|
isDiagnostic: boolean;
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
|
difficulty?: Difficulty;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListeningPart {
|
export interface ListeningPart {
|
||||||
@@ -65,10 +69,11 @@ export interface UserSolution {
|
|||||||
export interface WritingExam {
|
export interface WritingExam {
|
||||||
module: "writing";
|
module: "writing";
|
||||||
id: string;
|
id: string;
|
||||||
exercises: Exercise[];
|
exercises: WritingExercise[];
|
||||||
minTimer: number;
|
minTimer: number;
|
||||||
isDiagnostic: boolean;
|
isDiagnostic: boolean;
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
|
difficulty?: Difficulty;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WordCounter {
|
interface WordCounter {
|
||||||
@@ -79,11 +84,12 @@ interface WordCounter {
|
|||||||
export interface SpeakingExam {
|
export interface SpeakingExam {
|
||||||
id: string;
|
id: string;
|
||||||
module: "speaking";
|
module: "speaking";
|
||||||
exercises: Exercise[];
|
exercises: (SpeakingExercise | InteractiveSpeakingExercise)[];
|
||||||
minTimer: number;
|
minTimer: number;
|
||||||
isDiagnostic: boolean;
|
isDiagnostic: boolean;
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
instructorGender: InstructorGender;
|
instructorGender: InstructorGender;
|
||||||
|
difficulty?: Difficulty;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Exercise =
|
export type Exercise =
|
||||||
@@ -142,6 +148,7 @@ export interface WritingExercise {
|
|||||||
solution: string;
|
solution: string;
|
||||||
evaluation?: CommonEvaluation;
|
evaluation?: CommonEvaluation;
|
||||||
}[];
|
}[];
|
||||||
|
topic?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpeakingExercise {
|
export interface SpeakingExercise {
|
||||||
@@ -156,6 +163,7 @@ export interface SpeakingExercise {
|
|||||||
solution: string;
|
solution: string;
|
||||||
evaluation?: SpeakingEvaluation;
|
evaluation?: SpeakingEvaluation;
|
||||||
}[];
|
}[];
|
||||||
|
topic?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InteractiveSpeakingExercise {
|
export interface InteractiveSpeakingExercise {
|
||||||
@@ -169,6 +177,7 @@ export interface InteractiveSpeakingExercise {
|
|||||||
solution: {questionIndex: number; question: string; answer: string}[];
|
solution: {questionIndex: number; question: string; answer: string}[];
|
||||||
evaluation?: InteractiveSpeakingEvaluation;
|
evaluation?: InteractiveSpeakingEvaluation;
|
||||||
}[];
|
}[];
|
||||||
|
topic?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FillBlanksExercise {
|
export interface FillBlanksExercise {
|
||||||
|
|||||||
@@ -35,3 +35,16 @@ export interface Payment {
|
|||||||
corporateTransfer?: string;
|
corporateTransfer?: string;
|
||||||
commissionTransfer?: string;
|
commissionTransfer?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface PaypalPayment {
|
||||||
|
orderId: string;
|
||||||
|
userId: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: Date;
|
||||||
|
value: number;
|
||||||
|
currency: string;
|
||||||
|
subscriptionDuration: number;
|
||||||
|
subscriptionDurationUnit: DurationUnit;
|
||||||
|
subscriptionExpirationDate: Date;
|
||||||
|
}
|
||||||
@@ -24,4 +24,5 @@ export interface Assignment {
|
|||||||
instructorGender?: InstructorGender;
|
instructorGender?: InstructorGender;
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
endDate: Date;
|
endDate: Date;
|
||||||
|
archived?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,3 +32,7 @@ export const TicketStatusLabel: { [key in TicketStatus]: string } = {
|
|||||||
"in-progress": "In Progress",
|
"in-progress": "In Progress",
|
||||||
completed: "Completed",
|
completed: "Completed",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface TicketWithCorporate extends Ticket {
|
||||||
|
corporate?: string;
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ export interface StudentUser extends BasicUser {
|
|||||||
type: "student";
|
type: "student";
|
||||||
preferredGender?: InstructorGender;
|
preferredGender?: InstructorGender;
|
||||||
demographicInformation?: DemographicInformation;
|
demographicInformation?: DemographicInformation;
|
||||||
|
preferredTopics?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeacherUser extends BasicUser {
|
export interface TeacherUser extends BasicUser {
|
||||||
@@ -52,6 +53,7 @@ export interface DeveloperUser extends BasicUser {
|
|||||||
type: "developer";
|
type: "developer";
|
||||||
preferredGender?: InstructorGender;
|
preferredGender?: InstructorGender;
|
||||||
demographicInformation?: DemographicInformation;
|
demographicInformation?: DemographicInformation;
|
||||||
|
preferredTopics?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CorporateInformation {
|
export interface CorporateInformation {
|
||||||
|
|||||||
@@ -336,12 +336,13 @@ export default function ExamPage({page}: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
answers.forEach((x) => {
|
answers.forEach((x) => {
|
||||||
console.log({x});
|
const examModule =
|
||||||
|
x.module || (x.type === "writing" ? "writing" : x.type === "speaking" || x.type === "interactiveSpeaking" ? "speaking" : undefined);
|
||||||
|
|
||||||
scores[x.module!] = {
|
scores[examModule!] = {
|
||||||
total: scores[x.module!].total + x.score.total,
|
total: scores[examModule!].total + x.score.total,
|
||||||
correct: scores[x.module!].correct + x.score.correct,
|
correct: scores[examModule!].correct + x.score.correct,
|
||||||
missing: scores[x.module!].missing + x.score.missing,
|
missing: scores[examModule!].missing + x.score.missing,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -423,7 +424,7 @@ export default function ExamPage({page}: Props) {
|
|||||||
<AbandonPopup
|
<AbandonPopup
|
||||||
isOpen={showAbandonPopup}
|
isOpen={showAbandonPopup}
|
||||||
abandonPopupTitle="Leave Exercise"
|
abandonPopupTitle="Leave Exercise"
|
||||||
abandonPopupDescription="Are you sure you want to leave the exercise? You will lose all your progress."
|
abandonPopupDescription="Are you sure you want to leave the exercise? Your progress will be saved and this exam can be resumed on the Dashboard."
|
||||||
abandonConfirmButtonText="Confirm"
|
abandonConfirmButtonText="Confirm"
|
||||||
onAbandon={() => {
|
onAbandon={() => {
|
||||||
reset();
|
reset();
|
||||||
|
|||||||
@@ -1,23 +1,30 @@
|
|||||||
import {LevelExam, MultipleChoiceExercise} from "@/interfaces/exam";
|
import Select from "@/components/Low/Select";
|
||||||
|
import {Difficulty, LevelExam, MultipleChoiceExercise} from "@/interfaces/exam";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {getExamById} from "@/utils/exams";
|
import {getExamById} from "@/utils/exams";
|
||||||
import {playSound} from "@/utils/sound";
|
import {playSound} from "@/utils/sound";
|
||||||
import {Tab} from "@headlessui/react";
|
import {Tab} from "@headlessui/react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import {capitalize, sample} from "lodash";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
import {BsArrowRepeat} from "react-icons/bs";
|
import {BsArrowRepeat} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import {v4} from "uuid";
|
import {v4} from "uuid";
|
||||||
|
|
||||||
const TaskTab = ({exam, setExam}: {exam?: LevelExam; setExam: (exam: LevelExam) => void}) => {
|
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
||||||
|
|
||||||
|
const TaskTab = ({exam, difficulty, setExam}: {exam?: LevelExam; difficulty: Difficulty; setExam: (exam: LevelExam) => void}) => {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const generate = () => {
|
const generate = () => {
|
||||||
|
const url = new URLSearchParams();
|
||||||
|
url.append("difficulty", difficulty);
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.get(`/api/exam/level/generate/level`)
|
.get(`/api/exam/level/generate/level?${url.toString()}`)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
playSound(typeof result.data === "string" ? "error" : "check");
|
playSound(typeof result.data === "string" ? "error" : "check");
|
||||||
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
||||||
@@ -107,6 +114,7 @@ const LevelGeneration = () => {
|
|||||||
const [generatedExam, setGeneratedExam] = useState<LevelExam>();
|
const [generatedExam, setGeneratedExam] = useState<LevelExam>();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [resultingExam, setResultingExam] = useState<LevelExam>();
|
const [resultingExam, setResultingExam] = useState<LevelExam>();
|
||||||
|
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -163,6 +171,16 @@ const LevelGeneration = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className="flex gap-4 w-1/2">
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||||
|
<Select
|
||||||
|
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
|
||||||
|
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
|
||||||
|
value={{value: difficulty, label: capitalize(difficulty)}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Tab.Group>
|
<Tab.Group>
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1">
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1">
|
||||||
<Tab
|
<Tab
|
||||||
@@ -178,7 +196,7 @@ const LevelGeneration = () => {
|
|||||||
</Tab>
|
</Tab>
|
||||||
</Tab.List>
|
</Tab.List>
|
||||||
<Tab.Panels>
|
<Tab.Panels>
|
||||||
<TaskTab exam={generatedExam} setExam={setGeneratedExam} />
|
<TaskTab difficulty={difficulty} exam={generatedExam} setExam={setGeneratedExam} />
|
||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
<div className="w-full flex justify-end gap-4">
|
<div className="w-full flex justify-end gap-4">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import {Exercise, ListeningExam} from "@/interfaces/exam";
|
import Select from "@/components/Low/Select";
|
||||||
|
import {Difficulty, Exercise, ListeningExam} from "@/interfaces/exam";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {getExamById} from "@/utils/exams";
|
import {getExamById} from "@/utils/exams";
|
||||||
import {playSound} from "@/utils/sound";
|
import {playSound} from "@/utils/sound";
|
||||||
@@ -7,17 +8,34 @@ import {convertCamelCaseToReadable} from "@/utils/string";
|
|||||||
import {Tab} from "@headlessui/react";
|
import {Tab} from "@headlessui/react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import {capitalize, sample} from "lodash";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
|
|
||||||
const PartTab = ({part, types, index, setPart}: {part?: ListeningPart; types: string[]; index: number; setPart: (part?: ListeningPart) => void}) => {
|
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
||||||
|
|
||||||
|
const PartTab = ({
|
||||||
|
part,
|
||||||
|
types,
|
||||||
|
difficulty,
|
||||||
|
index,
|
||||||
|
setPart,
|
||||||
|
}: {
|
||||||
|
part?: ListeningPart;
|
||||||
|
difficulty: Difficulty;
|
||||||
|
types: string[];
|
||||||
|
index: number;
|
||||||
|
setPart: (part?: ListeningPart) => void;
|
||||||
|
}) => {
|
||||||
const [topic, setTopic] = useState("");
|
const [topic, setTopic] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const generate = () => {
|
const generate = () => {
|
||||||
const url = new URLSearchParams();
|
const url = new URLSearchParams();
|
||||||
|
url.append("difficulty", difficulty);
|
||||||
|
|
||||||
if (topic) url.append("topic", topic);
|
if (topic) url.append("topic", topic);
|
||||||
if (types) types.forEach((t) => url.append("exercises", t));
|
if (types) types.forEach((t) => url.append("exercises", t));
|
||||||
|
|
||||||
@@ -115,6 +133,7 @@ const ListeningGeneration = () => {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [resultingExam, setResultingExam] = useState<ListeningExam>();
|
const [resultingExam, setResultingExam] = useState<ListeningExam>();
|
||||||
const [types, setTypes] = useState<string[]>([]);
|
const [types, setTypes] = useState<string[]>([]);
|
||||||
|
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const part1Timer = part1 ? 5 : 0;
|
const part1Timer = part1 ? 5 : 0;
|
||||||
@@ -148,7 +167,7 @@ const ListeningGeneration = () => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post(`/api/exam/listening/generate/listening`, {parts, minTimer})
|
.post(`/api/exam/listening/generate/listening`, {parts, minTimer, difficulty})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
playSound("sent");
|
playSound("sent");
|
||||||
console.log(`Generated Exam ID: ${result.data.id}`);
|
console.log(`Generated Exam ID: ${result.data.id}`);
|
||||||
@@ -159,6 +178,7 @@ const ListeningGeneration = () => {
|
|||||||
setPart2(undefined);
|
setPart2(undefined);
|
||||||
setPart3(undefined);
|
setPart3(undefined);
|
||||||
setPart4(undefined);
|
setPart4(undefined);
|
||||||
|
setDifficulty(sample(DIFFICULTIES)!);
|
||||||
setTypes([]);
|
setTypes([]);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -186,6 +206,7 @@ const ListeningGeneration = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className="flex gap-4 w-1/2">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -196,6 +217,16 @@ const ListeningGeneration = () => {
|
|||||||
className="max-w-[300px]"
|
className="max-w-[300px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||||
|
<Select
|
||||||
|
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
|
||||||
|
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
|
||||||
|
value={{value: difficulty, label: capitalize(difficulty)}}
|
||||||
|
disabled={!!part1 || !!part2 || !!part3 || !!part4}
|
||||||
|
/>
|
||||||
|
</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">Exercises</label>
|
<label className="font-normal text-base text-mti-gray-dim">Exercises</label>
|
||||||
@@ -271,7 +302,7 @@ const ListeningGeneration = () => {
|
|||||||
{part: part3, setPart: setPart3},
|
{part: part3, setPart: setPart3},
|
||||||
{part: part4, setPart: setPart4},
|
{part: part4, setPart: setPart4},
|
||||||
].map(({part, setPart}, index) => (
|
].map(({part, setPart}, index) => (
|
||||||
<PartTab part={part} types={types} index={index + 1} key={index} setPart={setPart} />
|
<PartTab part={part} difficulty={difficulty} types={types} index={index + 1} key={index} setPart={setPart} />
|
||||||
))}
|
))}
|
||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import {ReadingExam, ReadingPart} from "@/interfaces/exam";
|
import Select from "@/components/Low/Select";
|
||||||
|
import {Difficulty, ReadingExam, ReadingPart} from "@/interfaces/exam";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {getExamById} from "@/utils/exams";
|
import {getExamById} from "@/utils/exams";
|
||||||
import {playSound} from "@/utils/sound";
|
import {playSound} from "@/utils/sound";
|
||||||
@@ -7,18 +8,35 @@ import {convertCamelCaseToReadable} from "@/utils/string";
|
|||||||
import {Tab} from "@headlessui/react";
|
import {Tab} from "@headlessui/react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import {capitalize, sample} from "lodash";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import {v4} from "uuid";
|
import {v4} from "uuid";
|
||||||
|
|
||||||
const PartTab = ({part, types, index, setPart}: {part?: ReadingPart; types: string[]; index: number; setPart: (part?: ReadingPart) => void}) => {
|
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
||||||
|
|
||||||
|
const PartTab = ({
|
||||||
|
part,
|
||||||
|
types,
|
||||||
|
difficulty,
|
||||||
|
index,
|
||||||
|
setPart,
|
||||||
|
}: {
|
||||||
|
part?: ReadingPart;
|
||||||
|
types: string[];
|
||||||
|
index: number;
|
||||||
|
difficulty: Difficulty;
|
||||||
|
setPart: (part?: ReadingPart) => void;
|
||||||
|
}) => {
|
||||||
const [topic, setTopic] = useState("");
|
const [topic, setTopic] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const generate = () => {
|
const generate = () => {
|
||||||
const url = new URLSearchParams();
|
const url = new URLSearchParams();
|
||||||
|
url.append("difficulty", difficulty);
|
||||||
|
|
||||||
if (topic) url.append("topic", topic);
|
if (topic) url.append("topic", topic);
|
||||||
if (types) types.forEach((t) => url.append("exercises", t));
|
if (types) types.forEach((t) => url.append("exercises", t));
|
||||||
|
|
||||||
@@ -92,6 +110,7 @@ const ReadingGeneration = () => {
|
|||||||
const [types, setTypes] = useState<string[]>([]);
|
const [types, setTypes] = useState<string[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [resultingExam, setResultingExam] = useState<ReadingExam>();
|
const [resultingExam, setResultingExam] = useState<ReadingExam>();
|
||||||
|
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const parts = [part1, part2, part3].filter((x) => !!x);
|
const parts = [part1, part2, part3].filter((x) => !!x);
|
||||||
@@ -144,6 +163,7 @@ const ReadingGeneration = () => {
|
|||||||
id: v4(),
|
id: v4(),
|
||||||
type: "academic",
|
type: "academic",
|
||||||
variant: parts.length === 3 ? "full" : "partial",
|
variant: parts.length === 3 ? "full" : "partial",
|
||||||
|
difficulty,
|
||||||
};
|
};
|
||||||
|
|
||||||
axios
|
axios
|
||||||
@@ -157,6 +177,7 @@ const ReadingGeneration = () => {
|
|||||||
setPart1(undefined);
|
setPart1(undefined);
|
||||||
setPart2(undefined);
|
setPart2(undefined);
|
||||||
setPart3(undefined);
|
setPart3(undefined);
|
||||||
|
setDifficulty(sample(DIFFICULTIES)!);
|
||||||
setMinTimer(60);
|
setMinTimer(60);
|
||||||
setTypes([]);
|
setTypes([]);
|
||||||
})
|
})
|
||||||
@@ -169,6 +190,7 @@ const ReadingGeneration = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className="flex gap-4 w-1/2">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -179,6 +201,16 @@ const ReadingGeneration = () => {
|
|||||||
className="max-w-[300px]"
|
className="max-w-[300px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||||
|
<Select
|
||||||
|
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
|
||||||
|
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
|
||||||
|
value={{value: difficulty, label: capitalize(difficulty)}}
|
||||||
|
disabled={!!part1 || !!part2 || !!part3}
|
||||||
|
/>
|
||||||
|
</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">Exercises</label>
|
<label className="font-normal text-base text-mti-gray-dim">Exercises</label>
|
||||||
@@ -240,7 +272,7 @@ const ReadingGeneration = () => {
|
|||||||
{part: part2, setPart: setPart2},
|
{part: part2, setPart: setPart2},
|
||||||
{part: part3, setPart: setPart3},
|
{part: part3, setPart: setPart3},
|
||||||
].map(({part, setPart}, index) => (
|
].map(({part, setPart}, index) => (
|
||||||
<PartTab part={part} types={types} index={index + 1} key={index} setPart={setPart} />
|
<PartTab part={part} types={types} difficulty={difficulty} index={index + 1} key={index} setPart={setPart} />
|
||||||
))}
|
))}
|
||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import Select from "@/components/Low/Select";
|
import Select from "@/components/Low/Select";
|
||||||
import {Exercise, InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise} from "@/interfaces/exam";
|
import {Difficulty, Exercise, InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise} from "@/interfaces/exam";
|
||||||
import {AVATARS} from "@/resources/speakingAvatars";
|
import {AVATARS} from "@/resources/speakingAvatars";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {getExamById} from "@/utils/exams";
|
import {getExamById} from "@/utils/exams";
|
||||||
@@ -17,7 +17,19 @@ import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
|||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import {v4} from "uuid";
|
import {v4} from "uuid";
|
||||||
|
|
||||||
const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; setPart: (part?: SpeakingPart) => void}) => {
|
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
||||||
|
|
||||||
|
const PartTab = ({
|
||||||
|
part,
|
||||||
|
index,
|
||||||
|
difficulty,
|
||||||
|
setPart,
|
||||||
|
}: {
|
||||||
|
part?: SpeakingPart;
|
||||||
|
difficulty: Difficulty;
|
||||||
|
index: number;
|
||||||
|
setPart: (part?: SpeakingPart) => void;
|
||||||
|
}) => {
|
||||||
const [gender, setGender] = useState<"male" | "female">("male");
|
const [gender, setGender] = useState<"male" | "female">("male");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
@@ -25,8 +37,11 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
|
|||||||
setPart(undefined);
|
setPart(undefined);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const url = new URLSearchParams();
|
||||||
|
url.append("difficulty", difficulty);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.get(`/api/exam/speaking/generate/speaking_task_${index}`)
|
.get(`/api/exam/speaking/generate/speaking_task_${index}?${url.toString()}`)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
playSound(typeof result.data === "string" ? "error" : "check");
|
playSound(typeof result.data === "string" ? "error" : "check");
|
||||||
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
||||||
@@ -55,7 +70,7 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
|
|||||||
|
|
||||||
playSound(isError ? "error" : "check");
|
playSound(isError ? "error" : "check");
|
||||||
if (isError) return toast.error("Something went wrong, please try to generate the video again.");
|
if (isError) return toast.error("Something went wrong, please try to generate the video again.");
|
||||||
setPart({...part, result: result.data, gender, avatar});
|
setPart({...part, result: {...result.data, topic: part?.topic}, gender, avatar});
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast.error("Something went wrong!");
|
toast.error("Something went wrong!");
|
||||||
@@ -64,6 +79,8 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
|
|||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => console.log(part), [part]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
|
<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 flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
@@ -174,6 +191,7 @@ const SpeakingGeneration = () => {
|
|||||||
const [minTimer, setMinTimer] = useState(14);
|
const [minTimer, setMinTimer] = useState(14);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [resultingExam, setResultingExam] = useState<SpeakingExam>();
|
const [resultingExam, setResultingExam] = useState<SpeakingExam>();
|
||||||
|
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const parts = [part1, part2, part3].filter((x) => !!x);
|
const parts = [part1, part2, part3].filter((x) => !!x);
|
||||||
@@ -213,6 +231,7 @@ const SpeakingGeneration = () => {
|
|||||||
setPart1(undefined);
|
setPart1(undefined);
|
||||||
setPart2(undefined);
|
setPart2(undefined);
|
||||||
setPart3(undefined);
|
setPart3(undefined);
|
||||||
|
setDifficulty(sample(DIFFICULTIES)!);
|
||||||
setMinTimer(14);
|
setMinTimer(14);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -240,6 +259,7 @@ const SpeakingGeneration = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className="flex gap-4 w-1/2">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -250,6 +270,16 @@ const SpeakingGeneration = () => {
|
|||||||
className="max-w-[300px]"
|
className="max-w-[300px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||||
|
<Select
|
||||||
|
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
|
||||||
|
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
|
||||||
|
value={{value: difficulty, label: capitalize(difficulty)}}
|
||||||
|
disabled={!!part1 || !!part2 || !!part3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Tab.Group>
|
<Tab.Group>
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
||||||
@@ -293,7 +323,7 @@ const SpeakingGeneration = () => {
|
|||||||
{part: part2, setPart: setPart2},
|
{part: part2, setPart: setPart2},
|
||||||
{part: part3, setPart: setPart3},
|
{part: part3, setPart: setPart3},
|
||||||
].map(({part, setPart}, index) => (
|
].map(({part, setPart}, index) => (
|
||||||
<PartTab part={part} index={index + 1} key={index} setPart={setPart} />
|
<PartTab difficulty={difficulty} part={part} index={index + 1} key={index} setPart={setPart} />
|
||||||
))}
|
))}
|
||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
|
|||||||
@@ -1,24 +1,32 @@
|
|||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import {WritingExam, WritingExercise} from "@/interfaces/exam";
|
import Select from "@/components/Low/Select";
|
||||||
|
import {Difficulty, WritingExam, WritingExercise} from "@/interfaces/exam";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {getExamById} from "@/utils/exams";
|
import {getExamById} from "@/utils/exams";
|
||||||
import {playSound} from "@/utils/sound";
|
import {playSound} from "@/utils/sound";
|
||||||
import {Tab} from "@headlessui/react";
|
import {Tab} from "@headlessui/react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import {capitalize, sample} from "lodash";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import {v4} from "uuid";
|
import {v4} from "uuid";
|
||||||
|
|
||||||
const TaskTab = ({task, index, setTask}: {task?: string; index: number; setTask: (task: string) => void}) => {
|
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
||||||
|
|
||||||
|
const TaskTab = ({task, index, difficulty, setTask}: {task?: string; difficulty: Difficulty; index: number; setTask: (task: string) => void}) => {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const generate = () => {
|
const generate = () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const url = new URLSearchParams();
|
||||||
|
url.append("difficulty", difficulty);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.get(`/api/exam/writing/generate/writing_task${index}_general`)
|
.get(`/api/exam/writing/generate/writing_task${index}_general?${url.toString()}`)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
playSound(typeof result.data === "string" ? "error" : "check");
|
playSound(typeof result.data === "string" ? "error" : "check");
|
||||||
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
||||||
@@ -72,6 +80,7 @@ const WritingGeneration = () => {
|
|||||||
const [minTimer, setMinTimer] = useState(60);
|
const [minTimer, setMinTimer] = useState(60);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [resultingExam, setResultingExam] = useState<WritingExam>();
|
const [resultingExam, setResultingExam] = useState<WritingExam>();
|
||||||
|
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const task1Timer = task1 ? 20 : 0;
|
const task1Timer = task1 ? 20 : 0;
|
||||||
@@ -144,6 +153,7 @@ const WritingGeneration = () => {
|
|||||||
exercises: [...(exercise1 ? [exercise1] : []), ...(exercise2 ? [exercise2] : [])],
|
exercises: [...(exercise1 ? [exercise1] : []), ...(exercise2 ? [exercise2] : [])],
|
||||||
id: v4(),
|
id: v4(),
|
||||||
variant: exercise1 && exercise2 ? "full" : "partial",
|
variant: exercise1 && exercise2 ? "full" : "partial",
|
||||||
|
difficulty,
|
||||||
};
|
};
|
||||||
|
|
||||||
axios
|
axios
|
||||||
@@ -156,6 +166,7 @@ const WritingGeneration = () => {
|
|||||||
|
|
||||||
setTask1(undefined);
|
setTask1(undefined);
|
||||||
setTask2(undefined);
|
setTask2(undefined);
|
||||||
|
setDifficulty(sample(DIFFICULTIES)!);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
@@ -166,6 +177,7 @@ const WritingGeneration = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className="flex gap-4 w-1/2">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -176,6 +188,16 @@ const WritingGeneration = () => {
|
|||||||
className="max-w-[300px]"
|
className="max-w-[300px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||||
|
<Select
|
||||||
|
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
|
||||||
|
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
|
||||||
|
value={{value: difficulty, label: capitalize(difficulty)}}
|
||||||
|
disabled={!!task1 || !!task2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Tab.Group>
|
<Tab.Group>
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1">
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1">
|
||||||
@@ -207,7 +229,7 @@ const WritingGeneration = () => {
|
|||||||
{task: task1, setTask: setTask1},
|
{task: task1, setTask: setTask1},
|
||||||
{task: task2, setTask: setTask2},
|
{task: task2, setTask: setTask2},
|
||||||
].map(({task, setTask}, index) => (
|
].map(({task, setTask}, index) => (
|
||||||
<TaskTab task={task} index={index + 1} key={index} setTask={setTask} />
|
<TaskTab difficulty={difficulty} task={task} index={index + 1} key={index} setTask={setTask} />
|
||||||
))}
|
))}
|
||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { toast } from "react-toastify";
|
|||||||
import { KeyedMutator } from "swr";
|
import { KeyedMutator } from "swr";
|
||||||
import Select from "react-select";
|
import Select from "react-select";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
|
import useAcceptedTerms from "@/hooks/useAcceptedTerms";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -40,6 +41,7 @@ export default function RegisterCorporate({
|
|||||||
const [companyName, setCompanyName] = useState("");
|
const [companyName, setCompanyName] = useState("");
|
||||||
const [companyUsers, setCompanyUsers] = useState(0);
|
const [companyUsers, setCompanyUsers] = useState(0);
|
||||||
const [subscriptionDuration, setSubscriptionDuration] = useState(1);
|
const [subscriptionDuration, setSubscriptionDuration] = useState(1);
|
||||||
|
const {acceptedTerms, renderCheckbox} = useAcceptedTerms();
|
||||||
|
|
||||||
const { users } = useUsers();
|
const { users } = useUsers();
|
||||||
|
|
||||||
@@ -257,7 +259,9 @@ export default function RegisterCorporate({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex w-full flex-col items-start gap-4">
|
||||||
|
{renderCheckbox()}
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="w-full lg:mt-8"
|
className="w-full lg:mt-8"
|
||||||
color="purple"
|
color="purple"
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ 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";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { KeyedMutator } from "swr";
|
import { KeyedMutator } from "swr";
|
||||||
|
import useAcceptedTerms from "@/hooks/useAcceptedTerms";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
queryCode?: string;
|
queryCode?: string;
|
||||||
@@ -35,6 +36,7 @@ export default function RegisterIndividual({
|
|||||||
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 [hasCode, setHasCode] = useState<boolean>(!!queryCode);
|
||||||
|
const {acceptedTerms, renderCheckbox} = useAcceptedTerms();
|
||||||
|
|
||||||
const onSuccess = () =>
|
const onSuccess = () =>
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -146,7 +148,9 @@ export default function RegisterIndividual({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex w-full flex-col items-start gap-4">
|
||||||
|
{renderCheckbox()}
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="w-full lg:mt-8"
|
className="w-full lg:mt-8"
|
||||||
color="purple"
|
color="purple"
|
||||||
@@ -156,6 +160,7 @@ export default function RegisterIndividual({
|
|||||||
!name ||
|
!name ||
|
||||||
!password ||
|
!password ||
|
||||||
!confirmPassword ||
|
!confirmPassword ||
|
||||||
|
!acceptedTerms ||
|
||||||
password !== confirmPassword ||
|
password !== confirmPassword ||
|
||||||
(hasCode ? !code : false)
|
(hasCode ? !code : false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {BsArrowRepeat} from "react-icons/bs";
|
|||||||
import InviteCard from "@/components/Medium/InviteCard";
|
import InviteCard from "@/components/Medium/InviteCard";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {PayPalScriptProvider} from "@paypal/react-paypal-js";
|
import {PayPalScriptProvider} from "@paypal/react-paypal-js";
|
||||||
|
import { usePaypalTracking } from "@/hooks/usePaypalTracking";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -31,6 +32,7 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
|
|||||||
const {users} = useUsers();
|
const {users} = useUsers();
|
||||||
const {groups} = useGroups();
|
const {groups} = useGroups();
|
||||||
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user?.id});
|
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user?.id});
|
||||||
|
const trackingId = usePaypalTracking();
|
||||||
|
|
||||||
const isIndividual = () => {
|
const isIndividual = () => {
|
||||||
if (user?.type === "developer") return true;
|
if (user?.type === "developer") return true;
|
||||||
@@ -121,6 +123,7 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
|
|||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
setTimeout(reload, 500);
|
setTimeout(reload, 500);
|
||||||
}}
|
}}
|
||||||
|
trackingId={trackingId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-start gap-1">
|
<div className="flex flex-col items-start gap-1">
|
||||||
@@ -165,6 +168,7 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
|
|||||||
setTimeout(reload, 500);
|
setTimeout(reload, 500);
|
||||||
}}
|
}}
|
||||||
loadScript
|
loadScript
|
||||||
|
trackingId={trackingId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-start gap-1">
|
<div className="flex flex-col items-start gap-1">
|
||||||
|
|||||||
33
src/pages/api/assignments/[id]/archive.tsx
Normal file
33
src/pages/api/assignments/[id]/archive.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { app } from "@/firebase";
|
||||||
|
import { getFirestore, doc, getDoc, setDoc } from "firebase/firestore";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
|
||||||
|
const db = getFirestore(app);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
// verify if it's a logged user that is trying to archive
|
||||||
|
if (req.session.user) {
|
||||||
|
const { id } = req.query as { id: string };
|
||||||
|
const docSnap = await getDoc(doc(db, "assignments", id));
|
||||||
|
|
||||||
|
if (!docSnap.exists()) {
|
||||||
|
res.status(404).json({ ok: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await setDoc(docSnap.ref, { archived: true }, { merge: true });
|
||||||
|
res.status(200).json({ ok: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(401).json({ ok: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === "POST") return post(req, res);
|
||||||
|
res.status(404).json({ ok: false });
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import {getFirestore, collection, getDocs, query, where} from "firebase/firestor
|
|||||||
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 {Difficulty, Exam} from "@/interfaces/exam";
|
||||||
import {Stat} from "@/interfaces/user";
|
import {Stat} from "@/interfaces/user";
|
||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -25,10 +25,21 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
if (!req.session.user) return res.status(401).json({ok: false});
|
if (!req.session.user) return res.status(401).json({ok: false});
|
||||||
if (req.session.user.type !== "developer") return res.status(403).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 {endpoint, topic, exercises, difficulty} = req.query as {
|
||||||
|
module: Module;
|
||||||
|
endpoint: string;
|
||||||
|
topic?: string;
|
||||||
|
exercises?: string[];
|
||||||
|
difficulty?: Difficulty;
|
||||||
|
};
|
||||||
const url = `${process.env.BACKEND_URL}/${endpoint}`;
|
const url = `${process.env.BACKEND_URL}/${endpoint}`;
|
||||||
|
|
||||||
const result = await axios.get(`${url}${topic && exercises ? `?topic=${topic.toLowerCase()}&exercises=${exercises.join("&exercises=")}` : ""}`, {
|
const params = new URLSearchParams();
|
||||||
|
if (topic) params.append("topic", topic);
|
||||||
|
if (exercises) exercises.forEach((exercise) => params.append("exercises", exercise));
|
||||||
|
if (difficulty) params.append("difficulty", difficulty);
|
||||||
|
|
||||||
|
const result = await axios.get(`${url}${params.toString().length > 0 ? `?${params.toString()}` : ""}`, {
|
||||||
headers: {Authorization: `Bearer ${process.env.BACKEND_JWT}`},
|
headers: {Authorization: `Bearer ${process.env.BACKEND_JWT}`},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {withIronSessionApiRoute} from "iron-session/next";
|
|||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {Exam, InstructorGender, Variant} from "@/interfaces/exam";
|
import {Exam, InstructorGender, Variant} from "@/interfaces/exam";
|
||||||
import {getExams} from "@/utils/exams.be";
|
import {getExams} from "@/utils/exams.be";
|
||||||
|
import {Module} from "@/interfaces";
|
||||||
const db = getFirestore(app);
|
const db = getFirestore(app);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
@@ -24,7 +25,7 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const {module, avoidRepeated, variant, instructorGender} = req.query as {
|
const {module, avoidRepeated, variant, instructorGender} = req.query as {
|
||||||
module: string;
|
module: Module;
|
||||||
avoidRepeated: string;
|
avoidRepeated: string;
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
instructorGender?: InstructorGender;
|
instructorGender?: InstructorGender;
|
||||||
|
|||||||
30
src/pages/api/payments/paypal.ts
Normal file
30
src/pages/api/payments/paypal.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { app } from "@/firebase";
|
||||||
|
import {
|
||||||
|
getFirestore,
|
||||||
|
getDocs,
|
||||||
|
collection,
|
||||||
|
} from "firebase/firestore";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
|
||||||
|
const db = getFirestore(app);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const payments = await getDocs(collection(db, "paypalpayments"));
|
||||||
|
|
||||||
|
const data = payments.docs.map((doc) => doc.data());
|
||||||
|
res.status(200).json(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,30 +1,46 @@
|
|||||||
// 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, setDoc, doc} from "firebase/firestore";
|
import {
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
getFirestore,
|
||||||
import {sessionOptions} from "@/lib/session";
|
collection,
|
||||||
|
getDocs,
|
||||||
|
setDoc,
|
||||||
|
doc,
|
||||||
|
} from "firebase/firestore";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {DurationUnit, TokenError, TokenSuccess} from "@/interfaces/paypal";
|
import { DurationUnit, TokenError, TokenSuccess } from "@/interfaces/paypal";
|
||||||
import {base64} from "@firebase/util";
|
import { base64 } from "@firebase/util";
|
||||||
import {v4} from "uuid";
|
import { v4 } from "uuid";
|
||||||
import {OrderResponseBody} from "@paypal/paypal-js";
|
import { OrderResponseBody } from "@paypal/paypal-js";
|
||||||
import {getAccessToken} from "@/utils/paypal";
|
import { getAccessToken } from "@/utils/paypal";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {Group} from "@/interfaces/user";
|
import { Group } from "@/interfaces/user";
|
||||||
|
|
||||||
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 !== "POST") return res.status(404).json({ok: false, reason: "Method not supported!"});
|
if (req.method !== "POST")
|
||||||
if (!req.session.user) return res.status(401).json({ok: false});
|
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();
|
const accessToken = await getAccessToken();
|
||||||
if (!accessToken) return res.status(401).json({ok: false, reason: "Authorization failed!"});
|
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 { id, duration, duration_unit, trackingId } = req.body as {
|
||||||
|
id: string;
|
||||||
|
duration: number;
|
||||||
|
duration_unit: DurationUnit;
|
||||||
|
trackingId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!trackingId)
|
||||||
|
return res.status(401).json({ ok: false, reason: "Missing tracking id!" });
|
||||||
|
|
||||||
const request = await axios.post(
|
const request = await axios.post(
|
||||||
`${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders/${id}/capture`,
|
`${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders/${id}/capture`,
|
||||||
@@ -32,13 +48,15 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"PayPal-Client-Metadata-Id": trackingId,
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (request.data.status === "COMPLETED") {
|
if (request.data.status === "COMPLETED") {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
const subscriptionExpirationDate = req.session.user.subscriptionExpirationDate;
|
const subscriptionExpirationDate =
|
||||||
|
req.session.user.subscriptionExpirationDate;
|
||||||
const today = moment(new Date());
|
const today = moment(new Date());
|
||||||
const dateToBeAddedTo = !subscriptionExpirationDate
|
const dateToBeAddedTo = !subscriptionExpirationDate
|
||||||
? today
|
? today
|
||||||
@@ -49,10 +67,32 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const updatedExpirationDate = dateToBeAddedTo.add(duration, duration_unit);
|
const updatedExpirationDate = dateToBeAddedTo.add(duration, duration_unit);
|
||||||
await setDoc(
|
await setDoc(
|
||||||
doc(db, "users", req.session.user.id),
|
doc(db, "users", req.session.user.id),
|
||||||
{subscriptionExpirationDate: updatedExpirationDate.toISOString(), status: "active"},
|
{
|
||||||
{merge: true},
|
subscriptionExpirationDate: updatedExpirationDate.toISOString(),
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
{ merge: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setDoc(
|
||||||
|
doc(db, 'paypalpayments', v4()),
|
||||||
|
{
|
||||||
|
orderId: id,
|
||||||
|
userId: req.session.user.id,
|
||||||
|
status: request.data.status,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
value: request.data.purchase_units[0].payments.captures[0].amount.value,
|
||||||
|
currency: request.data.purchase_units[0].payments.captures[0].amount.currency_code,
|
||||||
|
subscriptionDuration: duration,
|
||||||
|
subscriptionDurationUnit: duration_unit,
|
||||||
|
subscriptionExpirationDate: updatedExpirationDate.toISOString(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch(err) {
|
||||||
|
console.error('Failed to insert paypal payment!', err);
|
||||||
|
}
|
||||||
|
|
||||||
if (user.type === "corporate") {
|
if (user.type === "corporate") {
|
||||||
const snapshot = await getDocs(collection(db, "groups"));
|
const snapshot = await getDocs(collection(db, "groups"));
|
||||||
const groups: Group[] = (
|
const groups: Group[] = (
|
||||||
@@ -69,15 +109,24 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
async (x) =>
|
async (x) =>
|
||||||
await setDoc(
|
await setDoc(
|
||||||
doc(db, "users", x),
|
doc(db, "users", x),
|
||||||
{subscriptionExpirationDate: updatedExpirationDate.toISOString(), status: "active"},
|
{
|
||||||
{merge: true},
|
subscriptionExpirationDate:
|
||||||
),
|
updatedExpirationDate.toISOString(),
|
||||||
),
|
status: "active",
|
||||||
|
},
|
||||||
|
{ merge: true }
|
||||||
|
)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(200).json({ok: true});
|
return res.status(200).json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(404).json({ok: false, reason: "Order ID not found or purchase was not approved!"});
|
res
|
||||||
|
.status(404)
|
||||||
|
.json({
|
||||||
|
ok: false,
|
||||||
|
reason: "Order ID not found or purchase was not approved!",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const accessToken = await getAccessToken();
|
const accessToken = await getAccessToken();
|
||||||
if (!accessToken) return res.status(401).json({ok: false, reason: "Authorization failed!"});
|
if (!accessToken) return res.status(401).json({ok: false, reason: "Authorization failed!"});
|
||||||
|
|
||||||
const {currencyCode, price} = req.body as {currencyCode: string; price: number};
|
const {currencyCode, price, trackingId} = req.body as {currencyCode: string; price: number, trackingId: string};
|
||||||
|
|
||||||
|
if(!trackingId) return res.status(401).json({ok: false, reason: "Missing tracking id!"});
|
||||||
|
|
||||||
const request = await axios.post<OrderResponseBody>(
|
const request = await axios.post<OrderResponseBody>(
|
||||||
`${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders`,
|
`${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders`,
|
||||||
@@ -34,11 +36,24 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
reference_id: v4(),
|
reference_id: v4(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
payment_source: {
|
||||||
|
paypal: {
|
||||||
|
email_address: req.session.user.email || "",
|
||||||
|
experience_context: {
|
||||||
|
payment_method_preference: "IMMEDIATE_PAYMENT_REQUIRED",
|
||||||
|
locale: "en-US",
|
||||||
|
landing_page: "LOGIN",
|
||||||
|
shipping_preference: "NO_SHIPPING",
|
||||||
|
user_action: "PAY_NOW",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
intent: "CAPTURE",
|
intent: "CAPTURE",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'PayPal-Client-Metadata-Id': trackingId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
55
src/pages/api/paypal/raas.ts
Normal file
55
src/pages/api/paypal/raas.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// 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 !== "PUT")
|
||||||
|
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 trackingId = `${req.session.user.id}-${Date.now()}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = await axios.put(
|
||||||
|
`${process.env.PAYPAL_ACCESS_TOKEN_URL}/v1/risk/transaction-contexts/${process.env.PAYPAL_MERCHANT_ID}/${trackingId}`,
|
||||||
|
{
|
||||||
|
additional_data: [
|
||||||
|
{
|
||||||
|
key: "user_id",
|
||||||
|
value: req.session.user.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.status(request.status).json({
|
||||||
|
ok: true,
|
||||||
|
trackingId,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ ok: false, reason: "Failed to create tracking ID" });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {Stat, User} from "@/interfaces/user";
|
|||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {calculateBandScore} from "@/utils/score";
|
import {calculateBandScore} from "@/utils/score";
|
||||||
import {groupByModule, groupBySession} from "@/utils/stats";
|
import {groupByModule, groupBySession} from "@/utils/stats";
|
||||||
|
import { MODULE_ARRAY } from "@/utils/moduleUtils";
|
||||||
import {getAuth} from "firebase/auth";
|
import {getAuth} from "firebase/auth";
|
||||||
import {collection, doc, getDoc, getDocs, getFirestore, query, updateDoc, where} from "firebase/firestore";
|
import {collection, doc, getDoc, getDocs, getFirestore, query, updateDoc, where} from "firebase/firestore";
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
@@ -55,7 +56,7 @@ async function update(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
MODULES.forEach((module: Module) => {
|
MODULE_ARRAY.forEach((module: Module) => {
|
||||||
const moduleStats = sessionStats.filter((x) => x.module === module);
|
const moduleStats = sessionStats.filter((x) => x.module === module);
|
||||||
if (moduleStats.length === 0) return;
|
if (moduleStats.length === 0) return;
|
||||||
|
|
||||||
@@ -87,11 +88,18 @@ async function update(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
.filter((x) => x.total > 0)
|
.filter((x) => x.total > 0)
|
||||||
.reduce((acc, cur) => ({total: acc.total + cur.total, correct: acc.correct + cur.correct}), {total: 0, correct: 0});
|
.reduce((acc, cur) => ({total: acc.total + cur.total, correct: acc.correct + cur.correct}), {total: 0, correct: 0});
|
||||||
|
|
||||||
|
const levelLevel = sessionLevels
|
||||||
|
.map((x) => x.level)
|
||||||
|
.filter((x) => x.total > 0)
|
||||||
|
.reduce((acc, cur) => ({total: acc.total + cur.total, correct: acc.correct + cur.correct}), {total: 0, correct: 0});
|
||||||
|
|
||||||
|
|
||||||
const levels = {
|
const levels = {
|
||||||
reading: calculateBandScore(readingLevel.correct, readingLevel.total, "reading", req.session.user.focus),
|
reading: calculateBandScore(readingLevel.correct, readingLevel.total, "reading", req.session.user.focus),
|
||||||
listening: calculateBandScore(listeningLevel.correct, listeningLevel.total, "listening", req.session.user.focus),
|
listening: calculateBandScore(listeningLevel.correct, listeningLevel.total, "listening", req.session.user.focus),
|
||||||
writing: calculateBandScore(writingLevel.correct, writingLevel.total, "writing", req.session.user.focus),
|
writing: calculateBandScore(writingLevel.correct, writingLevel.total, "writing", req.session.user.focus),
|
||||||
speaking: calculateBandScore(speakingLevel.correct, speakingLevel.total, "speaking", req.session.user.focus),
|
speaking: calculateBandScore(speakingLevel.correct, speakingLevel.total, "speaking", req.session.user.focus),
|
||||||
|
level: calculateBandScore(levelLevel.correct, levelLevel.total, "level", req.session.user.focus),
|
||||||
};
|
};
|
||||||
|
|
||||||
const userDoc = doc(db, "users", req.session.user.id);
|
const userDoc = doc(db, "users", req.session.user.id);
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const audioFile = files.audio;
|
const audioFile = files.audio;
|
||||||
const audioFileRef = ref(storage, `${fields.root}/${(audioFile as any).path.split("/").pop()!.replace("upload_", "")}`);
|
const audioFileRef = ref(storage, `${fields.root}/${(audioFile as any).path.replace("upload_", "")}`);
|
||||||
|
|
||||||
const binary = fs.readFileSync((audioFile as any).path).buffer;
|
const binary = fs.readFileSync((audioFile as any).path).buffer;
|
||||||
const snapshot = await uploadBytes(audioFileRef, binary);
|
const snapshot = await uploadBytes(audioFileRef, binary);
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import {
|
|||||||
} from "firebase/firestore";
|
} 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 { Ticket } from "@/interfaces/ticket";
|
import { Ticket, TicketTypeLabel, TicketStatusLabel } from "@/interfaces/ticket";
|
||||||
|
import moment from "moment";
|
||||||
|
import { sendEmail } from "@/email";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = getFirestore(app);
|
||||||
|
|
||||||
@@ -69,12 +71,38 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { id } = req.query as { id: string };
|
const { id } = req.query as { id: string };
|
||||||
|
const body = req.body as Ticket;
|
||||||
|
|
||||||
const snapshot = await getDoc(doc(db, "tickets", id));
|
const snapshot = await getDoc(doc(db, "tickets", id));
|
||||||
|
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
if (user.type === "admin" || user.type === "developer") {
|
if (user.type === "admin" || user.type === "developer") {
|
||||||
await setDoc(snapshot.ref, req.body, { merge: true });
|
const data = snapshot.data() as Ticket;
|
||||||
return res.status(200).json({ ok: true });
|
await setDoc(snapshot.ref, body, { merge: true });
|
||||||
|
try {
|
||||||
|
// send email if the status actually changed to completed
|
||||||
|
if(data.status !== req.body.status && req.body.status === 'completed') {
|
||||||
|
await sendEmail(
|
||||||
|
"ticketStatusCompleted",
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
subject: body.subject,
|
||||||
|
reporter: body.reporter,
|
||||||
|
date: moment(body.date).format("DD/MM/YYYY - HH:mm"),
|
||||||
|
type: TicketTypeLabel[body.type],
|
||||||
|
reportedFrom: body.reportedFrom,
|
||||||
|
description: body.description,
|
||||||
|
},
|
||||||
|
[data.reporter.email],
|
||||||
|
`Ticket ${id}: ${data.subject}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch(err) {
|
||||||
|
console.error(err);
|
||||||
|
// doesnt matter if the email fails
|
||||||
|
}
|
||||||
|
res.status(200).json({ ok: true });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(403).json({ ok: false });
|
res.status(403).json({ ok: false });
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// 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 { sendEmail } from "@/email";
|
import { sendEmail } from "@/email";
|
||||||
import { app } from "@/firebase";
|
import { app } from "@/firebase";
|
||||||
import { Ticket, TicketTypeLabel } from "@/interfaces/ticket";
|
import { Ticket, TicketTypeLabel, TicketWithCorporate } from "@/interfaces/ticket";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import {
|
import {
|
||||||
collection,
|
collection,
|
||||||
@@ -9,35 +9,76 @@ import {
|
|||||||
getDocs,
|
getDocs,
|
||||||
getFirestore,
|
getFirestore,
|
||||||
setDoc,
|
setDoc,
|
||||||
|
where,
|
||||||
|
query,
|
||||||
} from "firebase/firestore";
|
} from "firebase/firestore";
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import ShortUniqueId from "short-unique-id";
|
import ShortUniqueId from "short-unique-id";
|
||||||
|
import { Group, CorporateUser } from "@/interfaces/user";
|
||||||
|
|
||||||
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) {
|
||||||
|
// due to integration with the homepage the POST request should be public
|
||||||
|
if (req.method === "POST") {
|
||||||
|
await post(req, res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// specific logic for the preflight request
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
res.status(200).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ ok: false });
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "GET") await get(req, res);
|
if (req.method === "GET") {
|
||||||
if (req.method === "POST") await post(req, res);
|
await get(req, res);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const snapshot = await getDocs(collection(db, "tickets"));
|
const snapshot = await getDocs(collection(db, "tickets"));
|
||||||
|
|
||||||
res.status(200).json(
|
const docs = snapshot.docs.map((doc) => ({
|
||||||
snapshot.docs.map((doc) => ({
|
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
...doc.data(),
|
...doc.data(),
|
||||||
})),
|
})) as Ticket[];
|
||||||
);
|
|
||||||
|
// fetch all groups for these users
|
||||||
|
|
||||||
|
const reporters = [...new Set(docs.map((d) => d.reporter.id).filter((id) => id))];
|
||||||
|
|
||||||
|
const groupsSnapshot = await getDocs(query(collection(db, "groups"), where("participants", "array-contains-any", reporters)));
|
||||||
|
const groups = groupsSnapshot.docs.map((doc) => doc.data()) as Group[];
|
||||||
|
|
||||||
|
// based on the admin of each group, verify if it exists and it's of type corporate
|
||||||
|
const groupsAdmins = [...new Set(groups.map((g) => g.admin).filter((id) => id))];
|
||||||
|
const adminsSnapshot = await getDocs(query(collection(db, "users"), where("id", "in", groupsAdmins), where("type", "==", "corporate")));
|
||||||
|
const admins = adminsSnapshot.docs.map((doc) => doc.data());
|
||||||
|
|
||||||
|
const docsWithAdmins = docs.map((d) => {
|
||||||
|
const group = groups.find((g) => g.participants.includes(d.reporter.id));
|
||||||
|
const admin = admins.find((a) => a.id === group?.admin) as CorporateUser;
|
||||||
|
|
||||||
|
if(admin) {
|
||||||
|
return {
|
||||||
|
...d,
|
||||||
|
corporate: admin.corporateInformation?.companyInformation?.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return d;
|
||||||
|
}) as TicketWithCorporate[];
|
||||||
|
|
||||||
|
res.status(200).json(docsWithAdmins);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
@@ -61,7 +102,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
description: body.description,
|
description: body.description,
|
||||||
},
|
},
|
||||||
[body.reporter.email],
|
[body.reporter.email],
|
||||||
`Ticket ${id}: ${body.subject}`,
|
`Ticket ${id}: ${body.subject}`
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.log(e);
|
||||||
|
|||||||
57
src/pages/api/users/agents/[code].ts
Normal file
57
src/pages/api/users/agents/[code].ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { app, adminApp } from "@/firebase";
|
||||||
|
import { AgentUser } from "@/interfaces/user";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import {
|
||||||
|
collection,
|
||||||
|
getDocs,
|
||||||
|
getFirestore,
|
||||||
|
query,
|
||||||
|
where,
|
||||||
|
} from "firebase/firestore";
|
||||||
|
import { getAuth } from "firebase-admin/auth";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import countryCodes from "country-codes-list";
|
||||||
|
const db = getFirestore(app);
|
||||||
|
const auth = getAuth(adminApp);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(user, sessionOptions);
|
||||||
|
|
||||||
|
interface Contact {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
number: string;
|
||||||
|
}
|
||||||
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const { code, language = 'en' } = req.query as { code: string, language: string};
|
||||||
|
|
||||||
|
const usersQuery = query(
|
||||||
|
collection(db, "users"),
|
||||||
|
where("type", "==", "agent"),
|
||||||
|
where("demographicInformation.country", "==", code)
|
||||||
|
);
|
||||||
|
const docsUser = await getDocs(usersQuery);
|
||||||
|
|
||||||
|
const docs = docsUser.docs.map((doc) => doc.data() as AgentUser);
|
||||||
|
|
||||||
|
const entries = docs.map((user: AgentUser) => {
|
||||||
|
const newUser = {
|
||||||
|
name: user.agentInformation.companyName,
|
||||||
|
email: user.email,
|
||||||
|
number: user.demographicInformation?.phone as string,
|
||||||
|
} as Contact;
|
||||||
|
return newUser;
|
||||||
|
}) as Contact[];
|
||||||
|
|
||||||
|
const country = countryCodes.findOne("countryCode" as any, code.toUpperCase());
|
||||||
|
const key = language === 'ar' ? 'countryNameLocal' : 'countryNameEn';
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
label: country[key],
|
||||||
|
entries,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function user(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === "GET") return get(req, res);
|
||||||
|
}
|
||||||
68
src/pages/api/users/agents/index.ts
Normal file
68
src/pages/api/users/agents/index.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { app, adminApp } from "@/firebase";
|
||||||
|
import { AgentUser } from "@/interfaces/user";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import {
|
||||||
|
collection,
|
||||||
|
getDocs,
|
||||||
|
getFirestore,
|
||||||
|
query,
|
||||||
|
where,
|
||||||
|
} from "firebase/firestore";
|
||||||
|
import { getAuth } from "firebase-admin/auth";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import countryCodes from "country-codes-list";
|
||||||
|
const db = getFirestore(app);
|
||||||
|
const auth = getAuth(adminApp);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(user, sessionOptions);
|
||||||
|
|
||||||
|
interface Contact {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
number: string;
|
||||||
|
}
|
||||||
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const { language = 'en' } = req.query as { language: string };
|
||||||
|
|
||||||
|
const usersQuery = query(
|
||||||
|
collection(db, "users"),
|
||||||
|
where("type", "==", "agent")
|
||||||
|
);
|
||||||
|
const docsUser = await getDocs(usersQuery);
|
||||||
|
|
||||||
|
const docs = docsUser.docs.map((doc) => doc.data() as AgentUser);
|
||||||
|
|
||||||
|
const data = docs.reduce(
|
||||||
|
(acc: Record<string, Contact[]>, user: AgentUser) => {
|
||||||
|
const countryCode = user.demographicInformation?.country as string;
|
||||||
|
const currentValues = acc[countryCode] || ([] as Contact[]);
|
||||||
|
const newUser = {
|
||||||
|
name: user.agentInformation.companyName,
|
||||||
|
email: user.email,
|
||||||
|
number: user.demographicInformation?.phone as string,
|
||||||
|
} as Contact;
|
||||||
|
return {
|
||||||
|
...acc,
|
||||||
|
[countryCode]: [...currentValues, newUser],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
) as Record<string, Contact[]>;
|
||||||
|
|
||||||
|
const result = Object.keys(data).map((code) => {
|
||||||
|
const country = countryCodes.findOne("countryCode" as any, code.toUpperCase());
|
||||||
|
const key = language === 'ar' ? 'countryNameLocal' : 'countryNameEn';
|
||||||
|
return {
|
||||||
|
label: country[key],
|
||||||
|
key: code,
|
||||||
|
entries: data[code],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function user(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === "GET") return get(req, res);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,7 @@ import {CorporateUser, EmploymentStatus, EMPLOYMENT_STATUS, Gender, User} from "
|
|||||||
import CountrySelect from "@/components/Low/CountrySelect";
|
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} from "react-icons/bs";
|
import {BsCamera, BsQuestionCircleFill} from "react-icons/bs";
|
||||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
@@ -31,6 +31,8 @@ import ModuleLevelSelector from "@/components/Medium/ModuleLevelSelector";
|
|||||||
import Select from "@/components/Low/Select";
|
import Select from "@/components/Low/Select";
|
||||||
import {InstructorGender} from "@/interfaces/exam";
|
import {InstructorGender} from "@/interfaces/exam";
|
||||||
import {capitalize} from "lodash";
|
import {capitalize} from "lodash";
|
||||||
|
import TopicModal from "@/components/Medium/TopicModal";
|
||||||
|
import {v4} from "uuid";
|
||||||
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
|
|
||||||
@@ -90,6 +92,9 @@ function UserProfile({user, mutateUser}: Props) {
|
|||||||
const [preferredGender, setPreferredGender] = useState<InstructorGender | undefined>(
|
const [preferredGender, setPreferredGender] = useState<InstructorGender | undefined>(
|
||||||
user.type === "student" || user.type === "developer" ? user.preferredGender || "varied" : undefined,
|
user.type === "student" || user.type === "developer" ? user.preferredGender || "varied" : undefined,
|
||||||
);
|
);
|
||||||
|
const [preferredTopics, setPreferredTopics] = useState<string[] | undefined>(
|
||||||
|
user.type === "student" || user.type === "developer" ? user.preferredTopics : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
const [position, setPosition] = useState<string | undefined>(user.type === "corporate" ? user.demographicInformation?.position : undefined);
|
const [position, setPosition] = useState<string | undefined>(user.type === "corporate" ? user.demographicInformation?.position : undefined);
|
||||||
const [corporateInformation, setCorporateInformation] = useState(user.type === "corporate" ? user.corporateInformation : undefined);
|
const [corporateInformation, setCorporateInformation] = useState(user.type === "corporate" ? user.corporateInformation : undefined);
|
||||||
@@ -99,6 +104,8 @@ function UserProfile({user, mutateUser}: Props) {
|
|||||||
);
|
);
|
||||||
const [timezone, setTimezone] = useState<string>(user.demographicInformation?.timezone || moment.tz.guess());
|
const [timezone, setTimezone] = useState<string>(user.demographicInformation?.timezone || moment.tz.guess());
|
||||||
|
|
||||||
|
const [isPreferredTopicsOpen, setIsPreferredTopicsOpen] = useState(false);
|
||||||
|
|
||||||
const {groups} = useGroups();
|
const {groups} = useGroups();
|
||||||
const {users} = useUsers();
|
const {users} = useUsers();
|
||||||
|
|
||||||
@@ -156,6 +163,7 @@ function UserProfile({user, mutateUser}: Props) {
|
|||||||
profilePicture,
|
profilePicture,
|
||||||
desiredLevels,
|
desiredLevels,
|
||||||
preferredGender,
|
preferredGender,
|
||||||
|
preferredTopics,
|
||||||
demographicInformation: {
|
demographicInformation: {
|
||||||
phone,
|
phone,
|
||||||
country,
|
country,
|
||||||
@@ -350,6 +358,7 @@ function UserProfile({user, mutateUser}: Props) {
|
|||||||
{preferredGender && ["developer", "student"].includes(user.type) && (
|
{preferredGender && ["developer", "student"].includes(user.type) && (
|
||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
<DoubleColumnRow>
|
||||||
<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">Speaking Instructor's Gender</label>
|
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor's Gender</label>
|
||||||
<Select
|
<Select
|
||||||
@@ -362,6 +371,28 @@ function UserProfile({user, mutateUser}: Props) {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-4 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim flex gap-2 items-center">
|
||||||
|
Preferred Topics{" "}
|
||||||
|
<span
|
||||||
|
className="tooltip"
|
||||||
|
data-tip="These topics will be considered for speaking and writing modules, aiming to include at least one exercise containing of the these in the selected exams.">
|
||||||
|
<BsQuestionCircleFill />
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<Button className="w-full" variant="outline" onClick={() => setIsPreferredTopicsOpen(true)}>
|
||||||
|
Select Topics ({preferredTopics?.length || "All"} selected)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DoubleColumnRow>
|
||||||
|
|
||||||
|
<TopicModal
|
||||||
|
key={v4()}
|
||||||
|
isOpen={isPreferredTopicsOpen}
|
||||||
|
onClose={() => setIsPreferredTopicsOpen(false)}
|
||||||
|
selectTopics={setPreferredTopics}
|
||||||
|
initialTopics={preferredTopics || []}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
TicketStatusLabel,
|
TicketStatusLabel,
|
||||||
TicketType,
|
TicketType,
|
||||||
TicketTypeLabel,
|
TicketTypeLabel,
|
||||||
|
TicketWithCorporate,
|
||||||
} from "@/interfaces/ticket";
|
} from "@/interfaces/ticket";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||||
@@ -28,7 +29,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { BsArrowDown, BsArrowUp } from "react-icons/bs";
|
import { BsArrowDown, BsArrowUp } from "react-icons/bs";
|
||||||
import { ToastContainer } from "react-toastify";
|
import { ToastContainer } from "react-toastify";
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<Ticket>();
|
const columnHelper = createColumnHelper<TicketWithCorporate>();
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(({ req, res }) => {
|
export const getServerSideProps = withIronSessionSsr(({ req, res }) => {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
@@ -75,10 +76,26 @@ const TypesClassNames: { [key in TicketType]: string } = {
|
|||||||
help: "bg-mti-blue-light",
|
help: "bg-mti-blue-light",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const escapedURL = process.env.NEXT_PUBLIC_WEBSITE_URL || ''.replace(
|
||||||
|
/[.*+?^${}()|[\]\\]/g,
|
||||||
|
"\\$&"
|
||||||
|
);
|
||||||
|
const fromHomepage = [new RegExp(`^${escapedURL}`), /\/contact$/];
|
||||||
|
|
||||||
|
type Source = "webpage" | "platform" | "";
|
||||||
|
|
||||||
|
const SOURCE_OPTIONS = [
|
||||||
|
{ value: "", label: "All" },
|
||||||
|
{ value: "webpage", label: "Webpage" },
|
||||||
|
{ value: "platform", label: "Platform" },
|
||||||
|
]
|
||||||
|
|
||||||
export default function Tickets() {
|
export default function Tickets() {
|
||||||
const [filteredTickets, setFilteredTickets] = useState<Ticket[]>([]);
|
const [filteredTickets, setFilteredTickets] = useState<Ticket[]>([]);
|
||||||
const [selectedTicket, setSelectedTicket] = useState<Ticket>();
|
const [selectedTicket, setSelectedTicket] = useState<Ticket>();
|
||||||
const [assigneeFilter, setAssigneeFilter] = useState<string>();
|
const [assigneeFilter, setAssigneeFilter] = useState<string>();
|
||||||
|
const [sourceFilter, setSourceFilter] = useState<Source>("");
|
||||||
|
|
||||||
const [dateSorting, setDateSorting] = useState<"asc" | "desc">("desc");
|
const [dateSorting, setDateSorting] = useState<"asc" | "desc">("desc");
|
||||||
|
|
||||||
const [typeFilter, setTypeFilter] = useState<TicketType>();
|
const [typeFilter, setTypeFilter] = useState<TicketType>();
|
||||||
@@ -90,7 +107,7 @@ export default function Tickets() {
|
|||||||
|
|
||||||
const sortByDate = (a: Ticket, b: Ticket) => {
|
const sortByDate = (a: Ticket, b: Ticket) => {
|
||||||
return moment((dateSorting === "desc" ? b : a).date).diff(
|
return moment((dateSorting === "desc" ? b : a).date).diff(
|
||||||
moment((dateSorting === "desc" ? a : b).date),
|
moment((dateSorting === "desc" ? a : b).date)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -102,11 +119,16 @@ export default function Tickets() {
|
|||||||
if (statusFilter) filters.push((x: Ticket) => x.status === statusFilter);
|
if (statusFilter) filters.push((x: Ticket) => x.status === statusFilter);
|
||||||
if (assigneeFilter)
|
if (assigneeFilter)
|
||||||
filters.push((x: Ticket) => x.assignedTo === assigneeFilter);
|
filters.push((x: Ticket) => x.assignedTo === assigneeFilter);
|
||||||
|
if (sourceFilter) {
|
||||||
|
if (sourceFilter === "webpage")
|
||||||
|
filters.push((x: Ticket) => fromHomepage.some((r) => r.test(x.reportedFrom)));
|
||||||
|
if (sourceFilter === "platform")
|
||||||
|
filters.push((x: Ticket) => !fromHomepage.some((r) => r.test(x.reportedFrom)));
|
||||||
|
}
|
||||||
setFilteredTickets(
|
setFilteredTickets(
|
||||||
[...filters.reduce((d, f) => d.filter(f), tickets)].sort(sortByDate),
|
[...filters.reduce((d, f) => d.filter(f), tickets)].sort(sortByDate)
|
||||||
);
|
);
|
||||||
}, [tickets, typeFilter, statusFilter, assigneeFilter, dateSorting, user]);
|
}, [tickets, typeFilter, statusFilter, assigneeFilter, dateSorting, user, sourceFilter]);
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
columnHelper.accessor("id", {
|
columnHelper.accessor("id", {
|
||||||
@@ -119,7 +141,7 @@ export default function Tickets() {
|
|||||||
<span
|
<span
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"rounded-lg p-1 px-2 text-white",
|
"rounded-lg p-1 px-2 text-white",
|
||||||
TypesClassNames[info.getValue()],
|
TypesClassNames[info.getValue()]
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{TicketTypeLabel[info.getValue()]}
|
{TicketTypeLabel[info.getValue()]}
|
||||||
@@ -148,7 +170,9 @@ export default function Tickets() {
|
|||||||
{dateSorting === "asc" && <BsArrowUp />}
|
{dateSorting === "asc" && <BsArrowUp />}
|
||||||
</button>
|
</button>
|
||||||
) as any,
|
) as any,
|
||||||
cell: (info) => moment(info.getValue()).format("DD/MM/YYYY - HH:mm"),
|
cell: (info) => (
|
||||||
|
<span className="whitespace-nowrap">{moment(info.getValue()).format("DD/MM/YYYY - HH:mm")}</span>
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("subject", {
|
columnHelper.accessor("subject", {
|
||||||
header: "Subject",
|
header: "Subject",
|
||||||
@@ -162,7 +186,7 @@ export default function Tickets() {
|
|||||||
<span
|
<span
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"rounded-lg p-1 px-2 text-white",
|
"rounded-lg p-1 px-2 text-white",
|
||||||
StatusClassNames[info.getValue()],
|
StatusClassNames[info.getValue()]
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{TicketStatusLabel[info.getValue()]}
|
{TicketStatusLabel[info.getValue()]}
|
||||||
@@ -173,6 +197,10 @@ export default function Tickets() {
|
|||||||
header: "Assignee",
|
header: "Assignee",
|
||||||
cell: (info) => users.find((x) => x.id === info.getValue())?.name || "",
|
cell: (info) => users.find((x) => x.id === info.getValue())?.name || "",
|
||||||
}),
|
}),
|
||||||
|
columnHelper.accessor("corporate", {
|
||||||
|
header: "Corporate",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
const getAssigneeValue = () => {
|
const getAssigneeValue = () => {
|
||||||
@@ -288,7 +316,7 @@ export default function Tickets() {
|
|||||||
{ value: "me", label: "Assigned to me" },
|
{ value: "me", label: "Assigned to me" },
|
||||||
...users
|
...users
|
||||||
.filter((x) =>
|
.filter((x) =>
|
||||||
["admin", "developer", "agent"].includes(x.type),
|
["admin", "developer", "agent"].includes(x.type)
|
||||||
)
|
)
|
||||||
.map((u) => ({
|
.map((u) => ({
|
||||||
value: u.id,
|
value: u.id,
|
||||||
@@ -300,7 +328,7 @@ export default function Tickets() {
|
|||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
value
|
value
|
||||||
? setAssigneeFilter(
|
? setAssigneeFilter(
|
||||||
value.value === "me" ? user.id : value.value,
|
value.value === "me" ? user.id : value.value
|
||||||
)
|
)
|
||||||
: setAssigneeFilter(undefined)
|
: setAssigneeFilter(undefined)
|
||||||
}
|
}
|
||||||
@@ -308,6 +336,18 @@ export default function Tickets() {
|
|||||||
isClearable
|
isClearable
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex w-full flex-col gap-3">
|
||||||
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
|
Source
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
options={SOURCE_OPTIONS}
|
||||||
|
disabled={user.type === "agent"}
|
||||||
|
value={SOURCE_OPTIONS.find((x) => x.value === sourceFilter)}
|
||||||
|
onChange={(value) => setSourceFilter(value?.value as Source)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table className="bg-mti-purple-ultralight/40 w-full rounded-xl">
|
<table className="bg-mti-purple-ultralight/40 w-full rounded-xl">
|
||||||
@@ -320,7 +360,7 @@ export default function Tickets() {
|
|||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header.column.columnDef.header,
|
header.column.columnDef.header,
|
||||||
header.getContext(),
|
header.getContext()
|
||||||
)}
|
)}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
@@ -332,7 +372,7 @@ export default function Tickets() {
|
|||||||
<tr
|
<tr
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"even:bg-mti-purple-ultralight/40 hover:bg-mti-purple-ultralight cursor-pointer rounded-lg py-2 odd:bg-white",
|
"even:bg-mti-purple-ultralight/40 hover:bg-mti-purple-ultralight cursor-pointer rounded-lg py-2 odd:bg-white",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out"
|
||||||
)}
|
)}
|
||||||
onClick={() => setSelectedTicket(row.original)}
|
onClick={() => setSelectedTicket(row.original)}
|
||||||
key={row.id}
|
key={row.id}
|
||||||
@@ -341,7 +381,7 @@ export default function Tickets() {
|
|||||||
<td className="w-fit items-center px-4 py-2" key={cell.id}>
|
<td className="w-fit items-center px-4 py-2" key={cell.id}>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
cell.column.columnDef.cell,
|
cell.column.columnDef.cell,
|
||||||
cell.getContext(),
|
cell.getContext()
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
|
|||||||
55
src/resources/topics.ts
Normal file
55
src/resources/topics.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
const topics = [
|
||||||
|
"Education",
|
||||||
|
"Technology",
|
||||||
|
"Environment",
|
||||||
|
"Health and Fitness",
|
||||||
|
"Globalization",
|
||||||
|
"Engineering",
|
||||||
|
"Work and Careers",
|
||||||
|
"Travel and Tourism",
|
||||||
|
"Culture and Traditions",
|
||||||
|
"Social Issues",
|
||||||
|
"Arts and Entertainment",
|
||||||
|
"Climate Change",
|
||||||
|
"Social Media",
|
||||||
|
"Sustainable Development",
|
||||||
|
"Health Care",
|
||||||
|
"Immigration",
|
||||||
|
"Artificial Intelligence",
|
||||||
|
"Consumerism",
|
||||||
|
"Online Shopping",
|
||||||
|
"Energy",
|
||||||
|
"Oil and Gas",
|
||||||
|
"Poverty and Inequality",
|
||||||
|
"Cultural Diversity",
|
||||||
|
"Democracy and Governance",
|
||||||
|
"Mental Health",
|
||||||
|
"Ethics and Morality",
|
||||||
|
"Population Growth",
|
||||||
|
"Science and Innovation",
|
||||||
|
"Poverty Alleviation",
|
||||||
|
"Cybersecurity and Privacy",
|
||||||
|
"Human Rights",
|
||||||
|
"Social Justice",
|
||||||
|
"Food and Agriculture",
|
||||||
|
"Cyberbullying and Online Safety",
|
||||||
|
"Linguistic Diversity",
|
||||||
|
"Urbanization",
|
||||||
|
"Artificial Intelligence in Education",
|
||||||
|
"Youth Empowerment",
|
||||||
|
"Disaster Management",
|
||||||
|
"Mental Health Stigma",
|
||||||
|
"Internet Censorship",
|
||||||
|
"Sustainable Fashion",
|
||||||
|
"Indigenous Rights",
|
||||||
|
"Water Scarcity",
|
||||||
|
"Social Entrepreneurship",
|
||||||
|
"Privacy in the Digital Age",
|
||||||
|
"Sustainable Transportation",
|
||||||
|
"Gender Equality",
|
||||||
|
"Automation and Job Displacement",
|
||||||
|
"Digital Divide",
|
||||||
|
"Education Inequality",
|
||||||
|
];
|
||||||
|
|
||||||
|
export default topics;
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import {collection, getDocs, query, where, setDoc, doc, Firestore} from "firebase/firestore";
|
import {collection, getDocs, query, where, setDoc, doc, Firestore, getDoc} from "firebase/firestore";
|
||||||
import {shuffle} from "lodash";
|
import {shuffle} from "lodash";
|
||||||
import {Exam, InstructorGender, Variant} from "@/interfaces/exam";
|
import {Difficulty, Exam, InstructorGender, SpeakingExam, Variant, WritingExam} from "@/interfaces/exam";
|
||||||
import {Stat} from "@/interfaces/user";
|
import {DeveloperUser, Stat, StudentUser, User} from "@/interfaces/user";
|
||||||
|
import {Module} from "@/interfaces";
|
||||||
|
|
||||||
export const getExams = async (
|
export const getExams = async (
|
||||||
db: Firestore,
|
db: Firestore,
|
||||||
module: string,
|
module: Module,
|
||||||
avoidRepeated: string,
|
avoidRepeated: string,
|
||||||
// added userId as due to assignments being set from the teacher to the student
|
// added userId as due to assignments being set from the teacher to the student
|
||||||
// we need to make sure we are serving exams not executed by the user and not
|
// we need to make sure we are serving exams not executed by the user and not
|
||||||
@@ -27,8 +28,10 @@ export const getExams = async (
|
|||||||
})),
|
})),
|
||||||
) as Exam[];
|
) as Exam[];
|
||||||
|
|
||||||
const variantExams: Exam[] = filterByVariant(allExams, variant);
|
let exams: Exam[] = filterByVariant(allExams, variant);
|
||||||
const genderedExams: Exam[] = filterByInstructorGender(variantExams, instructorGender);
|
exams = filterByInstructorGender(exams, instructorGender);
|
||||||
|
exams = await filterByDifficulty(db, exams, module, userId);
|
||||||
|
exams = await filterByPreference(db, exams, module, userId);
|
||||||
|
|
||||||
if (avoidRepeated === "true") {
|
if (avoidRepeated === "true") {
|
||||||
const statsQ = query(collection(db, "stats"), where("user", "==", userId));
|
const statsQ = query(collection(db, "stats"), where("user", "==", userId));
|
||||||
@@ -38,12 +41,12 @@ export const getExams = async (
|
|||||||
id: doc.id,
|
id: doc.id,
|
||||||
...doc.data(),
|
...doc.data(),
|
||||||
})) as unknown as Stat[];
|
})) as unknown as Stat[];
|
||||||
const filteredExams = genderedExams.filter((x) => !stats.map((s) => s.exam).includes(x.id));
|
const filteredExams = exams.filter((x) => !stats.map((s) => s.exam).includes(x.id));
|
||||||
|
|
||||||
return filteredExams.length > 0 ? filteredExams : genderedExams;
|
return filteredExams.length > 0 ? filteredExams : exams;
|
||||||
}
|
}
|
||||||
|
|
||||||
return genderedExams;
|
return exams;
|
||||||
};
|
};
|
||||||
|
|
||||||
const filterByInstructorGender = (exams: Exam[], instructorGender?: InstructorGender) => {
|
const filterByInstructorGender = (exams: Exam[], instructorGender?: InstructorGender) => {
|
||||||
@@ -56,3 +59,37 @@ const filterByVariant = (exams: Exam[], variant?: Variant) => {
|
|||||||
const filtered = variant && variant === "partial" ? exams.filter((x) => x.variant === "partial") : exams.filter((x) => x.variant !== "partial");
|
const filtered = variant && variant === "partial" ? exams.filter((x) => x.variant === "partial") : exams.filter((x) => x.variant !== "partial");
|
||||||
return filtered.length > 0 ? filtered : exams;
|
return filtered.length > 0 ? filtered : exams;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filterByDifficulty = async (db: Firestore, exams: Exam[], module: Module, userID?: string) => {
|
||||||
|
if (!userID) return exams;
|
||||||
|
const userRef = await getDoc(doc(db, "users", userID));
|
||||||
|
if (!userRef.exists()) return exams;
|
||||||
|
|
||||||
|
const user = {...userRef.data(), id: userRef.id} as User;
|
||||||
|
const difficulty = user.levels[module] <= 3 ? "easy" : user.levels[module] <= 6 ? "medium" : "hard";
|
||||||
|
|
||||||
|
const filteredExams = exams.filter((exam) => exam.difficulty === difficulty);
|
||||||
|
return filteredExams.length === 0 ? exams : filteredExams;
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterByPreference = async (db: Firestore, exams: Exam[], module: Module, userID?: string) => {
|
||||||
|
if (!["speaking", "writing"].includes(module)) return exams;
|
||||||
|
|
||||||
|
if (!userID) return exams;
|
||||||
|
const userRef = await getDoc(doc(db, "users", userID));
|
||||||
|
if (!userRef.exists()) return exams;
|
||||||
|
|
||||||
|
const user = {...userRef.data(), id: userRef.id} as StudentUser | DeveloperUser;
|
||||||
|
if (!["developer", "student"].includes(user.type)) return exams;
|
||||||
|
if (!user.preferredTopics || user.preferredTopics.length === 0) return exams;
|
||||||
|
|
||||||
|
const userTopics = user.preferredTopics;
|
||||||
|
const topicalExams = exams.filter((e) => {
|
||||||
|
const exam = e as WritingExam | SpeakingExam;
|
||||||
|
const topics = exam.exercises.map((x) => x.topic).filter((x) => !!x) as string[];
|
||||||
|
|
||||||
|
return topics.some((topic) => userTopics.map((x) => x.toLowerCase()).includes(topic.toLowerCase()));
|
||||||
|
});
|
||||||
|
|
||||||
|
return topicalExams.length > 0 ? shuffle(topicalExams) : exams;
|
||||||
|
};
|
||||||
|
|||||||
@@ -163,3 +163,14 @@ export const getLevelScore = (level: number) => {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getLevelLabel = (level: number) => {
|
||||||
|
if (level < 2) return ["Foundation", "Pre-A1"];
|
||||||
|
if (level < 4) return ["Elementary", "A1"];
|
||||||
|
if (level < 5) return ["Pre-intermediate", "A2"];
|
||||||
|
if (level < 6) return ["Intermediate", "B1"];
|
||||||
|
if (level < 7) return ["Upper Intermediate", "B2"];
|
||||||
|
if (level < 8) return ["Advanced", "C1"];
|
||||||
|
|
||||||
|
return ["Proficiency", "C2"];
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user