Compare commits

...

66 Commits

Author SHA1 Message Date
Joao Ramos
2a27fbd02f Merge branch 'develop' into feature-paypal-simple 2024-03-05 14:45:51 +00:00
Joao Ramos
a86ed9f76c Added RAAS api and tracking
Improvements on paypal UI
2024-03-05 14:37:02 +00:00
Tiago Ribeiro
20b52d049d Updated the default to be 100 2024-03-04 00:14:04 +00:00
Tiago Ribeiro
165e33b188 Updated an error with the session 2024-03-03 12:01:05 +00:00
João Ramos
04cbcbc4cb Merged in feature-homepage-contacts-languages (pull request #43)
Feature homepage contacts languages

Approved-by: Tiago Ribeiro
2024-02-29 23:54:04 +00:00
Tiago Ribeiro
2feb9223c1 Merged develop into feature-homepage-contacts-languages 2024-02-29 23:53:42 +00:00
Joao Ramos
02d2d07f6c Paypal tab is now only displayed to admin or devs 2024-02-29 21:33:47 +00:00
Joao Ramos
ecd66d61f2 Updated exam abandon message 2024-02-29 21:33:07 +00:00
Joao Ramos
424b72efaf Uppercased the country code to prevent API errors 2024-02-29 18:35:30 +00:00
Joao Ramos
79e51d6294 Added support for the homepage languages 2024-02-29 18:26:41 +00:00
Tiago Ribeiro
773480875f Extremely wrong approach, but want to test 2024-02-28 22:44:18 +00:00
Joao Ramos
96d1b85f56 Removed HeaderGroup types on CSV export 2024-02-28 22:34:58 +00:00
Joao Ramos
cf20920fd8 Updated possible HeaderGroup types on CSV export 2024-02-28 22:29:07 +00:00
Joao Ramos
4114971244 Prevented date from wrapping 2024-02-28 20:01:43 +00:00
João Ramos
bee20388d9 Merged in feature-paypal-payments (pull request #40)
Added integration for paypal payments
2024-02-27 14:20:35 +00:00
João Ramos
bd97529658 Merged in feature-tickets-with-admin (pull request #42)
Added corporate display to Tickets table

Approved-by: Tiago Ribeiro
2024-02-27 14:17:43 +00:00
Tiago Ribeiro
d3c24d738c Merged develop into feature-tickets-with-admin 2024-02-27 14:17:28 +00:00
João Ramos
eac43a160d Merged in tickets-source (pull request #41)
Added Approach to accept ticket source

Approved-by: Tiago Ribeiro
2024-02-27 14:17:06 +00:00
Joao Ramos
24c3f506c6 Added corporate display to Tickets table 2024-02-26 23:27:10 +00:00
Joao Ramos
3e13ed5830 Added Approach to accept ticket source 2024-02-26 19:09:42 +00:00
Joao Ramos
9b5ff70037 Added search text to Paypal payments
Updated CSV Downlaod
Reordered the UI of the page
2024-02-26 18:46:21 +00:00
Joao Ramos
d7f1a4f6b2 Added integration for paypal payments 2024-02-25 16:59:21 +00:00
Tiago Ribeiro
b663e5c706 Updated the labels for the level 2024-02-24 22:35:13 +00:00
Tiago Ribeiro
efb99b31f2 Merge branch 'develop' 2024-02-23 15:20:10 +00:00
Tiago Ribeiro
03882d2a7e Improved some visual things requested by the client 2024-02-23 15:18:45 +00:00
Tiago Ribeiro
a3087567ea Merge branch 'develop' 2024-02-23 15:07:42 +00:00
João Ramos
e37afd5bbc Merged in feature-homepage-users (pull request #39)
Added API endpoints for agents load for the homepage

Approved-by: Alvaro Doria
2024-02-19 14:35:29 +00:00
João Ramos
cf5e827ca7 Merged in feature-archive-assignments (pull request #38)
Added approach to archive past assignments

Approved-by: Alvaro Doria
2024-02-19 14:35:20 +00:00
João Ramos
bfa9d039e2 Merged in bug-user-level-progress (pull request #37)
Bug user level progress

Approved-by: Alvaro Doria
2024-02-19 14:35:08 +00:00
Joao Ramos
62b915fbc1 Added API endpoints for agents load for the homepage 2024-02-18 18:04:54 +00:00
Joao Ramos
cdfafb3eea Added approach to archive past assignments 2024-02-18 11:46:08 +00:00
Joao Ramos
29cae5c3d2 Stats for Level exam are now being properly calculated 2024-02-18 11:09:25 +00:00
Joao Ramos
04f97b62c3 Filtered out level from students history display 2024-02-17 14:22:46 +00:00
Joao Ramos
52d309e7f4 Fixed NaN display on level progress 2024-02-17 10:43:55 +00:00
Tiago Ribeiro
dbf5b17f64 Merge branch 'develop' 2024-02-15 16:10:06 +00:00
Tiago Ribeiro
703fb0df5f Updated the generic Listening intro 2024-02-15 14:07:30 +00:00
Tiago Ribeiro
be4d2de76f Updated the module title to keep in mind the previous time spent 2024-02-14 11:31:35 +00:00
Tiago Ribeiro
44c61c2e5d Fixed a bug with the module evaluation, no idea why it was happening 2024-02-14 11:19:55 +00:00
João Ramos
764064bc28 Merged in feature-ticket-badge (pull request #36)
Added a badge with the amount of pending tickets assigned to the user

Approved-by: Tiago Ribeiro
2024-02-14 00:06:27 +00:00
Tiago Ribeiro
d87de9fea9 Updated the styling a little bit 2024-02-14 00:05:08 +00:00
Joao Ramos
b63ba3f316 Added a badge with the amount of pending tickets assigned to the user 2024-02-13 17:29:55 +00:00
Tiago Ribeiro
64b1d9266e Updated the Speaking Generation to save the topic as well 2024-02-13 16:26:56 +00:00
Tiago Ribeiro
b7cd1fb141 Merge branch 'feature/user-choose-topics' into develop 2024-02-13 16:07:12 +00:00
Tiago Ribeiro
30cb2f460c Changed the label on the Save button 2024-02-13 16:05:13 +00:00
Tiago Ribeiro
6a38b7a32e Updated the exam selection to get exams related to the user's topic preference 2024-02-13 16:04:46 +00:00
Joao Ramos
2a1b5236ee Revert "Updated the label for the Tickets button"
This reverts commit a99f6fd20e.
2024-02-13 15:43:24 +00:00
Joao Ramos
a99f6fd20e Updated the label for the Tickets button 2024-02-13 15:37:37 +00:00
Tiago Ribeiro
c0c9d22864 Added the ability for a user to select their preferred topics 2024-02-13 11:39:19 +00:00
Tiago Ribeiro
718782cfd5 Merged in develop (pull request #35)
Updated the main branch - 13/02/24
2024-02-13 00:52:45 +00:00
Tiago Ribeiro
f643430068 Merge branch 'develop' into feature/user-choose-topics 2024-02-13 00:45:56 +00:00
João Ramos
2823af7ef8 Merged in privacy-policy (pull request #33)
Added checkbox for accepted terms

Approved-by: Tiago Ribeiro
2024-02-13 00:44:55 +00:00
Tiago Ribeiro
57116f50e8 Hard coded the website link because there are some problems with ENV variables in the frontend 2024-02-13 00:44:16 +00:00
Tiago Ribeiro
e382a09ae8 Added the key "topic" to Writing, Speaking and Interactive Speaking exercises 2024-02-13 00:42:09 +00:00
João Ramos
b4c7c9a911 Merged in contact-us-form (pull request #32)
Contact Us support

Approved-by: Tiago Ribeiro
2024-02-13 00:40:23 +00:00
Tiago Ribeiro
86e920f102 Merged develop into contact-us-form 2024-02-13 00:39:59 +00:00
João Ramos
6f12a4a1db Merged in email-notification-ticket-close (pull request #34)
Added an email notification when the ticket is submitted
2024-02-13 00:39:39 +00:00
Tiago Ribeiro
a27a3c1fb0 Merged develop into contact-us-form 2024-02-13 00:38:56 +00:00
Joao Ramos
63618405bc Added an email notification when the ticket is submitted 2024-02-12 22:37:16 +00:00
João Ramos
7ab67fdf15 Merged develop into privacy-policy 2024-02-12 21:49:11 +00:00
Joao Ramos
17ec004a59 Added checkbox for accepted terms 2024-02-12 21:45:37 +00:00
Tiago Ribeiro
417bd7fecb Added tooltips to the student dashboard 2024-02-12 19:26:55 +00:00
Joao Ramos
e82895351d Published the tickets POST route 2024-02-12 18:18:25 +00:00
Tiago Ribeiro
4802310474 Added the ability to delete already finished assignments 2024-02-12 11:15:04 +00:00
Tiago Ribeiro
dc3373be6a Added the ability to choose a difficulty when generating an exam 2024-02-10 13:26:08 +00:00
Tiago Ribeiro
2e894622d0 Updated users to get exams related to their level 2024-02-10 09:10:52 +00:00
Tiago Ribeiro
1895b9e183 Disabled the navigation on the mobile menu 2024-02-09 16:48:25 +00:00
58 changed files with 3414 additions and 2058 deletions

View File

@@ -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",
},
],
},
];
},
};

View File

@@ -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,

View File

@@ -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,
});
};

View File

@@ -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">

View File

@@ -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(

View File

@@ -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) {

View 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>
);
}

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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}

View File

@@ -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" />}

View File

@@ -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>
);

View File

@@ -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>
</>

View File

@@ -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>

View 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>

View File

@@ -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);

View File

@@ -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.",
},
]}
/>

View 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;

View 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;
};

View 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};
}

View 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;
};

View File

@@ -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 };
}

View 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;

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -24,4 +24,5 @@ export interface Assignment {
instructorGender?: InstructorGender;
startDate: Date;
endDate: Date;
archived?: boolean;
}

View File

@@ -32,3 +32,7 @@ export const TicketStatusLabel: { [key in TicketStatus]: string } = {
"in-progress": "In Progress",
completed: "Completed",
};
export interface TicketWithCorporate extends Ticket {
corporate?: string;
}

View File

@@ -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 {

View File

@@ -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();

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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)
}

View File

@@ -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">

View 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 });
}

View File

@@ -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}`},
});

View File

@@ -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;

View 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);
}

View File

@@ -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!",
});
}

View File

@@ -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,
},
},
);

View 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" });
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 });

View File

@@ -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);

View 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);
}

View 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

View File

@@ -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&apos;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 || []}
/>
</>
)}

View File

@@ -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
View 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;

View File

@@ -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;
};

View File

@@ -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"];
};