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} */
|
||||
const websiteUrl = process.env.NODE_ENV === 'production' ? "https://encoach.com" : "http://localhost:3000";
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
output: "standalone",
|
||||
@@ -8,7 +9,7 @@ const nextConfig = {
|
||||
source: "/api/packages",
|
||||
headers: [
|
||||
{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",
|
||||
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({
|
||||
exercise: id,
|
||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
score: {correct: 100, total: 100, missing: 0},
|
||||
type,
|
||||
});
|
||||
};
|
||||
@@ -96,7 +96,7 @@ export default function InteractiveSpeaking({
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
score: {correct: 100, total: 100, missing: 0},
|
||||
type,
|
||||
});
|
||||
};
|
||||
@@ -131,7 +131,7 @@ export default function InteractiveSpeaking({
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
score: {correct: 100, total: 100, missing: 0},
|
||||
type,
|
||||
});
|
||||
}
|
||||
@@ -176,7 +176,7 @@ export default function InteractiveSpeaking({
|
||||
{
|
||||
exercise: id,
|
||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
score: {correct: 100, total: 100, missing: 0},
|
||||
module: "speaking",
|
||||
exam: examID,
|
||||
type,
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: storagePath ? [{id, solution: storagePath}] : [],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
score: {correct: 100, total: 100, missing: 0},
|
||||
type,
|
||||
});
|
||||
};
|
||||
@@ -94,7 +94,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
|
||||
onBack({
|
||||
exercise: id,
|
||||
solutions: storagePath ? [{id, solution: storagePath}] : [],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
score: {correct: 100, total: 100, missing: 0},
|
||||
type,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function Writing({
|
||||
if (inputText.length > 0 && saveTimer % 10 === 0) {
|
||||
setUserSolutions([
|
||||
...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
|
||||
@@ -64,7 +64,8 @@ export default function Writing({
|
||||
}, []);
|
||||
|
||||
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
|
||||
}, [hasExamEnded]);
|
||||
|
||||
@@ -147,7 +148,9 @@ export default function Writing({
|
||||
<Button
|
||||
color="purple"
|
||||
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">
|
||||
Back
|
||||
</Button>
|
||||
@@ -158,8 +161,9 @@ export default function Writing({
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: [{id, solution: inputText.replaceAll(/\s{2,}/g, " ")}],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
score: {correct: 100, total: 100, missing: 0},
|
||||
type,
|
||||
module: "writing",
|
||||
})
|
||||
}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
|
||||
@@ -34,6 +34,7 @@ export default function Layout({user, children, className, navDisabled = false,
|
||||
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
|
||||
className="-md:hidden"
|
||||
userType={user.type}
|
||||
userId={user.id}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
|
||||
@@ -21,7 +21,11 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
|
||||
const [timer, setTimer] = useState(minTimer * 60);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [warningMode, setWarningMode] = useState(false);
|
||||
|
||||
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
|
||||
const {timeSpent} = useExamStore((state) => state);
|
||||
|
||||
useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]);
|
||||
|
||||
useEffect(() => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -13,9 +13,10 @@ interface Props {
|
||||
onClose: () => void;
|
||||
path: string;
|
||||
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 logout = async () => {
|
||||
@@ -34,8 +35,7 @@ export default function MobileMenu({ isOpen, onClose, path, user }: Props) {
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
leaveTo="opacity-0">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||
</Transition.Child>
|
||||
|
||||
@@ -48,146 +48,105 @@ export default function MobileMenu({ isOpen, onClose, path, user }: Props) {
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
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.Title
|
||||
as="header"
|
||||
className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden"
|
||||
>
|
||||
<Link href="/">
|
||||
<Image
|
||||
src="/logo_title.png"
|
||||
alt="EnCoach logo"
|
||||
width={69}
|
||||
height={69}
|
||||
/>
|
||||
<Dialog.Title as="header" className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden">
|
||||
<Link href={disableNavigation ? "" : "/"}>
|
||||
<Image src="/logo_title.png" alt="EnCoach logo" width={69} height={69} />
|
||||
</Link>
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={onClose}
|
||||
tabIndex={0}
|
||||
>
|
||||
<BsXLg
|
||||
className="text-mti-purple-light text-2xl"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="cursor-pointer" onClick={onClose} tabIndex={0}>
|
||||
<BsXLg className="text-mti-purple-light text-2xl" onClick={onClose} />
|
||||
</div>
|
||||
</Dialog.Title>
|
||||
<div className="flex h-full flex-col gap-6 px-8 text-lg">
|
||||
<Link
|
||||
href="/"
|
||||
href={disableNavigation ? "" : "/"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}
|
||||
>
|
||||
path === "/" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Dashboard
|
||||
</Link>
|
||||
{(user.type === "student" ||
|
||||
user.type === "teacher" ||
|
||||
user.type === "developer") && (
|
||||
{(user.type === "student" || user.type === "teacher" || user.type === "developer") && (
|
||||
<>
|
||||
<Link
|
||||
href="/exam"
|
||||
href={disableNavigation ? "" : "/exam"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/exam" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}
|
||||
>
|
||||
path === "/exam" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Exams
|
||||
</Link>
|
||||
<Link
|
||||
href="/exercises"
|
||||
href={disableNavigation ? "" : "/exercises"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/exercises" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}
|
||||
>
|
||||
)}>
|
||||
Exercises
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<Link
|
||||
href="/stats"
|
||||
href={disableNavigation ? "" : "/stats"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/stats" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}
|
||||
>
|
||||
path === "/stats" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Stats
|
||||
</Link>
|
||||
<Link
|
||||
href="/record"
|
||||
href={disableNavigation ? "" : "/record"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/record" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}
|
||||
>
|
||||
path === "/record" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Record
|
||||
</Link>
|
||||
{["admin", "developer", "agent", "corporate"].includes(
|
||||
user.type,
|
||||
) && (
|
||||
{["admin", "developer", "agent", "corporate"].includes(user.type) && (
|
||||
<Link
|
||||
href="/payment-record"
|
||||
href={disableNavigation ? "" : "/payment-record"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/payment-record" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}
|
||||
>
|
||||
)}>
|
||||
Payment Record
|
||||
</Link>
|
||||
)}
|
||||
{["admin", "developer", "corporate", "teacher"].includes(
|
||||
user.type,
|
||||
) && (
|
||||
{["admin", "developer", "corporate", "teacher"].includes(user.type) && (
|
||||
<Link
|
||||
href="/settings"
|
||||
href={disableNavigation ? "" : "/settings"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/settings" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}
|
||||
>
|
||||
path === "/settings" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Settings
|
||||
</Link>
|
||||
)}
|
||||
{["admin", "developer", "agent"].includes(user.type) && (
|
||||
<Link
|
||||
href="/tickets"
|
||||
href={disableNavigation ? "" : "/tickets"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/tickets" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}
|
||||
>
|
||||
path === "/tickets" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Tickets
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href="/profile"
|
||||
href={disableNavigation ? "" : "/profile"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/profile" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}
|
||||
>
|
||||
path === "/profile" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Profile
|
||||
</Link>
|
||||
|
||||
<span
|
||||
className={clsx(
|
||||
"w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out",
|
||||
)}
|
||||
onClick={logout}
|
||||
>
|
||||
className={clsx("w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out")}
|
||||
onClick={logout}>
|
||||
Logout
|
||||
</span>
|
||||
</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)} />
|
||||
</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">
|
||||
<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" />
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { DurationUnit } from "@/interfaces/paypal";
|
||||
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OnCancelledActions, OrderResponseBody} from "@paypal/paypal-js";
|
||||
import {PayPalButtons, PayPalScriptProvider, usePayPalScriptReducer} from "@paypal/react-paypal-js";
|
||||
import {
|
||||
CreateOrderActions,
|
||||
CreateOrderData,
|
||||
OnApproveActions,
|
||||
OnApproveData,
|
||||
OnCancelledActions,
|
||||
OrderResponseBody,
|
||||
} from "@paypal/paypal-js";
|
||||
import {
|
||||
PayPalButtons,
|
||||
PayPalScriptProvider,
|
||||
usePayPalScriptReducer,
|
||||
} from "@paypal/react-paypal-js";
|
||||
import axios from "axios";
|
||||
import {useEffect, useState} from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
interface Props {
|
||||
@@ -14,20 +25,48 @@ interface Props {
|
||||
loadScript?: boolean;
|
||||
setIsLoading: (isLoading: boolean) => void;
|
||||
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
|
||||
trackingId?: string;
|
||||
}
|
||||
|
||||
export default function PayPalPayment({clientID, price, currency, duration, duration_unit, loadScript, setIsLoading, onSuccess}: Props) {
|
||||
const createOrder = async (data: CreateOrderData, actions: CreateOrderActions): Promise<string> => {
|
||||
export default function PayPalPayment({
|
||||
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);
|
||||
|
||||
return axios
|
||||
.post<OrderResponseBody>("/api/paypal", {currencyCode: currency, price})
|
||||
.post<OrderResponseBody>("/api/paypal", {
|
||||
currencyCode: currency,
|
||||
price,
|
||||
trackingId,
|
||||
})
|
||||
.then((response) => response.data)
|
||||
.then((data) => data.id);
|
||||
};
|
||||
|
||||
const onApprove = async (data: OnApproveData, actions: OnApproveActions) => {
|
||||
const request = await axios.post<{ok: boolean; reason?: string}>("/api/paypal/approve", {id: data.orderID, duration, duration_unit});
|
||||
if (!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) {
|
||||
toast.error("Something went wrong, please try again later");
|
||||
@@ -42,10 +81,14 @@ export default function PayPalPayment({clientID, price, currency, duration, dura
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const onCancel = async (data: Record<string, unknown>, actions: OnCancelledActions) => {
|
||||
const onCancel = async (
|
||||
data: Record<string, unknown>,
|
||||
actions: OnCancelledActions
|
||||
) => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
if (trackingId) {
|
||||
return loadScript ? (
|
||||
<PayPalScriptProvider
|
||||
options={{
|
||||
@@ -54,7 +97,8 @@ export default function PayPalPayment({clientID, price, currency, duration, dura
|
||||
intent: "capture",
|
||||
commit: true,
|
||||
vault: true,
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<PayPalButtons
|
||||
className="w-full"
|
||||
style={{ layout: "vertical" }}
|
||||
@@ -75,3 +119,6 @@ export default function PayPalPayment({clientID, price, currency, duration, dura
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -21,9 +21,10 @@ import { useRouter } from "next/router";
|
||||
import axios from "axios";
|
||||
import FocusLayer from "@/components/FocusLayer";
|
||||
import {preventNavigation} from "@/utils/navigation.disabled";
|
||||
import { useState } from "react";
|
||||
import {useEffect, useState} from "react";
|
||||
import usePreferencesStore from "@/stores/preferencesStore";
|
||||
import {Type} from "@/interfaces/user";
|
||||
import useTicketsListener from "@/hooks/useTicketsListener";
|
||||
interface Props {
|
||||
path: string;
|
||||
navDisabled?: boolean;
|
||||
@@ -31,6 +32,7 @@ interface Props {
|
||||
onFocusLayerMouseEnter?: () => void;
|
||||
className?: string;
|
||||
userType?: Type;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
interface NavProps {
|
||||
@@ -40,47 +42,44 @@ interface NavProps {
|
||||
keyPath: string;
|
||||
disabled?: boolean;
|
||||
isMinimized?: boolean;
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
const Nav = ({
|
||||
Icon,
|
||||
label,
|
||||
path,
|
||||
keyPath,
|
||||
disabled = false,
|
||||
isMinimized = false,
|
||||
}: NavProps) => (
|
||||
const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false, badge}: NavProps) => {
|
||||
return (
|
||||
<Link
|
||||
href={!disabled ? keyPath : ""}
|
||||
className={clsx(
|
||||
"flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white",
|
||||
"transition-all duration-300 ease-in-out",
|
||||
disabled
|
||||
? "hover:bg-mti-gray-dim cursor-not-allowed"
|
||||
: "hover:bg-mti-purple-light cursor-pointer",
|
||||
"transition-all duration-300 ease-in-out relative",
|
||||
disabled ? "hover:bg-mti-gray-dim cursor-not-allowed" : "hover:bg-mti-purple-light cursor-pointer",
|
||||
path === keyPath && "bg-mti-purple-light text-white",
|
||||
isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]",
|
||||
)}
|
||||
>
|
||||
)}>
|
||||
<Icon size={24} />
|
||||
{!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>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Sidebar({
|
||||
path,
|
||||
navDisabled = false,
|
||||
focusMode = false,
|
||||
userType,
|
||||
onFocusLayerMouseEnter,
|
||||
className,
|
||||
}: Props) {
|
||||
export default function Sidebar({path, navDisabled = false, focusMode = false, userType, onFocusLayerMouseEnter, className, userId}: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [
|
||||
state.isSidebarMinimized,
|
||||
state.toggleSidebarMinimized,
|
||||
]);
|
||||
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
|
||||
|
||||
const {totalAssignedTickets} = useTicketsListener(userId);
|
||||
|
||||
useEffect(() => console.log(totalAssignedTickets), [totalAssignedTickets]);
|
||||
|
||||
const logout = async () => {
|
||||
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",
|
||||
isMinimized ? "w-fit" : "-xl:w-fit w-1/6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
)}>
|
||||
<div className="-xl:hidden flex-col gap-3 xl:flex">
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={MdSpaceDashboard}
|
||||
label="Dashboard"
|
||||
path={path}
|
||||
keyPath="/"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
{(userType === "student" ||
|
||||
userType === "teacher" ||
|
||||
userType === "developer") && (
|
||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
|
||||
{(userType === "student" || userType === "teacher" || userType === "developer") && (
|
||||
<>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
@@ -129,25 +118,9 @@ export default function Sidebar({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsGraphUp}
|
||||
label="Stats"
|
||||
path={path}
|
||||
keyPath="/stats"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsClockHistory}
|
||||
label="Record"
|
||||
path={path}
|
||||
keyPath="/record"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
{["admin", "developer", "agent", "corporate"].includes(
|
||||
userType || "",
|
||||
) && (
|
||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
|
||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
|
||||
{["admin", "developer", "agent", "corporate"].includes(userType || "") && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCurrencyDollar}
|
||||
@@ -157,9 +130,7 @@ export default function Sidebar({
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{["admin", "developer", "corporate", "teacher"].includes(
|
||||
userType || "",
|
||||
) && (
|
||||
{["admin", "developer", "corporate", "teacher"].includes(userType || "") && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsShieldFill}
|
||||
@@ -177,6 +148,7 @@ export default function Sidebar({
|
||||
path={path}
|
||||
keyPath="/tickets"
|
||||
isMinimized={isMinimized}
|
||||
badge={totalAssignedTickets}
|
||||
/>
|
||||
)}
|
||||
{userType === "developer" && (
|
||||
@@ -191,65 +163,16 @@ export default function Sidebar({
|
||||
)}
|
||||
</div>
|
||||
<div className="-xl:flex flex-col gap-3 xl:hidden">
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={MdSpaceDashboard}
|
||||
label="Dashboard"
|
||||
path={path}
|
||||
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}
|
||||
/>
|
||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} 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" && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsShieldFill}
|
||||
label="Settings"
|
||||
path={path}
|
||||
keyPath="/settings"
|
||||
isMinimized={true}
|
||||
/>
|
||||
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized={true} />
|
||||
)}
|
||||
{userType === "developer" && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCloudFill}
|
||||
label="Generation"
|
||||
path={path}
|
||||
keyPath="/generation"
|
||||
isMinimized={true}
|
||||
/>
|
||||
<Nav disabled={disableNavigation} Icon={BsCloudFill} label="Generation" path={path} keyPath="/generation" isMinimized={true} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -261,16 +184,9 @@ export default function Sidebar({
|
||||
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",
|
||||
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
||||
)}
|
||||
>
|
||||
{isMinimized ? (
|
||||
<BsChevronBarRight size={24} />
|
||||
) : (
|
||||
<BsChevronBarLeft size={24} />
|
||||
)}
|
||||
{!isMinimized && (
|
||||
<span className="text-lg font-medium">Minimize</span>
|
||||
)}
|
||||
)}>
|
||||
{isMinimized ? <BsChevronBarRight size={24} /> : <BsChevronBarLeft size={24} />}
|
||||
{!isMinimized && <span className="text-lg font-medium">Minimize</span>}
|
||||
</div>
|
||||
<div
|
||||
role="button"
|
||||
@@ -279,17 +195,12 @@ export default function Sidebar({
|
||||
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",
|
||||
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
||||
)}
|
||||
>
|
||||
)}>
|
||||
<RiLogoutBoxFill size={24} />
|
||||
{!isMinimized && (
|
||||
<span className="-xl:hidden text-lg font-medium">Log Out</span>
|
||||
)}
|
||||
{!isMinimized && <span className="-xl:hidden text-lg font-medium">Log Out</span>}
|
||||
</div>
|
||||
</div>
|
||||
{focusMode && (
|
||||
<FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
|
||||
)}
|
||||
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -224,9 +224,9 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
defaultValue={monthlyDuration}
|
||||
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>
|
||||
<div className="w-full grid grid-cols-5 gap-2">
|
||||
<div className="w-full grid grid-cols-6 gap-2">
|
||||
<Input
|
||||
name="paymentValue"
|
||||
onChange={(e) => setPaymentValue(e ? parseInt(e) : undefined)}
|
||||
@@ -237,7 +237,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
/>
|
||||
<Select
|
||||
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",
|
||||
)}
|
||||
options={CURRENCIES_OPTIONS}
|
||||
|
||||
@@ -13,11 +13,14 @@ import {
|
||||
BsPen,
|
||||
} from "react-icons/bs";
|
||||
import { usePDFDownload } from "@/hooks/usePDFDownload";
|
||||
import { useAssignmentArchive } from "@/hooks/useAssignmentArchive";
|
||||
import { uniqBy } from "lodash";
|
||||
|
||||
interface Props {
|
||||
onClick?: () => void;
|
||||
allowDownload?: boolean;
|
||||
reload?: Function;
|
||||
allowArchive?: boolean;
|
||||
}
|
||||
|
||||
export default function AssignmentCard({
|
||||
@@ -29,11 +32,14 @@ export default function AssignmentCard({
|
||||
assignees,
|
||||
results,
|
||||
exams,
|
||||
archived,
|
||||
onClick,
|
||||
allowDownload,
|
||||
reload,
|
||||
allowArchive,
|
||||
}: Assignment & Props) {
|
||||
const { users } = useUsers();
|
||||
const renderPdfIcon = usePDFDownload("assignments");
|
||||
const renderArchiveIcon = useAssignmentArchive(id, reload);
|
||||
|
||||
const calculateAverageModuleScore = (module: Module) => {
|
||||
const resultModuleBandScores = results.map((r) => {
|
||||
@@ -41,11 +47,11 @@ export default function AssignmentCard({
|
||||
|
||||
const correct = moduleStats.reduce(
|
||||
(acc, curr) => acc + curr.score.correct,
|
||||
0,
|
||||
0
|
||||
);
|
||||
const total = moduleStats.reduce(
|
||||
(acc, curr) => acc + curr.score.total,
|
||||
0,
|
||||
0
|
||||
);
|
||||
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-row justify-between">
|
||||
<h3 className="text-xl font-semibold">{name}</h3>
|
||||
<div className="flex gap-2">
|
||||
{allowDownload &&
|
||||
renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
|
||||
{allowArchive &&
|
||||
!archived &&
|
||||
renderArchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
|
||||
</div>
|
||||
</div>
|
||||
<ProgressBar
|
||||
color={results.length / assignees.length < 0.5 ? "red" : "purple"}
|
||||
@@ -94,7 +105,7 @@ export default function AssignmentCard({
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
module === "level" && "bg-ielts-level"
|
||||
)}
|
||||
>
|
||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import Modal from "@/components/Modal";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
@@ -9,17 +10,13 @@ import { getExamById } from "@/utils/exams";
|
||||
import {sortByModule} from "@/utils/moduleUtils";
|
||||
import {calculateBandScore} from "@/utils/score";
|
||||
import {convertToUserSolutions} from "@/utils/stats";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize, uniqBy} from "lodash";
|
||||
import moment from "moment";
|
||||
import {useRouter} from "next/router";
|
||||
import {
|
||||
BsBook,
|
||||
BsClipboard,
|
||||
BsHeadphones,
|
||||
BsMegaphone,
|
||||
BsPen,
|
||||
} from "react-icons/bs";
|
||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
@@ -36,6 +33,16 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
||||
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
||||
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 date = moment(parseInt(timestamp));
|
||||
const formatter = "YYYY/MM/DD - HH:mm";
|
||||
@@ -49,26 +56,15 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
||||
const resultModuleBandScores = assignment.results.map((r) => {
|
||||
const moduleStats = r.stats.filter((s) => s.module === module);
|
||||
|
||||
const correct = moduleStats.reduce(
|
||||
(acc, curr) => acc + curr.score.correct,
|
||||
0,
|
||||
);
|
||||
const total = moduleStats.reduce(
|
||||
(acc, curr) => acc + curr.score.total,
|
||||
0,
|
||||
);
|
||||
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
|
||||
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
|
||||
return calculateBandScore(correct, total, module, r.type);
|
||||
});
|
||||
|
||||
return resultModuleBandScores.length === 0
|
||||
? -1
|
||||
: resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
|
||||
assignment.results.length;
|
||||
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length;
|
||||
};
|
||||
|
||||
const aggregateScoresByModule = (
|
||||
stats: Stat[],
|
||||
): { module: Module; total: number; missing: number; correct: number }[] => {
|
||||
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => {
|
||||
const scores: {
|
||||
[key in Module]: {total: number; missing: number; correct: number};
|
||||
} = {
|
||||
@@ -112,22 +108,10 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
||||
.map((x) => ({module: x as Module, ...scores[x as Module]}));
|
||||
};
|
||||
|
||||
const customContent = (
|
||||
stats: Stat[],
|
||||
user: string,
|
||||
focus: "academic" | "general",
|
||||
) => {
|
||||
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 customContent = (stats: Stat[], user: string, focus: "academic" | "general") => {
|
||||
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) => ({
|
||||
module: x.module,
|
||||
@@ -137,9 +121,7 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
||||
const timeSpent = stats[0].timeSpent;
|
||||
|
||||
const selectExam = () => {
|
||||
const examPromises = uniqBy(stats, "exam").map((stat) =>
|
||||
getExamById(stat.module, stat.exam),
|
||||
);
|
||||
const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam));
|
||||
|
||||
Promise.all(examPromises).then((exams) => {
|
||||
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: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">
|
||||
{formatTimestamp(stats[0].date.toString())}
|
||||
</span>
|
||||
<span className="font-medium">{formatTimestamp(stats[0].date.toString())}</span>
|
||||
{timeSpent && (
|
||||
<>
|
||||
<span className="md:hidden 2xl:flex">• </span>
|
||||
<span className="text-sm">
|
||||
{Math.floor(timeSpent / 60)} minutes
|
||||
</span>
|
||||
<span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -178,15 +156,9 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
||||
correct / total >= 0.7 && "text-mti-purple",
|
||||
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
||||
correct / total < 0.3 && "text-mti-rose",
|
||||
)}
|
||||
>
|
||||
)}>
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -202,8 +174,7 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}
|
||||
>
|
||||
)}>
|
||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||
{module === "listening" && <BsHeadphones 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(
|
||||
"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.3 &&
|
||||
correct / total < 0.7 &&
|
||||
"hover:border-mti-red",
|
||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
||||
correct / total < 0.3 && "hover:border-mti-rose",
|
||||
)}
|
||||
onClick={selectExam}
|
||||
role="button"
|
||||
>
|
||||
role="button">
|
||||
{content}
|
||||
</div>
|
||||
<div
|
||||
@@ -245,14 +213,11 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
||||
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",
|
||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||
correct / total >= 0.3 &&
|
||||
correct / total < 0.7 &&
|
||||
"hover:border-mti-red",
|
||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
||||
correct / total < 0.3 && "hover:border-mti-rose",
|
||||
)}
|
||||
data-tip="Your screen size is too small to view previous exams."
|
||||
role="button"
|
||||
>
|
||||
role="button">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,27 +232,14 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
||||
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
|
||||
className="h-6"
|
||||
textClassName={
|
||||
(assignment?.results.length || 0) /
|
||||
(assignment?.assignees.length || 1) <
|
||||
0.5
|
||||
? "!text-mti-gray-dim font-light"
|
||||
: "text-white"
|
||||
}
|
||||
percentage={
|
||||
((assignment?.results.length || 0) /
|
||||
(assignment?.assignees.length || 1)) *
|
||||
100
|
||||
(assignment?.results.length || 0) / (assignment?.assignees.length || 1) < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"
|
||||
}
|
||||
percentage={((assignment?.results.length || 0) / (assignment?.assignees.length || 1)) * 100}
|
||||
/>
|
||||
<div className="flex items-start gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span>
|
||||
Start Date:{" "}
|
||||
{moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}
|
||||
</span>
|
||||
<span>
|
||||
End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}
|
||||
</span>
|
||||
<span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
<span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
</div>
|
||||
<span>
|
||||
Assignees:{" "}
|
||||
@@ -312,19 +264,14 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}
|
||||
>
|
||||
)}>
|
||||
{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 === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||
{calculateAverageModuleScore(module) > -1 && (
|
||||
<span className="text-sm">
|
||||
{calculateAverageModuleScore(module).toFixed(1)}
|
||||
</span>
|
||||
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -332,22 +279,28 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xl font-bold">
|
||||
Results ({assignment?.results.length}/{assignment?.assignees.length}
|
||||
)
|
||||
Results ({assignment?.results.length}/{assignment?.assignees.length})
|
||||
</span>
|
||||
<div>
|
||||
{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">
|
||||
{assignment.results.map((r) =>
|
||||
customContent(r.stats, r.user, r.type),
|
||||
)}
|
||||
{assignment.results.map((r) => customContent(r.stats, r.user, r.type))}
|
||||
</div>
|
||||
)}
|
||||
{assignment && assignment?.results.length === 0 && (
|
||||
<span className="ml-1 font-semibold">No results yet...</span>
|
||||
)}
|
||||
{assignment && assignment?.results.length === 0 && <span className="ml-1 font-semibold">No results yet...</span>}
|
||||
</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>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import useExamStore from "@/stores/examStore";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {getUserCorporate} from "@/utils/groups";
|
||||
import {MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
|
||||
import {getLevelLabel, getLevelScore} from "@/utils/score";
|
||||
import {averageScore, groupBySession} from "@/utils/stats";
|
||||
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/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" />,
|
||||
value: Object.keys(groupBySession(stats)).length,
|
||||
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" />,
|
||||
value: stats.length,
|
||||
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" />,
|
||||
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
|
||||
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">
|
||||
<span className="text-lg font-bold">Score History</span>
|
||||
<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="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">
|
||||
@@ -237,7 +244,8 @@ export default function StudentDashboard({user}: Props) {
|
||||
<div className="flex w-full justify-between">
|
||||
<span className="text-sm font-bold md:font-extrabold">{capitalize(module)}</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,14 +253,15 @@ export default function StudentDashboard({user}: Props) {
|
||||
<ProgressBar
|
||||
color={module}
|
||||
label=""
|
||||
mark={Math.round((user.desiredLevels[module] * 100) / 9)}
|
||||
markLabel={`Desired Level: ${user.desiredLevels[module]}`}
|
||||
percentage={Math.round((user.levels[module] * 100) / 9)}
|
||||
mark={Math.round((desiredLevel * 100) / 9)}
|
||||
markLabel={`Desired Level: ${desiredLevel}`}
|
||||
percentage={Math.round((level * 100) / 9)}
|
||||
className="h-2 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
|
||||
@@ -151,9 +151,8 @@ export default function TeacherDashboard({user}: Props) {
|
||||
};
|
||||
|
||||
const AssignmentsPage = () => {
|
||||
const activeFilter = (a: Assignment) =>
|
||||
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;
|
||||
const activeFilter = (a: Assignment) => 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 futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
|
||||
|
||||
return (
|
||||
@@ -163,6 +162,7 @@ export default function TeacherDashboard({user}: Props) {
|
||||
onClose={() => {
|
||||
setSelectedAssignment(undefined);
|
||||
setIsCreatingAssignment(false);
|
||||
reloadAssignments();
|
||||
}}
|
||||
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>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{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>
|
||||
</section>
|
||||
@@ -280,7 +280,7 @@ export default function TeacherDashboard({user}: Props) {
|
||||
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
||||
<span className="flex flex-col gap-1 items-center text-xl">
|
||||
<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>
|
||||
</div>
|
||||
</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 =
|
||||
"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) {
|
||||
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" />,
|
||||
label: "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" />,
|
||||
label: "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" />,
|
||||
label: "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" />,
|
||||
label: "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" />,
|
||||
label: "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 axios from "axios";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
|
||||
export default function useTickets() {
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [tickets, setTickets] = useState<TicketWithCorporate[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const getData = () => {
|
||||
const getData = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<Ticket[]>(`/api/tickets`)
|
||||
.get<TicketWithCorporate[]>(`/api/tickets`)
|
||||
.then((response) => setTickets(response.data))
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(getData, []);
|
||||
useEffect(getData, [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 Variant = "full" | "partial";
|
||||
export type InstructorGender = "male" | "female" | "varied";
|
||||
export type Difficulty = "easy" | "medium" | "hard";
|
||||
|
||||
export interface ReadingExam {
|
||||
parts: ReadingPart[];
|
||||
@@ -12,6 +13,7 @@ export interface ReadingExam {
|
||||
type: "academic" | "general";
|
||||
isDiagnostic: boolean;
|
||||
variant?: Variant;
|
||||
difficulty?: Difficulty;
|
||||
}
|
||||
|
||||
export interface ReadingPart {
|
||||
@@ -29,6 +31,7 @@ export interface LevelExam {
|
||||
minTimer: number;
|
||||
isDiagnostic: boolean;
|
||||
variant?: Variant;
|
||||
difficulty?: Difficulty;
|
||||
}
|
||||
|
||||
export interface ListeningExam {
|
||||
@@ -38,6 +41,7 @@ export interface ListeningExam {
|
||||
minTimer: number;
|
||||
isDiagnostic: boolean;
|
||||
variant?: Variant;
|
||||
difficulty?: Difficulty;
|
||||
}
|
||||
|
||||
export interface ListeningPart {
|
||||
@@ -65,10 +69,11 @@ export interface UserSolution {
|
||||
export interface WritingExam {
|
||||
module: "writing";
|
||||
id: string;
|
||||
exercises: Exercise[];
|
||||
exercises: WritingExercise[];
|
||||
minTimer: number;
|
||||
isDiagnostic: boolean;
|
||||
variant?: Variant;
|
||||
difficulty?: Difficulty;
|
||||
}
|
||||
|
||||
interface WordCounter {
|
||||
@@ -79,11 +84,12 @@ interface WordCounter {
|
||||
export interface SpeakingExam {
|
||||
id: string;
|
||||
module: "speaking";
|
||||
exercises: Exercise[];
|
||||
exercises: (SpeakingExercise | InteractiveSpeakingExercise)[];
|
||||
minTimer: number;
|
||||
isDiagnostic: boolean;
|
||||
variant?: Variant;
|
||||
instructorGender: InstructorGender;
|
||||
difficulty?: Difficulty;
|
||||
}
|
||||
|
||||
export type Exercise =
|
||||
@@ -142,6 +148,7 @@ export interface WritingExercise {
|
||||
solution: string;
|
||||
evaluation?: CommonEvaluation;
|
||||
}[];
|
||||
topic?: string;
|
||||
}
|
||||
|
||||
export interface SpeakingExercise {
|
||||
@@ -156,6 +163,7 @@ export interface SpeakingExercise {
|
||||
solution: string;
|
||||
evaluation?: SpeakingEvaluation;
|
||||
}[];
|
||||
topic?: string;
|
||||
}
|
||||
|
||||
export interface InteractiveSpeakingExercise {
|
||||
@@ -169,6 +177,7 @@ export interface InteractiveSpeakingExercise {
|
||||
solution: {questionIndex: number; question: string; answer: string}[];
|
||||
evaluation?: InteractiveSpeakingEvaluation;
|
||||
}[];
|
||||
topic?: string;
|
||||
}
|
||||
|
||||
export interface FillBlanksExercise {
|
||||
|
||||
@@ -35,3 +35,16 @@ export interface Payment {
|
||||
corporateTransfer?: 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;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
archived?: boolean;
|
||||
}
|
||||
|
||||
@@ -32,3 +32,7 @@ export const TicketStatusLabel: { [key in TicketStatus]: string } = {
|
||||
"in-progress": "In Progress",
|
||||
completed: "Completed",
|
||||
};
|
||||
|
||||
export interface TicketWithCorporate extends Ticket {
|
||||
corporate?: string;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export interface StudentUser extends BasicUser {
|
||||
type: "student";
|
||||
preferredGender?: InstructorGender;
|
||||
demographicInformation?: DemographicInformation;
|
||||
preferredTopics?: string[];
|
||||
}
|
||||
|
||||
export interface TeacherUser extends BasicUser {
|
||||
@@ -52,6 +53,7 @@ export interface DeveloperUser extends BasicUser {
|
||||
type: "developer";
|
||||
preferredGender?: InstructorGender;
|
||||
demographicInformation?: DemographicInformation;
|
||||
preferredTopics?: string[];
|
||||
}
|
||||
|
||||
export interface CorporateInformation {
|
||||
|
||||
@@ -336,12 +336,13 @@ export default function ExamPage({page}: Props) {
|
||||
};
|
||||
|
||||
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!] = {
|
||||
total: scores[x.module!].total + x.score.total,
|
||||
correct: scores[x.module!].correct + x.score.correct,
|
||||
missing: scores[x.module!].missing + x.score.missing,
|
||||
scores[examModule!] = {
|
||||
total: scores[examModule!].total + x.score.total,
|
||||
correct: scores[examModule!].correct + x.score.correct,
|
||||
missing: scores[examModule!].missing + x.score.missing,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -423,7 +424,7 @@ export default function ExamPage({page}: Props) {
|
||||
<AbandonPopup
|
||||
isOpen={showAbandonPopup}
|
||||
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"
|
||||
onAbandon={() => {
|
||||
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 {getExamById} from "@/utils/exams";
|
||||
import {playSound} from "@/utils/sound";
|
||||
import {Tab} from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize, sample} from "lodash";
|
||||
import {useRouter} from "next/router";
|
||||
import {useState} from "react";
|
||||
import {BsArrowRepeat} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import {v4} from "uuid";
|
||||
|
||||
const TaskTab = ({exam, setExam}: {exam?: LevelExam; setExam: (exam: LevelExam) => void}) => {
|
||||
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
||||
|
||||
const TaskTab = ({exam, difficulty, setExam}: {exam?: LevelExam; difficulty: Difficulty; setExam: (exam: LevelExam) => void}) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const generate = () => {
|
||||
const url = new URLSearchParams();
|
||||
url.append("difficulty", difficulty);
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get(`/api/exam/level/generate/level`)
|
||||
.get(`/api/exam/level/generate/level?${url.toString()}`)
|
||||
.then((result) => {
|
||||
playSound(typeof result.data === "string" ? "error" : "check");
|
||||
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 [isLoading, setIsLoading] = useState(false);
|
||||
const [resultingExam, setResultingExam] = useState<LevelExam>();
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -163,6 +171,16 @@ const LevelGeneration = () => {
|
||||
|
||||
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.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1">
|
||||
<Tab
|
||||
@@ -178,7 +196,7 @@ const LevelGeneration = () => {
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<TaskTab exam={generatedExam} setExam={setGeneratedExam} />
|
||||
<TaskTab difficulty={difficulty} exam={generatedExam} setExam={setGeneratedExam} />
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
<div className="w-full flex justify-end gap-4">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 {getExamById} from "@/utils/exams";
|
||||
import {playSound} from "@/utils/sound";
|
||||
@@ -7,17 +8,34 @@ import {convertCamelCaseToReadable} from "@/utils/string";
|
||||
import {Tab} from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize, sample} from "lodash";
|
||||
import {useRouter} from "next/router";
|
||||
import {useEffect, useState} from "react";
|
||||
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
|
||||
const PartTab = ({part, types, index, setPart}: {part?: ListeningPart; types: string[]; index: number; setPart: (part?: ListeningPart) => void}) => {
|
||||
const 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 [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const generate = () => {
|
||||
const url = new URLSearchParams();
|
||||
url.append("difficulty", difficulty);
|
||||
|
||||
if (topic) url.append("topic", topic);
|
||||
if (types) types.forEach((t) => url.append("exercises", t));
|
||||
|
||||
@@ -115,6 +133,7 @@ const ListeningGeneration = () => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [resultingExam, setResultingExam] = useState<ListeningExam>();
|
||||
const [types, setTypes] = useState<string[]>([]);
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||
|
||||
useEffect(() => {
|
||||
const part1Timer = part1 ? 5 : 0;
|
||||
@@ -148,7 +167,7 @@ const ListeningGeneration = () => {
|
||||
setIsLoading(true);
|
||||
|
||||
axios
|
||||
.post(`/api/exam/listening/generate/listening`, {parts, minTimer})
|
||||
.post(`/api/exam/listening/generate/listening`, {parts, minTimer, difficulty})
|
||||
.then((result) => {
|
||||
playSound("sent");
|
||||
console.log(`Generated Exam ID: ${result.data.id}`);
|
||||
@@ -159,6 +178,7 @@ const ListeningGeneration = () => {
|
||||
setPart2(undefined);
|
||||
setPart3(undefined);
|
||||
setPart4(undefined);
|
||||
setDifficulty(sample(DIFFICULTIES)!);
|
||||
setTypes([]);
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -186,6 +206,7 @@ const ListeningGeneration = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-4 w-1/2">
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||
<Input
|
||||
@@ -196,6 +217,16 @@ const ListeningGeneration = () => {
|
||||
className="max-w-[300px]"
|
||||
/>
|
||||
</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">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Exercises</label>
|
||||
@@ -271,7 +302,7 @@ const ListeningGeneration = () => {
|
||||
{part: part3, setPart: setPart3},
|
||||
{part: part4, setPart: setPart4},
|
||||
].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.Group>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 {getExamById} from "@/utils/exams";
|
||||
import {playSound} from "@/utils/sound";
|
||||
@@ -7,18 +8,35 @@ import {convertCamelCaseToReadable} from "@/utils/string";
|
||||
import {Tab} from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize, sample} from "lodash";
|
||||
import {useRouter} from "next/router";
|
||||
import {useEffect, useState} from "react";
|
||||
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import {v4} from "uuid";
|
||||
|
||||
const PartTab = ({part, types, index, setPart}: {part?: ReadingPart; types: string[]; index: number; setPart: (part?: ReadingPart) => void}) => {
|
||||
const 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 [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const generate = () => {
|
||||
const url = new URLSearchParams();
|
||||
url.append("difficulty", difficulty);
|
||||
|
||||
if (topic) url.append("topic", topic);
|
||||
if (types) types.forEach((t) => url.append("exercises", t));
|
||||
|
||||
@@ -92,6 +110,7 @@ const ReadingGeneration = () => {
|
||||
const [types, setTypes] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [resultingExam, setResultingExam] = useState<ReadingExam>();
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||
|
||||
useEffect(() => {
|
||||
const parts = [part1, part2, part3].filter((x) => !!x);
|
||||
@@ -144,6 +163,7 @@ const ReadingGeneration = () => {
|
||||
id: v4(),
|
||||
type: "academic",
|
||||
variant: parts.length === 3 ? "full" : "partial",
|
||||
difficulty,
|
||||
};
|
||||
|
||||
axios
|
||||
@@ -157,6 +177,7 @@ const ReadingGeneration = () => {
|
||||
setPart1(undefined);
|
||||
setPart2(undefined);
|
||||
setPart3(undefined);
|
||||
setDifficulty(sample(DIFFICULTIES)!);
|
||||
setMinTimer(60);
|
||||
setTypes([]);
|
||||
})
|
||||
@@ -169,6 +190,7 @@ const ReadingGeneration = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-4 w-1/2">
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||
<Input
|
||||
@@ -179,6 +201,16 @@ const ReadingGeneration = () => {
|
||||
className="max-w-[300px]"
|
||||
/>
|
||||
</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">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Exercises</label>
|
||||
@@ -240,7 +272,7 @@ const ReadingGeneration = () => {
|
||||
{part: part2, setPart: setPart2},
|
||||
{part: part3, setPart: setPart3},
|
||||
].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.Group>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Input from "@/components/Low/Input";
|
||||
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 useExamStore from "@/stores/examStore";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
@@ -17,7 +17,19 @@ import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
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 [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
@@ -25,8 +37,11 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
|
||||
setPart(undefined);
|
||||
setIsLoading(true);
|
||||
|
||||
const url = new URLSearchParams();
|
||||
url.append("difficulty", difficulty);
|
||||
|
||||
axios
|
||||
.get(`/api/exam/speaking/generate/speaking_task_${index}`)
|
||||
.get(`/api/exam/speaking/generate/speaking_task_${index}?${url.toString()}`)
|
||||
.then((result) => {
|
||||
playSound(typeof result.data === "string" ? "error" : "check");
|
||||
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");
|
||||
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) => {
|
||||
toast.error("Something went wrong!");
|
||||
@@ -64,6 +79,8 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => console.log(part), [part]);
|
||||
|
||||
return (
|
||||
<Tab.Panel className="w-full bg-ielts-speaking/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
@@ -174,6 +191,7 @@ const SpeakingGeneration = () => {
|
||||
const [minTimer, setMinTimer] = useState(14);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [resultingExam, setResultingExam] = useState<SpeakingExam>();
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||
|
||||
useEffect(() => {
|
||||
const parts = [part1, part2, part3].filter((x) => !!x);
|
||||
@@ -213,6 +231,7 @@ const SpeakingGeneration = () => {
|
||||
setPart1(undefined);
|
||||
setPart2(undefined);
|
||||
setPart3(undefined);
|
||||
setDifficulty(sample(DIFFICULTIES)!);
|
||||
setMinTimer(14);
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -240,6 +259,7 @@ const SpeakingGeneration = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-4 w-1/2">
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||
<Input
|
||||
@@ -250,6 +270,16 @@ const SpeakingGeneration = () => {
|
||||
className="max-w-[300px]"
|
||||
/>
|
||||
</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.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: part3, setPart: setPart3},
|
||||
].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.Group>
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
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 {getExamById} from "@/utils/exams";
|
||||
import {playSound} from "@/utils/sound";
|
||||
import {Tab} from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize, sample} from "lodash";
|
||||
import {useRouter} from "next/router";
|
||||
import {useEffect, useState} from "react";
|
||||
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import {v4} from "uuid";
|
||||
|
||||
const TaskTab = ({task, index, setTask}: {task?: string; index: number; setTask: (task: string) => void}) => {
|
||||
const 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 generate = () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const url = new URLSearchParams();
|
||||
url.append("difficulty", difficulty);
|
||||
|
||||
axios
|
||||
.get(`/api/exam/writing/generate/writing_task${index}_general`)
|
||||
.get(`/api/exam/writing/generate/writing_task${index}_general?${url.toString()}`)
|
||||
.then((result) => {
|
||||
playSound(typeof result.data === "string" ? "error" : "check");
|
||||
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 [isLoading, setIsLoading] = useState(false);
|
||||
const [resultingExam, setResultingExam] = useState<WritingExam>();
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||
|
||||
useEffect(() => {
|
||||
const task1Timer = task1 ? 20 : 0;
|
||||
@@ -144,6 +153,7 @@ const WritingGeneration = () => {
|
||||
exercises: [...(exercise1 ? [exercise1] : []), ...(exercise2 ? [exercise2] : [])],
|
||||
id: v4(),
|
||||
variant: exercise1 && exercise2 ? "full" : "partial",
|
||||
difficulty,
|
||||
};
|
||||
|
||||
axios
|
||||
@@ -156,6 +166,7 @@ const WritingGeneration = () => {
|
||||
|
||||
setTask1(undefined);
|
||||
setTask2(undefined);
|
||||
setDifficulty(sample(DIFFICULTIES)!);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
@@ -166,6 +177,7 @@ const WritingGeneration = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-4 w-1/2">
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||
<Input
|
||||
@@ -176,6 +188,16 @@ const WritingGeneration = () => {
|
||||
className="max-w-[300px]"
|
||||
/>
|
||||
</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.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: task2, setTask: setTask2},
|
||||
].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.Group>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { toast } from "react-toastify";
|
||||
import { KeyedMutator } from "swr";
|
||||
import Select from "react-select";
|
||||
import moment from "moment";
|
||||
import useAcceptedTerms from "@/hooks/useAcceptedTerms";
|
||||
|
||||
interface Props {
|
||||
isLoading: boolean;
|
||||
@@ -40,6 +41,7 @@ export default function RegisterCorporate({
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const [companyUsers, setCompanyUsers] = useState(0);
|
||||
const [subscriptionDuration, setSubscriptionDuration] = useState(1);
|
||||
const {acceptedTerms, renderCheckbox} = useAcceptedTerms();
|
||||
|
||||
const { users } = useUsers();
|
||||
|
||||
@@ -257,7 +259,9 @@ export default function RegisterCorporate({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col items-start gap-4">
|
||||
{renderCheckbox()}
|
||||
</div>
|
||||
<Button
|
||||
className="w-full lg:mt-8"
|
||||
color="purple"
|
||||
|
||||
@@ -4,9 +4,10 @@ import Input from "@/components/Low/Input";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { sendEmailVerification } from "@/utils/email";
|
||||
import axios from "axios";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { KeyedMutator } from "swr";
|
||||
import useAcceptedTerms from "@/hooks/useAcceptedTerms";
|
||||
|
||||
interface Props {
|
||||
queryCode?: string;
|
||||
@@ -35,6 +36,7 @@ export default function RegisterIndividual({
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [code, setCode] = useState(queryCode || "");
|
||||
const [hasCode, setHasCode] = useState<boolean>(!!queryCode);
|
||||
const {acceptedTerms, renderCheckbox} = useAcceptedTerms();
|
||||
|
||||
const onSuccess = () =>
|
||||
toast.success(
|
||||
@@ -146,7 +148,9 @@ export default function RegisterIndividual({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col items-start gap-4">
|
||||
{renderCheckbox()}
|
||||
</div>
|
||||
<Button
|
||||
className="w-full lg:mt-8"
|
||||
color="purple"
|
||||
@@ -156,6 +160,7 @@ export default function RegisterIndividual({
|
||||
!name ||
|
||||
!password ||
|
||||
!confirmPassword ||
|
||||
!acceptedTerms ||
|
||||
password !== confirmPassword ||
|
||||
(hasCode ? !code : false)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {BsArrowRepeat} from "react-icons/bs";
|
||||
import InviteCard from "@/components/Medium/InviteCard";
|
||||
import {useRouter} from "next/router";
|
||||
import {PayPalScriptProvider} from "@paypal/react-paypal-js";
|
||||
import { usePaypalTracking } from "@/hooks/usePaypalTracking";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
@@ -31,6 +32,7 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
|
||||
const {users} = useUsers();
|
||||
const {groups} = useGroups();
|
||||
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user?.id});
|
||||
const trackingId = usePaypalTracking();
|
||||
|
||||
const isIndividual = () => {
|
||||
if (user?.type === "developer") return true;
|
||||
@@ -121,6 +123,7 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
|
||||
onSuccess={() => {
|
||||
setTimeout(reload, 500);
|
||||
}}
|
||||
trackingId={trackingId}
|
||||
/>
|
||||
</div>
|
||||
<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);
|
||||
}}
|
||||
loadScript
|
||||
trackingId={trackingId}
|
||||
/>
|
||||
</div>
|
||||
<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 {sessionOptions} from "@/lib/session";
|
||||
import {shuffle} from "lodash";
|
||||
import {Exam} from "@/interfaces/exam";
|
||||
import {Difficulty, Exam} from "@/interfaces/exam";
|
||||
import {Stat} from "@/interfaces/user";
|
||||
import {Module} from "@/interfaces";
|
||||
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.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 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}`},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {Exam, InstructorGender, Variant} from "@/interfaces/exam";
|
||||
import {getExams} from "@/utils/exams.be";
|
||||
import {Module} from "@/interfaces";
|
||||
const db = getFirestore(app);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
@@ -24,7 +25,7 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
|
||||
}
|
||||
|
||||
const {module, avoidRepeated, variant, instructorGender} = req.query as {
|
||||
module: string;
|
||||
module: Module;
|
||||
avoidRepeated: string;
|
||||
variant?: Variant;
|
||||
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,7 +1,13 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { app } from "@/firebase";
|
||||
import {getFirestore, collection, getDocs, setDoc, doc} from "firebase/firestore";
|
||||
import {
|
||||
getFirestore,
|
||||
collection,
|
||||
getDocs,
|
||||
setDoc,
|
||||
doc,
|
||||
} from "firebase/firestore";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import axios from "axios";
|
||||
@@ -18,13 +24,23 @@ const db = getFirestore(app);
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") return res.status(404).json({ok: false, reason: "Method not supported!"});
|
||||
if (req.method !== "POST")
|
||||
return res.status(404).json({ ok: false, reason: "Method not supported!" });
|
||||
if (!req.session.user) return res.status(401).json({ ok: false });
|
||||
|
||||
const accessToken = await getAccessToken();
|
||||
if (!accessToken) return res.status(401).json({ok: false, reason: "Authorization failed!"});
|
||||
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(
|
||||
`${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders/${id}/capture`,
|
||||
@@ -32,13 +48,15 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"PayPal-Client-Metadata-Id": trackingId,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (request.data.status === "COMPLETED") {
|
||||
const user = req.session.user;
|
||||
const subscriptionExpirationDate = req.session.user.subscriptionExpirationDate;
|
||||
const subscriptionExpirationDate =
|
||||
req.session.user.subscriptionExpirationDate;
|
||||
const today = moment(new Date());
|
||||
const dateToBeAddedTo = !subscriptionExpirationDate
|
||||
? today
|
||||
@@ -49,10 +67,32 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const updatedExpirationDate = dateToBeAddedTo.add(duration, duration_unit);
|
||||
await setDoc(
|
||||
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") {
|
||||
const snapshot = await getDocs(collection(db, "groups"));
|
||||
const groups: Group[] = (
|
||||
@@ -69,15 +109,24 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
async (x) =>
|
||||
await setDoc(
|
||||
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 });
|
||||
}
|
||||
|
||||
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();
|
||||
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>(
|
||||
`${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders`,
|
||||
@@ -34,11 +36,24 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
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",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
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 {calculateBandScore} from "@/utils/score";
|
||||
import {groupByModule, groupBySession} from "@/utils/stats";
|
||||
import { MODULE_ARRAY } from "@/utils/moduleUtils";
|
||||
import {getAuth} from "firebase/auth";
|
||||
import {collection, doc, getDoc, getDocs, getFirestore, query, updateDoc, where} from "firebase/firestore";
|
||||
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);
|
||||
if (moduleStats.length === 0) return;
|
||||
|
||||
@@ -87,11 +88,18 @@ async function update(req: NextApiRequest, res: NextApiResponse) {
|
||||
.filter((x) => x.total > 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 = {
|
||||
reading: calculateBandScore(readingLevel.correct, readingLevel.total, "reading", 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),
|
||||
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);
|
||||
|
||||
@@ -28,7 +28,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
}
|
||||
|
||||
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 snapshot = await uploadBytes(audioFileRef, binary);
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
} from "firebase/firestore";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
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);
|
||||
|
||||
@@ -69,12 +71,38 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
|
||||
}
|
||||
|
||||
const { id } = req.query as { id: string };
|
||||
const body = req.body as Ticket;
|
||||
|
||||
const snapshot = await getDoc(doc(db, "tickets", id));
|
||||
|
||||
const user = req.session.user;
|
||||
if (user.type === "admin" || user.type === "developer") {
|
||||
await setDoc(snapshot.ref, req.body, { merge: true });
|
||||
return res.status(200).json({ ok: true });
|
||||
const data = snapshot.data() as Ticket;
|
||||
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 });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import { sendEmail } from "@/email";
|
||||
import { app } from "@/firebase";
|
||||
import { Ticket, TicketTypeLabel } from "@/interfaces/ticket";
|
||||
import { Ticket, TicketTypeLabel, TicketWithCorporate } from "@/interfaces/ticket";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import {
|
||||
collection,
|
||||
@@ -9,35 +9,76 @@ import {
|
||||
getDocs,
|
||||
getFirestore,
|
||||
setDoc,
|
||||
where,
|
||||
query,
|
||||
} from "firebase/firestore";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import moment from "moment";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import ShortUniqueId from "short-unique-id";
|
||||
import { Group, CorporateUser } from "@/interfaces/user";
|
||||
|
||||
const db = getFirestore(app);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
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) {
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET") await get(req, res);
|
||||
if (req.method === "POST") await post(req, res);
|
||||
if (req.method === "GET") {
|
||||
await get(req, res);
|
||||
}
|
||||
}
|
||||
|
||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
const snapshot = await getDocs(collection(db, "tickets"));
|
||||
|
||||
res.status(200).json(
|
||||
snapshot.docs.map((doc) => ({
|
||||
const docs = snapshot.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
...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) {
|
||||
@@ -61,7 +102,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
description: body.description,
|
||||
},
|
||||
[body.reporter.email],
|
||||
`Ticket ${id}: ${body.subject}`,
|
||||
`Ticket ${id}: ${body.subject}`
|
||||
);
|
||||
} catch (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 {shouldRedirectHome} from "@/utils/navigation.disabled";
|
||||
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 useGroups from "@/hooks/useGroups";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
@@ -31,6 +31,8 @@ import ModuleLevelSelector from "@/components/Medium/ModuleLevelSelector";
|
||||
import Select from "@/components/Low/Select";
|
||||
import {InstructorGender} from "@/interfaces/exam";
|
||||
import {capitalize} from "lodash";
|
||||
import TopicModal from "@/components/Medium/TopicModal";
|
||||
import {v4} from "uuid";
|
||||
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||
const user = req.session.user;
|
||||
|
||||
@@ -90,6 +92,9 @@ function UserProfile({user, mutateUser}: Props) {
|
||||
const [preferredGender, setPreferredGender] = useState<InstructorGender | 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 [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 [isPreferredTopicsOpen, setIsPreferredTopicsOpen] = useState(false);
|
||||
|
||||
const {groups} = useGroups();
|
||||
const {users} = useUsers();
|
||||
|
||||
@@ -156,6 +163,7 @@ function UserProfile({user, mutateUser}: Props) {
|
||||
profilePicture,
|
||||
desiredLevels,
|
||||
preferredGender,
|
||||
preferredTopics,
|
||||
demographicInformation: {
|
||||
phone,
|
||||
country,
|
||||
@@ -350,6 +358,7 @@ function UserProfile({user, mutateUser}: Props) {
|
||||
{preferredGender && ["developer", "student"].includes(user.type) && (
|
||||
<>
|
||||
<Divider />
|
||||
<DoubleColumnRow>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor's Gender</label>
|
||||
<Select
|
||||
@@ -362,6 +371,28 @@ function UserProfile({user, mutateUser}: Props) {
|
||||
]}
|
||||
/>
|
||||
</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,
|
||||
TicketType,
|
||||
TicketTypeLabel,
|
||||
TicketWithCorporate,
|
||||
} from "@/interfaces/ticket";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||
@@ -28,7 +29,7 @@ import { useEffect, useState } from "react";
|
||||
import { BsArrowDown, BsArrowUp } from "react-icons/bs";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
|
||||
const columnHelper = createColumnHelper<Ticket>();
|
||||
const columnHelper = createColumnHelper<TicketWithCorporate>();
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(({ req, res }) => {
|
||||
const user = req.session.user;
|
||||
@@ -75,10 +76,26 @@ const TypesClassNames: { [key in TicketType]: string } = {
|
||||
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() {
|
||||
const [filteredTickets, setFilteredTickets] = useState<Ticket[]>([]);
|
||||
const [selectedTicket, setSelectedTicket] = useState<Ticket>();
|
||||
const [assigneeFilter, setAssigneeFilter] = useState<string>();
|
||||
const [sourceFilter, setSourceFilter] = useState<Source>("");
|
||||
|
||||
const [dateSorting, setDateSorting] = useState<"asc" | "desc">("desc");
|
||||
|
||||
const [typeFilter, setTypeFilter] = useState<TicketType>();
|
||||
@@ -90,7 +107,7 @@ export default function Tickets() {
|
||||
|
||||
const sortByDate = (a: Ticket, b: Ticket) => {
|
||||
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 (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(
|
||||
[...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 = [
|
||||
columnHelper.accessor("id", {
|
||||
@@ -119,7 +141,7 @@ export default function Tickets() {
|
||||
<span
|
||||
className={clsx(
|
||||
"rounded-lg p-1 px-2 text-white",
|
||||
TypesClassNames[info.getValue()],
|
||||
TypesClassNames[info.getValue()]
|
||||
)}
|
||||
>
|
||||
{TicketTypeLabel[info.getValue()]}
|
||||
@@ -148,7 +170,9 @@ export default function Tickets() {
|
||||
{dateSorting === "asc" && <BsArrowUp />}
|
||||
</button>
|
||||
) 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", {
|
||||
header: "Subject",
|
||||
@@ -162,7 +186,7 @@ export default function Tickets() {
|
||||
<span
|
||||
className={clsx(
|
||||
"rounded-lg p-1 px-2 text-white",
|
||||
StatusClassNames[info.getValue()],
|
||||
StatusClassNames[info.getValue()]
|
||||
)}
|
||||
>
|
||||
{TicketStatusLabel[info.getValue()]}
|
||||
@@ -173,6 +197,10 @@ export default function Tickets() {
|
||||
header: "Assignee",
|
||||
cell: (info) => users.find((x) => x.id === info.getValue())?.name || "",
|
||||
}),
|
||||
columnHelper.accessor("corporate", {
|
||||
header: "Corporate",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
];
|
||||
|
||||
const getAssigneeValue = () => {
|
||||
@@ -288,7 +316,7 @@ export default function Tickets() {
|
||||
{ value: "me", label: "Assigned to me" },
|
||||
...users
|
||||
.filter((x) =>
|
||||
["admin", "developer", "agent"].includes(x.type),
|
||||
["admin", "developer", "agent"].includes(x.type)
|
||||
)
|
||||
.map((u) => ({
|
||||
value: u.id,
|
||||
@@ -300,7 +328,7 @@ export default function Tickets() {
|
||||
onChange={(value) =>
|
||||
value
|
||||
? setAssigneeFilter(
|
||||
value.value === "me" ? user.id : value.value,
|
||||
value.value === "me" ? user.id : value.value
|
||||
)
|
||||
: setAssigneeFilter(undefined)
|
||||
}
|
||||
@@ -308,6 +336,18 @@ export default function Tickets() {
|
||||
isClearable
|
||||
/>
|
||||
</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>
|
||||
|
||||
<table className="bg-mti-purple-ultralight/40 w-full rounded-xl">
|
||||
@@ -320,7 +360,7 @@ export default function Tickets() {
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
header.getContext()
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
@@ -332,7 +372,7 @@ export default function Tickets() {
|
||||
<tr
|
||||
className={clsx(
|
||||
"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)}
|
||||
key={row.id}
|
||||
@@ -341,7 +381,7 @@ export default function Tickets() {
|
||||
<td className="w-fit items-center px-4 py-2" key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
cell.getContext()
|
||||
)}
|
||||
</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 {Exam, InstructorGender, Variant} from "@/interfaces/exam";
|
||||
import {Stat} from "@/interfaces/user";
|
||||
import {Difficulty, Exam, InstructorGender, SpeakingExam, Variant, WritingExam} from "@/interfaces/exam";
|
||||
import {DeveloperUser, Stat, StudentUser, User} from "@/interfaces/user";
|
||||
import {Module} from "@/interfaces";
|
||||
|
||||
export const getExams = async (
|
||||
db: Firestore,
|
||||
module: string,
|
||||
module: Module,
|
||||
avoidRepeated: string,
|
||||
// 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
|
||||
@@ -27,8 +28,10 @@ export const getExams = async (
|
||||
})),
|
||||
) as Exam[];
|
||||
|
||||
const variantExams: Exam[] = filterByVariant(allExams, variant);
|
||||
const genderedExams: Exam[] = filterByInstructorGender(variantExams, instructorGender);
|
||||
let exams: Exam[] = filterByVariant(allExams, variant);
|
||||
exams = filterByInstructorGender(exams, instructorGender);
|
||||
exams = await filterByDifficulty(db, exams, module, userId);
|
||||
exams = await filterByPreference(db, exams, module, userId);
|
||||
|
||||
if (avoidRepeated === "true") {
|
||||
const statsQ = query(collection(db, "stats"), where("user", "==", userId));
|
||||
@@ -38,12 +41,12 @@ export const getExams = async (
|
||||
id: doc.id,
|
||||
...doc.data(),
|
||||
})) 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) => {
|
||||
@@ -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");
|
||||
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 [];
|
||||
}
|
||||
};
|
||||
|
||||
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