Compare commits

...

92 Commits

Author SHA1 Message Date
Tiago Ribeiro
9b37b60be0 Improved the table 2024-07-30 23:22:30 +01:00
Tiago Ribeiro
4347d0cabb Merge branch 'develop' into feature/update-permission-ui 2024-07-30 23:20:16 +01:00
Tiago Ribeiro
0403773b8e Changed the IDs to now be words and allows the assignment to be like chosen 2024-07-30 23:18:50 +01:00
mzerone
8d99a6b03c ui updates for permissions. 2024-07-29 16:16:14 +02:00
Tiago Ribeiro
02320b9484 Tiny issue 2024-07-28 11:22:46 +01:00
Tiago Ribeiro
fb077fd8cc Updated the multiple choice 2024-07-27 17:43:26 +01:00
Tiago Ribeiro
b5a305485f Finalized the reading generation 2024-07-27 14:55:48 +01:00
Tiago Ribeiro
8f5b27e9ce Updated the FillBlanks to the new format 2024-07-27 14:38:45 +01:00
Tiago Ribeiro
9ef04b822a Updated the design of the feedback 2024-07-27 00:04:52 +01:00
Tiago Ribeiro
a6160c3cf0 Updated and fixed the level generation 2024-07-26 23:43:23 +01:00
Tiago Ribeiro
8f6639b7fc Finalized the Level Generation 2024-07-26 14:09:08 +01:00
Tiago Ribeiro
6a803fe137 Merge branch 'develop' of bitbucket.org:ecropdev/ielts-ui into develop 2024-07-26 11:51:03 +01:00
Carlos Mesquita
d7f6a4dde7 Undefined was causing record page to crash 2024-07-26 11:00:18 +01:00
Tiago Ribeiro
6058e510de Added the ability to generate custom level exams, still WIP in some parts 2024-07-26 10:29:36 +01:00
carlos.mesquita
7208530879 Merged in feature/ai-detection (pull request #57)
Used main branch as base branch in the last time

Approved-by: Tiago Ribeiro
2024-07-25 21:01:56 +00:00
Tiago Ribeiro
9b6c545932 Merged develop into feature/ai-detection 2024-07-25 21:00:40 +00:00
João Ramos
afb9071758 Merged in ENCOA-56_Permissions (pull request #55)
ENCOA-56 Permissions

Approved-by: Tiago Ribeiro
2024-07-25 21:00:28 +00:00
Tiago Ribeiro
d50393930e Merged develop into ENCOA-56_Permissions 2024-07-25 21:00:19 +00:00
João Ramos
03e1f2cfa3 Merged in master-corporate (pull request #54)
Master corporate

Approved-by: Tiago Ribeiro
2024-07-25 21:00:04 +00:00
Carlos Mesquita
877d2f359f Used main branch as base branch in the last time 2024-07-25 16:59:15 +01:00
Joao Ramos
45df9837e7 Added permissions to filter out the user update 2024-07-25 11:23:11 +01:00
Joao Ramos
923319051c Added code role validation 2024-07-25 09:43:11 +01:00
Joao Ramos
f6b4d6ad52 Initial approach where I replaced all the entries for checkAccess 2024-07-25 09:13:13 +01:00
Joao Ramos
19d16c9cef Added new permission system 2024-07-24 18:52:02 +01:00
Joao Ramos
daa27e41b3 Merge branch 'develop' into master-corporate 2024-07-24 09:50:53 +01:00
Joao Ramos
916fa66446 Added linked account 2024-07-24 09:25:29 +01:00
Tiago Ribeiro
10a3243756 Updated it to work with the new canges 2024-07-23 14:43:24 +01:00
Tiago Ribeiro
a1c7f70329 Solved some small issues 2024-07-22 23:35:22 +01:00
Tiago Ribeiro
bd2efb0ef5 Updated the color of the task response 2024-07-22 23:30:22 +01:00
Joao Ramos
34065f1f6e Merge branch 'develop' into master-corporate 2024-07-20 12:38:52 +01:00
Tiago Ribeiro
41873f80d7 ENCOA-66: Payment record not sorting 2024-07-18 15:45:28 +01:00
João Ramos
a1b67c017d Merged in ENCOA-57_EditExamsGenerate (pull request #53)
ENCOA-57 EditExamsGenerate

Approved-by: Tiago Ribeiro
2024-07-18 09:32:05 +00:00
Joao Ramos
13fd7e1ee5 Updated Level Generation 2024-07-17 23:44:53 +01:00
Joao Ramos
4996417218 Fixed sentence header mapping 2024-07-17 23:00:16 +01:00
Joao Ramos
60d436b5b9 Fixed wrong count 2024-07-13 17:25:10 +01:00
Joao Ramos
8d39a20267 Fixed broken list 2024-07-13 17:22:55 +01:00
Joao Ramos
5d46d7e453 Added initial support for "mastercorporate" 2024-07-13 17:19:42 +01:00
Joao Ramos
15f9fb320d Changed approach on available type selection 2024-07-09 23:42:16 +01:00
Joao Ramos
494fc9bab6 Changed approach on available type selection 2024-07-07 16:59:49 +01:00
Joao Ramos
0c5c024098 Added Match Sentences to Reading Generation 2024-07-07 16:43:14 +01:00
Joao Ramos
903a567805 Fixed error with update part 2024-07-04 01:35:10 +01:00
Joao Ramos
df3929d5e6 Added parse for speaking generation 2024-07-04 01:34:07 +01:00
Joao Ramos
6d62500596 Added write the blanks for Listening 2024-07-04 01:23:59 +01:00
Joao Ramos
e5e4e87752 Added Listening Multiple Choice Edit 2024-07-04 01:15:33 +01:00
Joao Ramos
0b3e686f3f Match sentence log 2024-07-04 00:25:04 +01:00
Joao Ramos
3da87cce60 Added write blanks edit 2024-07-04 00:19:07 +01:00
Joao Ramos
c9daba17e1 Added Fill Blanks Edits + True False Edit 2024-07-03 11:13:13 +01:00
João Ramos
5cfd6d56a6 Merged in bug-fixing-19-Jun-24 (pull request #52)
Bug fixing 19 Jun 24

Approved-by: Tiago Ribeiro
2024-06-25 11:47:24 +00:00
Joao Ramos
ec8c06ca94 Filtered out the fields from the UserCard in the list wouldn't have anything to display 2024-06-24 11:12:16 +01:00
Joao Ramos
77a22b3ab3 Added a screen to display the desired levels for the students of a specific corporate 2024-06-22 00:16:30 +01:00
Joao Ramos
e79139174b Added an initial filter for corporate on records 2024-06-21 23:22:49 +01:00
Joao Ramos
61a86394ed Added persistance to the selected user record 2024-06-20 23:20:52 +01:00
Joao Ramos
f6741dd80e Added date filter to code list 2024-06-20 22:57:34 +01:00
Joao Ramos
ce6708be6e Minor fixing on a duplicated key on table 2024-06-20 22:34:02 +01:00
Joao Ramos
b62cae2e3a Filtered done ticket out of the view 2024-06-19 23:14:05 +01:00
Joao Ramos
d73b6d9d12 Added badges for students 2024-06-19 23:00:43 +01:00
Joao Ramos
c11906a395 Standardized the access to the list of users 2024-06-19 21:59:35 +01:00
Joao Ramos
a29b0b56d9 Changed to the correct format for CSV 2024-06-19 21:39:27 +01:00
Tiago Ribeiro
53dbf99fba Fixed some more issues with the Speaking 2024-06-18 22:16:31 +01:00
Tiago Ribeiro
cb49e15cb0 Updated the speaking and interactive speaking to the new format 2024-06-18 10:02:03 +01:00
Tiago Ribeiro
0eddded560 Updated part of the speaking accordingly 2024-06-13 18:17:07 +01:00
Tiago Ribeiro
11c6f70576 Updated a simple bug 2024-06-11 22:44:00 +01:00
Tiago Ribeiro
6712e89c47 Updated the format of the interactive speaking 2024-06-11 11:20:54 +01:00
Tiago Ribeiro
9959cf4294 Removed the persistence for the Speaking exam for now 2024-06-08 10:39:26 +01:00
Tiago Ribeiro
daec246835 Updated the Level Exam to work based on Parts 2024-06-07 13:25:18 +01:00
Tiago Ribeiro
8ea97ee944 Added a new feature to check for and register inactivity during an exam 2024-06-04 22:18:45 +01:00
Tiago Ribeiro
975f4c8285 Updated Firebase to use a service account depending on the environment 2024-05-29 09:06:46 +01:00
Tiago Ribeiro
f0b85409c9 Merge branch 'main' into develop 2024-05-27 16:37:09 +01:00
Tiago Ribeiro
bdd862c633 Updated the state to be active on payment 2024-05-27 13:08:11 +01:00
Tiago Ribeiro
4166781f7e Improved some issues with the payment 2024-05-27 13:05:38 +01:00
Tiago Ribeiro
1f8e9106de Updated the code to set the expiry date to the end of the day 2024-05-27 12:19:25 +01:00
Tiago Ribeiro
9e651358d5 Updated the code to return a 400 when it is not a success 2024-05-27 12:16:08 +01:00
Tiago Ribeiro
5aed336c96 Added a log 2024-05-27 11:02:11 +01:00
Tiago Ribeiro
85b94512e9 Merge branch 'develop' into ENCOA-38/add-validity-date-for-discounts 2024-05-23 19:22:31 +01:00
Tiago Ribeiro
906646ebce Created the validity dates for discounts 2024-05-23 19:21:52 +01:00
Tiago Ribeiro
96108a4958 Reverted to have checks 2024-05-23 17:22:57 +01:00
Tiago Ribeiro
fb449f2054 Updated the status when the transaction is not successful 2024-05-21 15:40:18 +01:00
Tiago Ribeiro
d5ee3d9519 Added a log for debugging 2024-05-21 15:35:57 +01:00
Tiago Ribeiro
4e20ec6575 Removed a check from the webhook 2024-05-21 12:04:31 +01:00
Tiago Ribeiro
836b674076 Added some changes to the propagate corporate changes 2024-05-21 11:21:14 +01:00
Tiago Ribeiro
5086c6fb09 Solved a visual bug 2024-05-21 11:09:36 +01:00
Tiago Ribeiro
489c9c3b7e Possibly solved part of the issue with speaking 2024-05-20 21:28:45 +01:00
Tiago Ribeiro
e3ded29e77 Merge branch 'develop' 2024-05-20 21:09:43 +01:00
Tiago Ribeiro
16419a5584 Fixed a bug introduced on the last one 2024-05-20 11:23:52 +01:00
Tiago Ribeiro
3e3b24cc30 Solved a bug for level test 2024-05-20 11:18:46 +01:00
Tiago Ribeiro
841698ba10 Updated the profile to also have the focus in it 2024-05-20 11:13:09 +01:00
Tiago Ribeiro
d50904611c Added a missing space 2024-05-16 15:42:13 +01:00
Tiago Ribeiro
e77fd16d26 Added a space to it 2024-05-16 15:03:31 +01:00
Tiago Ribeiro
649f24e4ae Updated the showcase 2024-05-16 14:51:19 +01:00
Tiago Ribeiro
2f0cbfe74e Removed the billing details modal 2024-05-16 14:30:44 +01:00
Tiago Ribeiro
d022bd078a Updated the currencies to have OMR as well 2024-05-16 13:44:27 +01:00
Tiago Ribeiro
c18afee9ad Updated the packages 2024-05-16 13:34:18 +01:00
117 changed files with 19771 additions and 7702 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,5 @@
src/constants/test_firebase.json
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies # dependencies

7594
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@beam-australia/react-env": "^3.1.1", "@beam-australia/react-env": "^3.1.1",
"@dnd-kit/core": "^6.1.0", "@dnd-kit/core": "^6.1.0",
"@firebase/util": "^1.9.7",
"@headlessui/react": "^1.7.13", "@headlessui/react": "^1.7.13",
"@mdi/js": "^7.1.96", "@mdi/js": "^7.1.96",
"@mdi/react": "^1.6.1", "@mdi/react": "^1.6.1",
@@ -47,7 +48,6 @@
"next": "13.1.6", "next": "13.1.6",
"nodemailer": "^6.9.5", "nodemailer": "^6.9.5",
"nodemailer-express-handlebars": "^6.1.0", "nodemailer-express-handlebars": "^6.1.0",
"paymob-react": "git+https://github.com/tiago-ecrop/paymob-react-oman.git",
"primeicons": "^6.0.1", "primeicons": "^6.0.1",
"primereact": "^9.2.3", "primereact": "^9.2.3",
"qrcode": "^1.5.3", "qrcode": "^1.5.3",
@@ -68,9 +68,10 @@
"react-select": "^5.7.5", "react-select": "^5.7.5",
"react-string-replace": "^1.1.0", "react-string-replace": "^1.1.0",
"react-toastify": "^9.1.2", "react-toastify": "^9.1.2",
"react-tooltip": "^5.27.1",
"react-xarrows": "^2.0.2", "react-xarrows": "^2.0.2",
"read-excel-file": "^5.7.1", "read-excel-file": "^5.7.1",
"short-unique-id": "^5.0.2", "short-unique-id": "5.0.2",
"stripe": "^13.10.0", "stripe": "^13.10.0",
"swr": "^2.1.3", "swr": "^2.1.3",
"tailwind-scrollbar-hide": "^1.1.7", "tailwind-scrollbar-hide": "^1.1.7",
@@ -96,7 +97,6 @@
"autoprefixer": "^10.4.13", "autoprefixer": "^10.4.13",
"husky": "^8.0.3", "husky": "^8.0.3",
"postcss": "^8.4.21", "postcss": "^8.4.21",
"tailwindcss": "^3.2.4", "tailwindcss": "^3.2.4"
"types/": "paypal/react-paypal-js"
} }
} }

1
public/mat-icon-info.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed"><path d="M440-280h80v-240h-80v240Zm40-320q17 0 28.5-11.5T520-640q0-17-11.5-28.5T480-680q-17 0-28.5 11.5T440-640q0 17 11.5 28.5T480-600Zm0 520q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>

After

Width:  |  Height:  |  Size: 535 B

View File

@@ -0,0 +1,193 @@
import Image from "next/image";
import clsx from "clsx";
import RadialProgressBar from "./RadialProgressBar";
import { AIDetectionAttributes } from "@/interfaces/exam";
import { Tooltip } from 'react-tooltip';
import SegmentedProgressBar from "./SegmentedProgressBar";
// Colors and texts scrapped from gpt's zero react bundle
const AIDetection: React.FC<AIDetectionAttributes> = ({ predicted_class, confidence_category, class_probabilities, sentences }) => {
const probabilityTooltipContent = `
GTP's Zero deep learning model predicts the <br/>
probability this text has been entirely <br/>
generated by AI. For instance, a 40% AI <br/>
probability does not indicate that the text<br/>
contains 40% AI-written content. Rather, it<br/>
indicates the text is more likely to be partially<br/>
human written than be entirely AI-written.
`;
const confidenceTooltipContent = `
Confidence scores are a safeguard to better<br/>
understand AI identification results. GTP Zero<br/>
trained it's deep learning model on a diverse<br/>
dataset of millions of human and AI-written<br/>
documents. Green scores indicate that you can scan<br/>
with confidence that the model has classified<br/>
many similar documents with high accuracy.<br/>
Red scores indicate that this text is dissimilar<br/>
to the ones in their training set, which can impact<br/>
the model's accuracy, and to proceed with caution.
`;
const confidenceKeywords = ["moderately", "highly", "confident", "uncertain"];
var confidence = {
low: {
ai: "GPT Zero is uncertain about this text. If GPT Zero had to classify it, it would be considered",
human: "GPT Zero is uncertain about this text. If GPT Zero had to classify it, it would likely be considered",
mixed: "GPT Zero is uncertain about this text. If GPT Zero had to classify it, it would likely be a"
},
medium: {
ai: "GPT Zero is moderately confident this text was",
human: "GPT Zero is moderately confident this text is entirely",
mixed: "GPT Zero is moderately confident this text is a"
},
high: {
ai: "GPT Zero is highly confident this text was",
human: "GPT Zero is highly confident this text is entirely",
mixed: "GPT Zero is highly confident this text is a"
}
}
var classPrediction = {
ai: {
background: "bg-ai-detection-result-ai-bg",
color: "text-ai-detection-result-ai",
text: "ai generated"
},
mixed: {
background: "bg-ai-detection-result-mixed-bg",
color: "text-ai-detection-result-mixed",
text: "mix of ai and human"
},
human: {
background: "bg-ai-detection-result-human-bg",
color: "text-ai-detection-result-human",
text: "human"
}
}
const segments = [
{ percentage: Math.round(class_probabilities["human"] * 100), subtitle: 'human', color: "ai-detection-result-human" },
{ percentage: Math.round(class_probabilities["mixed"] * 100), subtitle: 'mixed', color: "ai-detection-result-mixed" },
{ percentage: Math.round(class_probabilities["ai"] * 100), subtitle: 'ai', color: "ai-detection-result-ai" }
];
const styleConfidenceText = (text: string): [string, string[]] => {
const keywords: string[] = [];
const styledText = text.split(" ").map(word => {
if (confidenceKeywords.includes(word)) {
keywords.push(word);
return `<span style="font-weight: 500; text-decoration: underline;">${word}</span>`;
}
return word
}).join(" ");
return [styledText, keywords];
};
const confidenceText = confidence[confidence_category][predicted_class];
const [styledText, keywords] = styleConfidenceText(confidenceText);
const tooltipStyle = {
"backgroundColor": "rgb(255, 255, 255)",
"color": "#8992B1",
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
borderRadius: '0.125rem'
}
const highestProbability = Math.max(class_probabilities["ai"], class_probabilities["human"], class_probabilities["mixed"]);
const spanTextColor = highestProbability === class_probabilities["ai"]
? "#f4bf4f"
: highestProbability === class_probabilities["human"]
? "#50c08a"
: "#93aafb";
let spanClassName = highestProbability === class_probabilities["ai"]
? "text-ai-detection-result-ai"
: highestProbability === class_probabilities["human"]
? "text-ai-detection-result-human"
: "text-ai-detection-result-mixed";
spanClassName = `${spanClassName} font-bold text-lg`
const percentage = Math.round(highestProbability * 100)
const hasHighlightedForAI = sentences.some(item => item.highlight_sentence_for_ai);
return (
<>
<Tooltip id="probability-tooltip" className="z-50 bg-white shadow-md rounded-sm" style={tooltipStyle} />
<Tooltip id="confidence-tooltip" className="z-50 bg-white shadow-md rounded-sm" style={tooltipStyle} />
<div className="flex flex-col bg-white p-6 rounded-lg shadow-lg gap-16">
<h1 className="text-lg font-semibold">GPT Zero AI Detection Results</h1>
<div className="flex flex-row -md:flex-col -lg:gap-0 -xl:gap-10 gap-20 items-stretch -md:items-center">
<div className="flex -md:w-5/6 w-1/2 justify-center">
<div className="flex flex-col border rounded-xl">
<h1 className="border-b p-6 font-medium">Text Classification</h1>
<div className="flex flex-row gap-8 items-center p-6">
<RadialProgressBar
percentage={percentage}
text={predicted_class}
color={spanTextColor}
spanClassName={spanClassName}
/>
<div className="flex flex-col gap-1 text-sm">
<div className="flex flex-row items-center">
<span className="mr-2 text-ai-detection-result-ai-text font-semibold text-xl">
{`${Math.round(class_probabilities["ai"] * 100)}%`}
</span>
<span className="text-sm -md:text-xs text-ai-detection-text">Probability AI generated</span>
<a data-tooltip-id="probability-tooltip" data-tooltip-html={probabilityTooltipContent} className='ml-1 flex items-center justify-center'>
<Image src="/mat-icon-info.svg" width={24} height={24} alt="Probability Tooltip" />
</a>
</div>
<div className="flex flex-row items-center gap-1">
<div className={clsx(
"rounded-full w-3 h-3",
confidence_category == 'low' ?
"bg-ai-detection-confidence-low border border-ai-detection-confidence-border" : "bg-ai-detection-confidence-low-transparent"
)}></div>
<div className={clsx(
"rounded-full w-3 h-3",
confidence_category == 'medium' ?
"bg-ai-detection-confidence-medium border border-ai-detection-confidence-border" : "bg-ai-detection-confidence-medium-transparent"
)}></div>
<div className={clsx(
"rounded-full w-3 h-3 mr-2",
confidence_category == 'high' ?
"bg-ai-detection-confidence-high border border-ai-detection-confidence-border" : "bg-ai-detection-confidence-high-transparent"
)}></div>
<span className="text-sm -md:text-xs text-ai-detection-text">{keywords.join(' ')}</span>
<a data-tooltip-id="confidence-tooltip" data-tooltip-html={confidenceTooltipContent} className='ml-1 flex items-center justify-center'>
<Image src="/mat-icon-info.svg" width={24} height={24} alt="Probability Tooltip" />
</a>
</div>
</div>
</div>
</div>
</div>
<div className="flex flex-col border rounded-xl -md:w-5/6 w-2/6">
<h1 className="border-b p-6 font-medium">Probability Breakdown</h1>
<div className="flex items-center w-full h-full">
<SegmentedProgressBar segments={segments} className="w-full px-8 -md:py-8 text-ai-detection-text" />
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-row items-center">
<div dangerouslySetInnerHTML={{ __html: styledText }} className="mr-2"></div>
<div className={clsx(
"flex items-center justify-center p-2 rounded",
classPrediction[predicted_class]['color'],
classPrediction[predicted_class]['background']
)}>
<span className="text-sm">{classPrediction[predicted_class]['text']}</span>
</div>
</div>
{(hasHighlightedForAI && <div>
Sentences that are likely written by AI are <span className="font-semibold bg-ai-detection-highlight">highlighted</span>.
</div>)}
</div>
</div >
<div>
{sentences.map((item, index) => (
<span
key={index}
className={item.highlight_sentence_for_ai ? 'bg-ai-detection-highlight' : ''}
>
{item.sentence}{' '}
</span>
))}
</div>
</>
)
}
export default AIDetection;

View File

@@ -77,15 +77,8 @@ export default function FillBlanks({
onBack, onBack,
}: FillBlanksExercise & CommonProps) { }: FillBlanksExercise & CommonProps) {
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions); const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
const [currentBlankId, setCurrentBlankId] = useState<string>();
const [isDrawerShowing, setIsDrawerShowing] = useState(false);
const hasExamEnded = useExamStore((state) => state.hasExamEnded); const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const allBlanks = Array.from(text.match(/({{\d+}})/g) || []).map((x) => x.replaceAll("{", "").replaceAll("}", ""));
useEffect(() => {
setTimeout(() => setIsDrawerShowing(!!currentBlankId), 100);
}, [currentBlankId]);
useEffect(() => { useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type}); if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
@@ -94,9 +87,17 @@ export default function FillBlanks({
const calculateScore = () => { const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0; const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = answers.filter( const correct = answers.filter((x) => {
(x) => solutions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false, const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution.toLowerCase();
).length; if (!solution) return false;
const option = words.find((w) =>
typeof w === "string" ? w.toLowerCase() === x.solution.toLowerCase() : w.letter.toLowerCase() === x.solution.toLowerCase(),
);
if (!option) return false;
return solution === (typeof option === "string" ? option.toLowerCase() : option.word.toLowerCase());
}).length;
const missing = total - answers.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length; const missing = total - answers.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return {total, correct, missing}; return {total, correct, missing};
@@ -104,49 +105,29 @@ export default function FillBlanks({
const renderLines = (line: string) => { const renderLines = (line: string) => {
return ( return (
<span className="text-base leading-5"> <div className="text-base leading-5">
{reactStringReplace(line, /({{\d+}})/g, (match) => { {reactStringReplace(line, /({{\d+}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, ""); const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = answers.find((x) => x.id === id); const userSolution = answers.find((x) => x.id === id);
return ( return (
<button <input
className={clsx( className={clsx(
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1", "rounded-full hover:text-white focus:ring-0 focus:outline-none focus:!text-white focus:bg-mti-purple transition duration-300 ease-in-out my-1 px-5 py-2 text-center",
!userSolution && "w-6 h-6 text-center text-mti-purple-light bg-mti-purple-ultralight", !userSolution && "text-center text-mti-purple-light bg-mti-purple-ultralight",
currentBlankId === id && "text-white !bg-mti-purple-light ", userSolution && "px-5 py-2 text-center text-mti-purple-dark bg-mti-purple-ultralight",
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
)} )}
onClick={() => setCurrentBlankId(id)}> onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), {id, solution: e.target.value}])}
{userSolution ? userSolution.solution : id} value={userSolution?.solution}></input>
</button>
); );
})} })}
</span> </div>
); );
}; };
return ( return (
<> <>
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20"> <div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
{(!!currentBlankId || isDrawerShowing) && (
<WordsDrawer
key={currentBlankId}
blankId={currentBlankId}
words={words.map((word) => ({word, isDisabled: allowRepetition ? false : answers.map((x) => x.solution).includes(word)}))}
previouslySelectedWord={currentBlankId ? answers.find((x) => x.id === currentBlankId)?.solution : undefined}
isOpen={isDrawerShowing}
onCancel={() => setCurrentBlankId(undefined)}
onAnswer={(solution: string) => {
setAnswers((prev) => [...prev.filter((x) => x.id !== currentBlankId), {id: currentBlankId!, solution}]);
if (allBlanks.findIndex((x) => x === currentBlankId) + 1 < allBlanks.length) {
setCurrentBlankId(allBlanks[allBlanks.findIndex((x) => x === currentBlankId) + 1]);
return;
}
setCurrentBlankId(undefined);
}}
/>
)}
<span className="text-sm w-full leading-6"> <span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => ( {prompt.split("\\n").map((line, index) => (
<Fragment key={index}> <Fragment key={index}>
@@ -163,6 +144,26 @@ export default function FillBlanks({
</p> </p>
))} ))}
</span> </span>
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-4">
<span className="font-medium text-mti-purple-dark">Options</span>
<div className="flex gap-4 flex-wrap">
{words.map((v) => {
const text = typeof v === "string" ? v : `${v.letter} - ${v.word}`;
return (
<span
className={clsx(
"border border-mti-purple-light rounded-full px-3 py-0.5 transition ease-in-out duration-300",
!!answers.find((x) => x.solution.toLowerCase() === (typeof v === "string" ? v : v.letter).toLowerCase()) &&
"bg-mti-purple-dark text-white",
)}
key={text}>
{text}
</span>
);
})}
</div>
</div>
</div> </div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8"> <div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">

View File

@@ -16,12 +16,12 @@ const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mo
export default function InteractiveSpeaking({ export default function InteractiveSpeaking({
id, id,
title, title,
first_title,
second_title,
examID, examID,
text,
type, type,
prompts, prompts,
userSolutions, userSolutions,
updateIndex,
onNext, onNext,
onBack, onBack,
}: InteractiveSpeakingExercise & CommonProps) { }: InteractiveSpeakingExercise & CommonProps) {
@@ -36,31 +36,6 @@ export default function InteractiveSpeaking({
const hasExamEnded = useExamStore((state) => state.hasExamEnded); const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const saveToStorage = async (previousURL?: string) => {
if (mediaBlob && mediaBlob.startsWith("blob")) {
const blobBuffer = await downloadBlob(mediaBlob);
const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"});
const seed = Math.random().toString().replace("0.", "");
const formData = new FormData();
formData.append("audio", audioFile, `${seed}.wav`);
formData.append("root", "speaking_recordings");
const config = {
headers: {
"Content-Type": "audio/wav",
},
};
const response = await axios.post<{path: string}>("/api/storage/insert", formData, config);
if (previousURL && !previousURL.startsWith("blob")) await axios.post("/api/storage/delete", {path: previousURL});
return response.data.path;
}
return undefined;
};
const back = async () => { const back = async () => {
setIsLoading(true); setIsLoading(true);
@@ -91,7 +66,9 @@ export default function InteractiveSpeaking({
return; return;
} }
setIsLoading(false); setIsLoading(false);
setQuestionIndex(0);
onNext({ onNext({
exercise: id, exercise: id,
@@ -111,10 +88,6 @@ export default function InteractiveSpeaking({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [userSolutions, mediaBlob, answers]); }, [userSolutions, mediaBlob, answers]);
useEffect(() => {
if (updateIndex) updateIndex(questionIndex);
}, [questionIndex, updateIndex]);
useEffect(() => { useEffect(() => {
if (hasExamEnded) { if (hasExamEnded) {
const answer = { const answer = {
@@ -154,13 +127,10 @@ export default function InteractiveSpeaking({
}, [answers, questionIndex]); }, [answers, questionIndex]);
const saveAnswer = async (index: number) => { const saveAnswer = async (index: number) => {
const previousURL = answers.find((x) => x.questionIndex === questionIndex)?.blob;
const audioPath = await saveToStorage(previousURL);
const answer = { const answer = {
questionIndex, questionIndex,
prompt: prompts[questionIndex].text, prompt: prompts[questionIndex].text,
blob: audioPath ? audioPath : mediaBlob!, blob: mediaBlob!,
}; };
setAnswers((prev) => [...prev.filter((x) => x.questionIndex !== index), answer]); setAnswers((prev) => [...prev.filter((x) => x.questionIndex !== index), answer]);
@@ -185,7 +155,7 @@ export default function InteractiveSpeaking({
<div className="flex flex-col h-full w-full gap-9"> <div className="flex flex-col h-full w-full gap-9">
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16"> <div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<span className="font-semibold">{title}</span> <span className="font-semibold">{!!first_title && !!second_title ? `${first_title} & ${second_title}` : title}</span>
</div> </div>
{prompts && prompts.length > 0 && ( {prompts && prompts.length > 0 && (
<div className="flex flex-col gap-4 w-full items-center"> <div className="flex flex-col gap-4 w-full items-center">
@@ -204,7 +174,7 @@ export default function InteractiveSpeaking({
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center"> <div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
<p className="text-base font-normal">Record your answer:</p> <p className="text-base font-normal">Record your answer:</p>
<div className="flex gap-8 items-center justify-center py-8"> <div className="flex gap-8 items-center justify-center py-8">
{status === "idle" && !mediaBlob && ( {status === "idle" && (
<> <>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" /> <div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
{status === "idle" && ( {status === "idle" && (
@@ -283,9 +253,9 @@ export default function InteractiveSpeaking({
</div> </div>
</> </>
)} )}
{((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && ( {status === "stopped" && mediaBlobUrl && (
<> <>
<Waveform audio={mediaBlobUrl ? mediaBlobUrl : mediaBlob!} waveColor="#FCDDEC" progressColor="#EF5DA8" /> <Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<BsTrashFill <BsTrashFill
className="text-mti-gray-cool cursor-pointer w-5 h-5" className="text-mti-gray-cool cursor-pointer w-5 h-5"

View File

@@ -87,10 +87,6 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
return {total, correct, missing}; return {total, correct, missing};
}; };
useEffect(() => {
console.log(answers);
}, [answers]);
useEffect(() => { useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type}); if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps

View File

@@ -3,6 +3,7 @@ import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam"
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import clsx from "clsx"; import clsx from "clsx";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import reactStringReplace from "react-string-replace";
import {CommonProps} from "."; import {CommonProps} from ".";
import Button from "../Low/Button"; import Button from "../Low/Button";
@@ -14,11 +15,24 @@ function Question({
userSolution, userSolution,
onSelectOption, onSelectOption,
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) { }: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
const renderPrompt = (prompt: string) => {
return reactStringReplace(prompt, /((<u>)[\w\s']+(<\/u>))/g, (match) => {
const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
return word.length > 0 ? <u>{word}</u> : null;
});
};
return ( return (
<div className="flex flex-col gap-10"> <div className="flex flex-col gap-10">
<span className=""> {isNaN(Number(id)) ? (
{id} - {prompt} <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
</span> ) : (
<span className="">
<>
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
</>
</span>
)}
<div className="flex flex-wrap gap-4 justify-between"> <div className="flex flex-wrap gap-4 justify-between">
{variant === "image" && {variant === "image" &&
options.map((option) => ( options.map((option) => (
@@ -51,16 +65,7 @@ function Question({
); );
} }
export default function MultipleChoice({ export default function MultipleChoice({id, prompt, type, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
id,
prompt,
type,
questions,
userSolutions,
updateIndex,
onNext,
onBack,
}: MultipleChoiceExercise & CommonProps) {
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions); const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
const {questionIndex, setQuestionIndex} = useExamStore((state) => state); const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
@@ -79,10 +84,6 @@ export default function MultipleChoice({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]); }, [hasExamEnded]);
useEffect(() => {
if (updateIndex) updateIndex(questionIndex);
}, [questionIndex, updateIndex]);
const onSelectOption = (option: string) => { const onSelectOption = (option: string) => {
const question = questions[questionIndex]; const question = questions[questionIndex];
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]); setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);

View File

@@ -7,18 +7,20 @@ import Button from "../Low/Button";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {downloadBlob} from "@/utils/evaluation"; import {downloadBlob} from "@/utils/evaluation";
import axios from "axios"; import axios from "axios";
import Modal from "../Modal";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false}); const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), { const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
ssr: false, ssr: false,
}); });
export default function Speaking({id, title, text, video_url, type, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) { export default function Speaking({id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
const [recordingDuration, setRecordingDuration] = useState(0); const [recordingDuration, setRecordingDuration] = useState(0);
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
const [mediaBlob, setMediaBlob] = useState<string>(); const [mediaBlob, setMediaBlob] = useState<string>();
const [audioURL, setAudioURL] = useState<string>(); const [audioURL, setAudioURL] = useState<string>();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isPromptsModalOpen, setIsPromptsModalOpen] = useState(false);
const hasExamEnded = useExamStore((state) => state.hasExamEnded); const hasExamEnded = useExamStore((state) => state.hasExamEnded);
@@ -74,26 +76,18 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
}, [isRecording]); }, [isRecording]);
const next = async () => { const next = async () => {
setIsLoading(true);
const storagePath = await saveToStorage();
setIsLoading(false);
onNext({ onNext({
exercise: id, exercise: id,
solutions: storagePath ? [{id, solution: storagePath}] : [], solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 0, total: 100, missing: 0}, score: {correct: 0, total: 100, missing: 0},
type, type,
}); });
}; };
const back = async () => { const back = async () => {
setIsLoading(true);
const storagePath = await saveToStorage();
setIsLoading(false);
onBack({ onBack({
exercise: id, exercise: id,
solutions: storagePath ? [{id, solution: storagePath}] : [], solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 0, total: 100, missing: 0}, score: {correct: 0, total: 100, missing: 0},
type, type,
}); });
@@ -101,9 +95,26 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
return ( return (
<div className="flex flex-col h-full w-full gap-9"> <div className="flex flex-col h-full w-full gap-9">
<Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}>
<div className="flex flex-col items-center justify-center gap-4 w-full h-full">
<div className="flex flex-col gap-1 ml-4">
{prompts.map((x, index) => (
<li className="italic" key={index}>
{x}
</li>
))}
</div>
{!!suffix && <span className="font-bold">{suffix}</span>}
</div>
</Modal>
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16"> <div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<span className="font-semibold">{title}</span> <div className="flex flex-col gap-0">
<span className="font-semibold">{title}</span>
{prompts.length > 0 && (
<span className="font-semibold">You should talk for at least 30 seconds for your answer to be valid.</span>
)}
</div>
{!video_url && ( {!video_url && (
<span className="font-regular"> <span className="font-regular">
{text.split("\\n").map((line, index) => ( {text.split("\\n").map((line, index) => (
@@ -115,7 +126,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
</span> </span>
)} )}
</div> </div>
<div className="flex gap-6"> <div className="flex flex-col gap-6 items-center">
{video_url && ( {video_url && (
<div className="flex flex-col gap-4 w-full items-center"> <div className="flex flex-col gap-4 w-full items-center">
<video key={id} autoPlay controls className="max-w-3xl rounded-xl"> <video key={id} autoPlay controls className="max-w-3xl rounded-xl">
@@ -123,18 +134,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
</video> </video>
</div> </div>
)} )}
{prompts && prompts.length > 0 && ( {prompts && prompts.length > 0 && <Button onClick={() => setIsPromptsModalOpen(true)}>View Prompts</Button>}
<div className="flex flex-col gap-4">
<span className="font-bold">You should talk about the following things:</span>
<div className="flex flex-col gap-1 ml-4">
{prompts.map((x, index) => (
<li className="italic" key={index}>
{x}
</li>
))}
</div>
</div>
)}
</div> </div>
</div> </div>

View File

@@ -23,7 +23,6 @@ const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentenc
export interface CommonProps { export interface CommonProps {
examID?: string; examID?: string;
updateIndex?: (internalIndex: number) => void;
onNext: (userSolutions: UserSolution) => void; onNext: (userSolutions: UserSolution) => void;
onBack: (userSolutions: UserSolution) => void; onBack: (userSolutions: UserSolution) => void;
} }
@@ -33,7 +32,6 @@ export const renderExercise = (
examID: string, examID: string,
onNext: (userSolutions: UserSolution) => void, onNext: (userSolutions: UserSolution) => void,
onBack: (userSolutions: UserSolution) => void, onBack: (userSolutions: UserSolution) => void,
updateIndex?: (internalIndex: number) => void,
) => { ) => {
switch (exercise.type) { switch (exercise.type) {
case "fillBlanks": case "fillBlanks":
@@ -43,16 +41,7 @@ export const renderExercise = (
case "matchSentences": case "matchSentences":
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} examID={examID} />; return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
case "multipleChoice": case "multipleChoice":
return ( return <MultipleChoice key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
<MultipleChoice
key={exercise.id}
{...(exercise as MultipleChoiceExercise)}
updateIndex={updateIndex}
onNext={onNext}
onBack={onBack}
examID={examID}
/>
);
case "writeBlanks": case "writeBlanks":
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />; return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
case "writing": case "writing":
@@ -65,7 +54,6 @@ export const renderExercise = (
key={exercise.id} key={exercise.id}
{...(exercise as InteractiveSpeakingExercise)} {...(exercise as InteractiveSpeakingExercise)}
examID={examID} examID={examID}
updateIndex={updateIndex}
onNext={onNext} onNext={onNext}
onBack={onBack} onBack={onBack}
/> />

View File

@@ -0,0 +1,82 @@
import {FillBlanksExercise} from "@/interfaces/exam";
import React from "react";
import Input from "@/components/Low/Input";
interface Props {
exercise: FillBlanksExercise;
updateExercise: (data: any) => void;
}
const FillBlanksEdit = (props: Props) => {
const {exercise, updateExercise} = props;
return (
<>
<Input
type="text"
label="Prompt"
name="prompt"
required
value={exercise.prompt}
onChange={(value) =>
updateExercise({
prompt: value,
})
}
/>
<Input
type="text"
label="Text"
name="text"
required
value={exercise.text}
onChange={(value) =>
updateExercise({
text: value,
})
}
/>
<h1>Solutions</h1>
<div className="w-full flex flex-wrap -mx-2">
{exercise.solutions.map((solution, index) => (
<div key={solution.id} className="flex sm:w-1/2 lg:w-1/4 px-2">
<Input
type="text"
label={`Solution ${index + 1}`}
name="solution"
required
value={solution.solution}
onChange={(value) =>
updateExercise({
solutions: exercise.solutions.map((sol) => (sol.id === solution.id ? {...sol, solution: value} : sol)),
})
}
/>
</div>
))}
</div>
<h1>Words</h1>
<div className="w-full flex flex-wrap -mx-2">
{exercise.words.map((word, index) => (
<div key={index} className="flex sm:w-1/2 lg:w-1/4 px-2">
<Input
type="text"
label={`Word ${index + 1}`}
name="word"
required
value={typeof word === "string" ? word : word.word}
onChange={(value) =>
updateExercise({
words: exercise.words.map((sol, idx) =>
index === idx ? (typeof word === "string" ? value : {...word, word: value}) : sol,
),
})
}
/>
</div>
))}
</div>
</>
);
};
export default FillBlanksEdit;

View File

@@ -0,0 +1,7 @@
import React from "react";
const InteractiveSpeakingEdit = () => {
return null;
};
export default InteractiveSpeakingEdit;

View File

@@ -0,0 +1,130 @@
import React from "react";
import { MatchSentencesExercise } from "@/interfaces/exam";
import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select";
interface Props {
exercise: MatchSentencesExercise;
updateExercise: (data: any) => void;
}
const MatchSentencesEdit = (props: Props) => {
const { exercise, updateExercise } = props;
const selectOptions = exercise.options.map((option) => ({
value: option.id,
label: option.id,
}));
return (
<>
<Input
type="text"
label="Prompt"
name="prompt"
required
value={exercise.prompt}
onChange={(value) =>
updateExercise({
prompt: value,
})
}
/>
<h1>Solutions</h1>
<div className="w-full flex flex-wrap -mx-2">
{exercise.sentences.map((sentence, index) => (
<div key={sentence.id} className="flex flex-col w-full px-2">
<div className="flex w-full">
<Input
type="text"
label={`Sentence ${index + 1}`}
name="sentence"
required
value={sentence.sentence}
onChange={(value) =>
updateExercise({
sentences: exercise.sentences.map((iSol) =>
iSol.id === sentence.id
? {
...iSol,
sentence: value,
}
: iSol
),
})
}
className="px-2"
/>
<div className="w-48 flex items-end px-2">
<Select
value={selectOptions.find(
(o) => o.value === sentence.solution
)}
options={selectOptions}
onChange={(value) => {
updateExercise({
sentences: exercise.sentences.map((iSol) =>
iSol.id === sentence.id
? {
...iSol,
solution: value?.value,
}
: iSol
),
});
}}
/>
</div>
</div>
</div>
))}
<h1>Options</h1>
<div className="w-full flex flex-wrap -mx-2">
{exercise.options.map((option, index) => (
<div key={option.id} className="flex flex-col w-full px-2">
<div className="flex w-full">
<Input
type="text"
label={`Option ${index + 1}`}
name="option"
required
value={option.sentence}
onChange={(value) =>
updateExercise({
options: exercise.options.map((iSol) =>
iSol.id === option.id
? {
...iSol,
sentence: value,
}
: iSol
),
})
}
className="px-2"
/>
<div className="w-48 flex items-end px-2">
<Select
value={{
value: option.id,
label: option.id,
}}
options={[
{
value: option.id,
label: option.id,
},
]}
disabled
onChange={() => {}}
/>
</div>
</div>
</div>
))}
</div>
</div>
</>
);
};
export default MatchSentencesEdit;

View File

@@ -0,0 +1,137 @@
import React from "react";
import Input from "@/components/Low/Input";
import {
MultipleChoiceExercise,
MultipleChoiceQuestion,
} from "@/interfaces/exam";
import Select from "@/components/Low/Select";
interface Props {
exercise: MultipleChoiceExercise;
updateExercise: (data: any) => void;
}
const variantOptions = [
{ value: "text", label: "Text", key: "text" },
{ value: "image", label: "Image", key: "src" },
];
const MultipleChoiceEdit = (props: Props) => {
const { exercise, updateExercise } = props;
return (
<>
<h1>Questions</h1>
<div className="w-full flex-no-wrap -mx-2">
{exercise.questions.map((question: MultipleChoiceQuestion, index) => {
const variantValue = variantOptions.find(
(o) => o.value === question.variant
);
const solutionsOptions = question.options.map((option) => ({
value: option.id,
label: option.id,
}));
const solutionValue = solutionsOptions.find(
(o) => o.value === question.solution
);
return (
<div key={question.id} className="flex w-full px-2 flex-col">
<span>Question ID: {question.id}</span>
<Input
type="text"
label="Prompt"
name="prompt"
required
value={question.prompt}
onChange={(value) =>
updateExercise({
questions: exercise.questions.map((sol) =>
sol.id === question.id ? { ...sol, prompt: value } : sol
),
})
}
/>
<div className="flex w-full">
<div className="w-48 flex items-end px-2">
<Select
value={solutionValue}
options={solutionsOptions}
onChange={(value) => {
updateExercise({
questions: exercise.questions.map((sol) =>
sol.id === question.id
? { ...sol, solution: value?.value }
: sol
),
});
}}
/>
</div>
<div className="w-48 flex items-end px-2">
<Select
value={variantValue}
options={variantOptions}
onChange={(value) => {
updateExercise({
questions: exercise.questions.map((sol) =>
sol.id === question.id
? { ...sol, variant: value?.value }
: sol
),
});
}}
/>
</div>
</div>
<div className="flex w-full flex-wrap -mx-2">
{question.options.map((option) => (
<div
key={option.id}
className="flex sm:w-1/2 lg:w-1/4 px-2 px-2"
>
<Input
type="text"
label={`Option ${option.id}`}
name="option"
required
value={option.text}
onChange={(value) =>
updateExercise({
questions: exercise.questions.map((sol) =>
sol.id === question.id
? {
...sol,
options: sol.options.map((opt) => {
if (
opt.id === option.id &&
variantValue?.key
) {
return {
...opt,
[variantValue.key]: value,
};
}
return opt;
}),
}
: sol
),
})
}
/>
</div>
))}
</div>
</div>
);
})}
</div>
</>
);
};
export default MultipleChoiceEdit;

View File

@@ -0,0 +1,7 @@
import React from 'react';
const SpeakingEdit = () => {
return null;
}
export default SpeakingEdit;

View File

@@ -0,0 +1,71 @@
import React from "react";
import { TrueFalseExercise } from "@/interfaces/exam";
import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select";
interface Props {
exercise: TrueFalseExercise;
updateExercise: (data: any) => void;
}
const options = [
{ value: "true", label: "True" },
{ value: "false", label: "False" },
{ value: "not_given", label: "Not Given" },
];
const TrueFalseEdit = (props: Props) => {
const { exercise, updateExercise } = props;
return (
<>
<Input
type="text"
label="Prompt"
name="prompt"
required
value={exercise.prompt}
onChange={(value) =>
updateExercise({
prompt: value,
})
}
/>
<h1>Questions</h1>
<div className="w-full flex-no-wrap -mx-2">
{exercise.questions.map((question, index) => (
<div key={question.id} className="flex w-full px-2">
<Input
type="text"
label={`Question ${index + 1}`}
name="question"
required
value={question.prompt}
onChange={(value) =>
updateExercise({
questions: exercise.questions.map((sol) =>
sol.id === question.id ? { ...sol, prompt: value } : sol
),
})
}
/>
<div className="w-48 flex items-end px-2">
<Select
value={options.find((o) => o.value === question.solution)}
options={options}
onChange={(value) => {
updateExercise({
questions: exercise.questions.map((sol) =>
sol.id === question.id ? { ...sol, solution: value?.value } : sol
),
});
}}
className="h-18"
/>
</div>
</div>
))}
</div>
</>
);
};
export default TrueFalseEdit;

View File

@@ -0,0 +1,94 @@
import React from "react";
import Input from "@/components/Low/Input";
import { WriteBlanksExercise } from "@/interfaces/exam";
interface Props {
exercise: WriteBlanksExercise;
updateExercise: (data: any) => void;
}
const WriteBlankEdits = (props: Props) => {
const { exercise, updateExercise } = props;
return (
<>
<Input
type="text"
label="Prompt"
name="prompt"
required
value={exercise.prompt}
onChange={(value) =>
updateExercise({
prompt: value,
})
}
/>
<Input
type="text"
label="Text"
name="text"
required
value={exercise.text}
onChange={(value) =>
updateExercise({
text: value,
})
}
/>
<Input
type="text"
label="Max Words"
name="number"
required
value={exercise.maxWords}
onChange={(value) =>
updateExercise({
maxWords: Number(value),
})
}
/>
<h1>Solutions</h1>
<div className="w-full flex flex-wrap -mx-2">
{exercise.solutions.map((solution) => (
<div key={solution.id} className="flex flex-col w-full px-2">
<span>Solution ID: {solution.id}</span>
{/* TODO: Consider adding an add and delete button */}
<div className="flex flex-wrap">
{solution.solution.map((sol, solIndex) => (
<Input
key={`${sol}-${solIndex}`}
type="text"
label={`Solution ${solIndex + 1}`}
name="solution"
required
value={sol}
onChange={(value) =>
updateExercise({
solutions: exercise.solutions.map((iSol) =>
iSol.id === solution.id
? {
...iSol,
solution: iSol.solution.map((iiSol, iiIndex) => {
if (iiIndex === solIndex) {
return value;
}
return iiSol;
}),
}
: iSol
),
})
}
className="sm:w-1/2 lg:w-1/4 px-2"
/>
))}
</div>
</div>
))}
</div>
</>
);
};
export default WriteBlankEdits;

View File

@@ -0,0 +1,7 @@
import React from 'react';
const WritingEdit = () => {
return null;
}
export default WritingEdit;

View File

@@ -33,8 +33,7 @@ export default function Layout({user, children, className, navDisabled = false,
focusMode={focusMode} focusMode={focusMode}
onFocusLayerMouseEnter={onFocusLayerMouseEnter} onFocusLayerMouseEnter={onFocusLayerMouseEnter}
className="-md:hidden" className="-md:hidden"
userType={user.type} user={user}
userId={user.id}
/> />
<div <div
className={clsx( className={clsx(

View File

@@ -16,6 +16,7 @@ import ShortUniqueId from "short-unique-id";
import Button from "../Low/Button"; import Button from "../Low/Button";
import Input from "../Low/Input"; import Input from "../Low/Input";
import Select from "../Low/Select"; import Select from "../Low/Select";
import { checkAccess } from "@/utils/permissions";
interface Props { interface Props {
user: User; user: User;
@@ -137,13 +138,13 @@ export default function TicketDisplay({ user, ticket, onClose }: Props) {
options={[ options={[
{ value: "me", label: "Assign to me" }, { value: "me", label: "Assign to me" },
...users ...users
.filter((x) => ["admin", "developer", "agent"].includes(x.type)) .filter((x) => checkAccess(x, ["admin", "developer", "agent"]))
.map((u) => ({ .map((u) => ({
value: u.id, value: u.id,
label: `${u.name} - ${u.email}`, label: `${u.name} - ${u.email}`,
})), })),
]} ]}
disabled={user.type === "agent"} disabled={checkAccess(user, ["agent"])}
value={ value={
assignedTo assignedTo
? { ? {

View File

@@ -4,7 +4,7 @@ import ReactSelect, {GroupBase, StylesConfig} from "react-select";
interface Option { interface Option {
[key: string]: any; [key: string]: any;
value: string; value: string | null;
label: string; label: string;
} }

View File

@@ -1,160 +1,215 @@
import {User} from "@/interfaces/user"; import { User } from "@/interfaces/user";
import {Dialog, Transition} from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import {Fragment} from "react"; import { Fragment } from "react";
import {BsXLg} from "react-icons/bs"; import { BsXLg } from "react-icons/bs";
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
path: string; path: string;
user: User; user: User;
disableNavigation?: boolean; disableNavigation?: boolean;
} }
export default function MobileMenu({isOpen, onClose, path, user, disableNavigation}: Props) { export default function MobileMenu({
const router = useRouter(); isOpen,
onClose,
path,
user,
disableNavigation,
}: Props) {
const router = useRouter();
const logout = async () => { const logout = async () => {
axios.post("/api/logout").finally(() => { axios.post("/api/logout").finally(() => {
setTimeout(() => router.reload(), 500); setTimeout(() => router.reload(), 500);
}); });
}; };
return ( return (
<Transition appear show={isOpen} as={Fragment}> <Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={onClose}> <Dialog as="div" className="relative z-10" onClose={onClose}>
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0"> leaveTo="opacity-0"
<div className="fixed inset-0 bg-black bg-opacity-25" /> >
</Transition.Child> <div className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto"> <div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center text-center"> <div className="flex min-h-full items-center justify-center text-center">
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0 scale-95" enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100" enterTo="opacity-100 scale-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"> leaveTo="opacity-0 scale-95"
<Dialog.Panel className="flex h-screen w-full transform flex-col gap-8 overflow-hidden bg-white text-left align-middle text-black shadow-xl transition-all"> >
<Dialog.Title as="header" className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden"> <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">
<Link href={disableNavigation ? "" : "/"}> <Dialog.Title
<Image src="/logo_title.png" alt="EnCoach logo" width={69} height={69} /> as="header"
</Link> className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden"
<div className="cursor-pointer" onClick={onClose} tabIndex={0}> >
<BsXLg className="text-mti-purple-light text-2xl" onClick={onClose} /> <Link href={disableNavigation ? "" : "/"}>
</div> <Image
</Dialog.Title> src="/logo_title.png"
<div className="flex h-full flex-col gap-6 px-8 text-lg"> alt="EnCoach logo"
<Link width={69}
href={disableNavigation ? "" : "/"} height={69}
className={clsx( />
"w-fit transition duration-300 ease-in-out", </Link>
path === "/" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", <div
)}> className="cursor-pointer"
Dashboard onClick={onClose}
</Link> tabIndex={0}
{(user.type === "student" || user.type === "teacher" || user.type === "developer") && ( >
<> <BsXLg
<Link className="text-mti-purple-light text-2xl"
href={disableNavigation ? "" : "/exam"} onClick={onClose}
className={clsx( />
"w-fit transition duration-300 ease-in-out", </div>
path === "/exam" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", </Dialog.Title>
)}> <div className="flex h-full flex-col gap-6 px-8 text-lg">
Exams <Link
</Link> href={disableNavigation ? "" : "/"}
<Link className={clsx(
href={disableNavigation ? "" : "/exercises"} "w-fit transition duration-300 ease-in-out",
className={clsx( path === "/" &&
"w-fit transition duration-300 ease-in-out", "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
path === "/exercises" && )}
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", >
)}> Dashboard
Exercises </Link>
</Link> {checkAccess(user, ["student", "teacher", "developer"]) && (
</> <>
)} <Link
<Link href={disableNavigation ? "" : "/exam"}
href={disableNavigation ? "" : "/stats"} className={clsx(
className={clsx( "w-fit transition duration-300 ease-in-out",
"w-fit transition duration-300 ease-in-out", path === "/exam" &&
path === "/stats" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
)}> )}
Stats >
</Link> Exams
<Link </Link>
href={disableNavigation ? "" : "/record"} <Link
className={clsx( href={disableNavigation ? "" : "/exercises"}
"w-fit transition duration-300 ease-in-out", className={clsx(
path === "/record" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", "w-fit transition duration-300 ease-in-out",
)}> path === "/exercises" &&
Record "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
</Link> )}
{["admin", "developer", "agent", "corporate"].includes(user.type) && ( >
<Link Exercises
href={disableNavigation ? "" : "/payment-record"} </Link>
className={clsx( </>
"w-fit transition duration-300 ease-in-out", )}
path === "/payment-record" && <Link
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", href={disableNavigation ? "" : "/stats"}
)}> className={clsx(
Payment Record "w-fit transition duration-300 ease-in-out",
</Link> path === "/stats" &&
)} "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
{["admin", "developer", "corporate", "teacher"].includes(user.type) && ( )}
<Link >
href={disableNavigation ? "" : "/settings"} Stats
className={clsx( </Link>
"w-fit transition duration-300 ease-in-out", <Link
path === "/settings" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", href={disableNavigation ? "" : "/record"}
)}> className={clsx(
Settings "w-fit transition duration-300 ease-in-out",
</Link> path === "/record" &&
)} "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
{["admin", "developer", "agent"].includes(user.type) && ( )}
<Link >
href={disableNavigation ? "" : "/tickets"} Record
className={clsx( </Link>
"w-fit transition duration-300 ease-in-out", {checkAccess(user, [
path === "/tickets" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", "admin",
)}> "developer",
Tickets "agent",
</Link> "corporate",
)} "mastercorporate",
<Link ]) && (
href={disableNavigation ? "" : "/profile"} <Link
className={clsx( href={disableNavigation ? "" : "/payment-record"}
"w-fit transition duration-300 ease-in-out", className={clsx(
path === "/profile" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", "w-fit transition duration-300 ease-in-out",
)}> path === "/payment-record" &&
Profile "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
</Link> )}
>
Payment Record
</Link>
)}
{checkAccess(user, [
"admin",
"developer",
"corporate",
"teacher",
"mastercorporate",
]) && (
<Link
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 "
)}
>
Settings
</Link>
)}
{checkAccess(user, ["admin", "developer", "agent"]) && (
<Link
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 "
)}
>
Tickets
</Link>
)}
<Link
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 "
)}
>
Profile
</Link>
<span <span
className={clsx("w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out")} className={clsx(
onClick={logout}> "w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out"
Logout )}
</span> onClick={logout}
</div> >
</Dialog.Panel> Logout
</Transition.Child> </span>
</div> </div>
</div> </Dialog.Panel>
</Dialog> </Transition.Child>
</Transition> </div>
); </div>
</Dialog>
</Transition>
);
} }

View File

@@ -1,14 +1,16 @@
import {Dialog, Transition} from "@headlessui/react"; import {Dialog, Transition} from "@headlessui/react";
import clsx from "clsx";
import {Fragment, ReactElement} from "react"; import {Fragment, ReactElement} from "react";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
title?: string; title?: string;
className?: string;
children?: ReactElement; children?: ReactElement;
} }
export default function Modal({isOpen, title, onClose, children}: Props) { export default function Modal({isOpen, title, className, onClose, children}: Props) {
return ( return (
<Transition appear show={isOpen} as={Fragment}> <Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-[200]" onClose={onClose}> <Dialog as="div" className="relative z-[200]" onClose={onClose}>
@@ -33,7 +35,11 @@ export default function Modal({isOpen, title, onClose, children}: Props) {
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"> leaveTo="opacity-0 scale-95">
<Dialog.Panel className="w-full max-w-6xl transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all"> <Dialog.Panel
className={clsx(
"w-full max-w-6xl transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all",
className,
)}>
{title && ( {title && (
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900"> <Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
{title} {title}

View File

@@ -1,118 +1,219 @@
import {User} from "@/interfaces/user"; import { User } from "@/interfaces/user";
import Link from "next/link"; import Link from "next/link";
import FocusLayer from "@/components/FocusLayer"; import FocusLayer from "@/components/FocusLayer";
import {preventNavigation} from "@/utils/navigation.disabled"; import { preventNavigation } from "@/utils/navigation.disabled";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import {BsList, BsQuestionCircle, BsQuestionCircleFill} from "react-icons/bs"; import { BsList, BsQuestionCircle, BsQuestionCircleFill } from "react-icons/bs";
import clsx from "clsx"; import clsx from "clsx";
import moment from "moment"; import moment from "moment";
import MobileMenu from "./MobileMenu"; import MobileMenu from "./MobileMenu";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import {Type} from "@/interfaces/user"; import { Type } from "@/interfaces/user";
import {USER_TYPE_LABELS} from "@/resources/user"; import { USER_TYPE_LABELS } from "@/resources/user";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import {isUserFromCorporate} from "@/utils/groups"; import { isUserFromCorporate } from "@/utils/groups";
import Button from "./Low/Button"; import Button from "./Low/Button";
import Modal from "./Modal"; import Modal from "./Modal";
import Input from "./Low/Input"; import Input from "./Low/Input";
import TicketSubmission from "./High/TicketSubmission"; import TicketSubmission from "./High/TicketSubmission";
import { Module } from "@/interfaces";
import Badge from "./Low/Badge";
import {
BsArrowRepeat,
BsBook,
BsCheck,
BsCheckCircle,
BsClipboard,
BsHeadphones,
BsMegaphone,
BsPen,
BsXCircle,
} from "react-icons/bs";
interface Props { interface Props {
user: User; user: User;
navDisabled?: boolean; navDisabled?: boolean;
focusMode?: boolean; focusMode?: boolean;
onFocusLayerMouseEnter?: () => void; onFocusLayerMouseEnter?: () => void;
path: string; path: string;
} }
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
export default function Navbar({user, path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) { export default function Navbar({
const [isMenuOpen, setIsMenuOpen] = useState(false); user,
const [disablePaymentPage, setDisablePaymentPage] = useState(true); path,
const [isTicketOpen, setIsTicketOpen] = useState(false); navDisabled = false,
focusMode = false,
onFocusLayerMouseEnter,
}: Props) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
const [isTicketOpen, setIsTicketOpen] = useState(false);
const router = useRouter(); const router = useRouter();
const disableNavigation = preventNavigation(navDisabled, focusMode); const disableNavigation = preventNavigation(navDisabled, focusMode);
const expirationDateColor = (date: Date) => { const expirationDateColor = (date: Date) => {
const momentDate = moment(date); const momentDate = moment(date);
const today = moment(new Date()); const today = moment(new Date());
if (today.add(1, "days").isAfter(momentDate)) return "!bg-mti-red-ultralight border-mti-red-light"; if (today.add(1, "days").isAfter(momentDate))
if (today.add(3, "days").isAfter(momentDate)) return "!bg-mti-rose-ultralight border-mti-rose-light"; return "!bg-mti-red-ultralight border-mti-red-light";
if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light"; if (today.add(3, "days").isAfter(momentDate))
}; return "!bg-mti-rose-ultralight border-mti-rose-light";
if (today.add(7, "days").isAfter(momentDate))
return "!bg-mti-orange-ultralight border-mti-orange-light";
};
const showExpirationDate = () => { const showExpirationDate = () => {
if (!user.subscriptionExpirationDate) return false; if (!user.subscriptionExpirationDate) return false;
const momentDate = moment(user.subscriptionExpirationDate); const momentDate = moment(user.subscriptionExpirationDate);
const today = moment(new Date()); const today = moment(new Date());
return today.add(7, "days").isAfter(momentDate); return today.add(7, "days").isAfter(momentDate);
}; };
useEffect(() => { useEffect(() => {
if (user.type !== "student" && user.type !== "teacher") return setDisablePaymentPage(false); if (user.type !== "student" && user.type !== "teacher")
isUserFromCorporate(user.id).then((result) => setDisablePaymentPage(result)); return setDisablePaymentPage(false);
}, [user]); isUserFromCorporate(user.id).then((result) =>
setDisablePaymentPage(result)
);
}, [user]);
return ( const badges = [
<> {
<Modal isOpen={isTicketOpen} onClose={() => setIsTicketOpen(false)} title="Submit a ticket"> module: "reading",
<TicketSubmission user={user} page={router.asPath} onClose={() => setIsTicketOpen(false)} /> icon: () => <BsBook className="h-4 w-4 text-white" />,
</Modal> achieved: user.levels.reading >= user.desiredLevels.reading,
},
{user && ( {
<MobileMenu disableNavigation={disableNavigation} path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} /> module: "listening",
)} icon: () => <BsHeadphones className="h-4 w-4 text-white" />,
<header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4"> achieved: user.levels.listening >= user.desiredLevels.listening,
<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" /> {
<h1 className="-md:hidden w-1/6 text-2xl font-bold">EnCoach</h1> module: "writing",
</Link> icon: () => <BsPen className="h-4 w-4 text-white" />,
<div className="flex items-center justify-end gap-4 md:mr-8 md:w-5/6"> achieved: user.levels.writing >= user.desiredLevels.writing,
{/* OPEN TICKET SYSTEM */} },
<button {
className={clsx( module: "speaking",
"border-mti-purple-light tooltip tooltip-bottom flex h-8 w-8 flex-col items-center justify-center rounded-full border p-1", icon: () => <BsMegaphone className="h-4 w-4 text-white" />,
"hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white z-20", achieved: user.levels.speaking >= user.desiredLevels.speaking,
)} },
data-tip="Submit a help/feedback ticket" {
onClick={() => setIsTicketOpen(true)}> module: "level",
<BsQuestionCircleFill /> icon: () => <BsClipboard className="h-4 w-4 text-white" />,
</button> achieved: user.levels.level >= user.desiredLevels.level,
},
];
{showExpirationDate() && ( return (
<Link <>
href={!!user.subscriptionExpirationDate && !disablePaymentPage ? "/payment" : ""} <Modal
data-tip="Expiry date" isOpen={isTicketOpen}
className={clsx( onClose={() => setIsTicketOpen(false)}
"flex w-fit cursor-pointer justify-center rounded-full border px-6 py-2 text-sm font-normal focus:outline-none", title="Submit a ticket"
"tooltip tooltip-bottom transition duration-300 ease-in-out", >
!user.subscriptionExpirationDate <TicketSubmission
? "bg-mti-green-ultralight border-mti-green-light" user={user}
: expirationDateColor(user.subscriptionExpirationDate), page={router.asPath}
"border-mti-gray-platinum bg-white", onClose={() => setIsTicketOpen(false)}
)}> />
{!user.subscriptionExpirationDate && "Unlimited"} </Modal>
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
</Link> {user && (
)} <MobileMenu
<Link href={disableNavigation ? "" : "/profile"} className="-md:hidden flex items-center justify-end gap-6"> disableNavigation={disableNavigation}
<img src={user.profilePicture} alt={user.name} className="h-10 w-10 rounded-full object-cover" /> path={path}
<span className="-md:hidden text-right"> isOpen={isMenuOpen}
{user.type === "corporate" ? `${user.corporateInformation?.companyInformation.name} |` : ""} {user.name} |{" "} onClose={() => setIsMenuOpen(false)}
{USER_TYPE_LABELS[user.type]} user={user}
</span> />
</Link> )}
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}> <header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4">
<BsList className="text-mti-purple-light h-8 w-8" /> <Link
</div> href={disableNavigation ? "" : "/"}
</div> className=" flex items-center gap-8 md:px-8"
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />} >
</header> <img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" />
</> <h1 className="-md:hidden w-1/6 text-2xl font-bold">EnCoach</h1>
); </Link>
<div className="flex items-center justify-end gap-4 md:mr-8 md:w-5/6">
{user.type === "student" &&
badges.map((badge) => (
<div
key={badge.module}
className={`${badge.achieved ? `bg-ielts-${badge.module}`: 'bg-mti-gray-anti-flash'} flex h-8 w-8 items-center justify-center rounded-full`}
>
{badge.icon()}
</div>
))}
{/* OPEN TICKET SYSTEM */}
<button
className={clsx(
"border-mti-purple-light tooltip tooltip-bottom flex h-8 w-8 flex-col items-center justify-center rounded-full border p-1",
"hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white z-20"
)}
data-tip="Submit a help/feedback ticket"
onClick={() => setIsTicketOpen(true)}
>
<BsQuestionCircleFill />
</button>
{showExpirationDate() && (
<Link
href={
!!user.subscriptionExpirationDate && !disablePaymentPage
? "/payment"
: ""
}
data-tip="Expiry date"
className={clsx(
"flex w-fit cursor-pointer justify-center rounded-full border px-6 py-2 text-sm font-normal focus:outline-none",
"tooltip tooltip-bottom transition duration-300 ease-in-out",
!user.subscriptionExpirationDate
? "bg-mti-green-ultralight border-mti-green-light"
: expirationDateColor(user.subscriptionExpirationDate),
"border-mti-gray-platinum bg-white"
)}
>
{!user.subscriptionExpirationDate && "Unlimited"}
{user.subscriptionExpirationDate &&
moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
</Link>
)}
<Link
href={disableNavigation ? "" : "/profile"}
className="-md:hidden flex items-center justify-end gap-6"
>
<img
src={user.profilePicture}
alt={user.name}
className="h-10 w-10 rounded-full object-cover"
/>
<span className="-md:hidden text-right">
{user.type === "corporate"
? `${user.corporateInformation?.companyInformation.name} |`
: ""}{" "}
{user.name} | {USER_TYPE_LABELS[user.type]}
</span>
</Link>
<div
className="cursor-pointer md:hidden"
onClick={() => setIsMenuOpen(true)}
>
<BsList className="text-mti-purple-light h-8 w-8" />
</div>
</div>
{focusMode && (
<FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
)}
</header>
</>
);
} }

View File

@@ -1,138 +0,0 @@
import { DurationUnit } from "@/interfaces/paypal";
import {
CreateOrderActions,
CreateOrderData,
OnApproveActions,
OnApproveData,
OnCancelledActions,
OrderResponseBody,
} from "@paypal/paypal-js";
import {
PayPalButtons,
PayPalScriptProvider,
usePayPalScriptReducer,
} from "@paypal/react-paypal-js";
import axios from "axios";
import { useState, useEffect } from "react";
import { toast } from "react-toastify";
interface Props {
clientID: string;
currency: string;
price: number;
duration: number;
duration_unit: DurationUnit;
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,
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,
trackingId,
})
.then((response) => response.data)
.then((data) => {
setIsLoading(false);
return data.id;
})
.catch((err) => {
setIsLoading(false);
return err;
});
};
const onApprove = async (data: OnApproveData, actions: OnApproveActions) => {
if (!trackingId) {
throw new Error("trackingId is not set");
}
axios
.post<{ ok: boolean; reason?: string }>("/api/paypal/approve", {
id: data.orderID,
duration,
duration_unit,
trackingId,
})
.then((request) => {
if (request.status !== 200) {
toast.error("Something went wrong, please try again later");
return;
}
toast.success("Your account has been credited more time!");
return onSuccess(duration, duration_unit);
})
.catch((err) => {
console.error(err);
toast.error("Something went wrong, please try again later");
});
};
const onError = async (data: Record<string, unknown>) => {
setIsLoading(false);
};
const onCancel = async (
data: Record<string, unknown>,
actions: OnCancelledActions
) => {
setIsLoading(false);
};
if (trackingId) {
return loadScript ? (
<PayPalScriptProvider
options={{
clientId: clientID,
currency,
intent: "capture",
commit: true,
}}
>
<PayPalButtons
className="w-full"
style={{ layout: "vertical" }}
createOrder={createOrder}
onApprove={onApprove}
onCancel={onCancel}
onError={onError}
/>
</PayPalScriptProvider>
) : (
<PayPalButtons
className="w-full"
style={{ layout: "vertical" }}
createOrder={createOrder}
onApprove={onApprove}
onCancel={onCancel}
onError={onError}
/>
);
}
return null;
}

View File

@@ -20,15 +20,6 @@ interface Props {
export default function PaymobPayment({user, price, setIsPaymentLoading, currency, duration, duration_unit, onSuccess}: Props) { export default function PaymobPayment({user, price, setIsPaymentLoading, currency, duration, duration_unit, onSuccess}: Props) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [firstName, setFirstName] = useState(user.name.split(" ")[0]);
const [lastName, setLastName] = useState([...user.name.split(" ")].pop());
const [street, setStreet] = useState("");
const [apartment, setApartment] = useState("");
const [building, setBuilding] = useState("");
const [state, setState] = useState("");
const [floor, setFloor] = useState("");
const router = useRouter(); const router = useRouter();
@@ -50,16 +41,16 @@ export default function PaymobPayment({user, price, setIsPaymentLoading, currenc
}, },
}, },
billing_data: { billing_data: {
apartment: apartment || "N/A", apartment: "N/A",
building: building || "N/A", building: "N/A",
country: user.demographicInformation?.country || "N/A", country: user.demographicInformation?.country || "N/A",
email: user.email, email: user.email,
first_name: user.name.split(" ")[0], first_name: user.name.split(" ")[0],
last_name: [...user.name.split(" ")].pop() || "N/A", last_name: [...user.name.split(" ")].pop() || "N/A",
floor: floor || "N/A", floor: "N/A",
phone_number: user.demographicInformation?.phone || "N/A", phone_number: user.demographicInformation?.phone || "N/A",
state: state || "N/A", state: "N/A",
street: street || "N/A", street: "N/A",
}, },
extras: { extras: {
userID: user.id, userID: user.id,
@@ -71,7 +62,6 @@ export default function PaymobPayment({user, price, setIsPaymentLoading, currenc
const response = await axios.post<{iframeURL: string}>(`/api/paymob`, paymentIntention); const response = await axios.post<{iframeURL: string}>(`/api/paymob`, paymentIntention);
router.push(response.data.iframeURL); router.push(response.data.iframeURL);
setIsModalOpen(false);
} catch (error) { } catch (error) {
console.error("Error starting card payment process:", error); console.error("Error starting card payment process:", error);
} }
@@ -79,27 +69,7 @@ export default function PaymobPayment({user, price, setIsPaymentLoading, currenc
return ( return (
<> <>
<Modal isOpen={isModalOpen} title="Billing Data" onClose={() => setIsModalOpen(false)}> <Button isLoading={isLoading} onClick={handleCardPayment}>
<div className="flex flex-col gap-4 mt-4">
<div className="grid grid-cols-2 gap-4">
<Input label="First Name" value={firstName} onChange={setFirstName} type="text" name="firstName" />
<Input label="Last Name" value={lastName} onChange={setLastName} type="text" name="lastName" />
</div>
<div className="grid grid-cols-3 -md:grid-cols-1 gap-4">
<Input label="State" value={state} onChange={setState} type="text" name="state" />
<Input label="Street" value={street} onChange={setStreet} type="text" name="street" />
<Input label="Building" value={building} onChange={setBuilding} type="text" name="building" />
</div>
<div className="grid grid-cols-2 gap-4">
<Input label="Floor" value={floor} onChange={setFloor} type="text" name="floor" />
<Input label="Apartment" value={apartment} onChange={setApartment} type="text" name="apartment" />
</div>
<Button className="w-full max-w-[200px] self-end mt-4" disabled={!firstName || !lastName} onClick={handleCardPayment}>
Complete Payment
</Button>
</div>
</Modal>
<Button isLoading={isLoading} onClick={() => setIsModalOpen(true)}>
Select Select
</Button> </Button>
</> </>

View File

@@ -0,0 +1,62 @@
import {Permission} from "@/interfaces/permissions";
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
import Link from "next/link";
import {convertCamelCaseToReadable} from "@/utils/string";
interface Props {
permissions: Permission[];
}
const columnHelper = createColumnHelper<Permission>();
const defaultColumns = [
columnHelper.accessor("type", {
header: () => <span>Type</span>,
cell: ({row, getValue}) => (
<Link
href={`/permissions/${row.original.id}`}
key={row.id}
className="underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer">
{convertCamelCaseToReadable(getValue() as string)}
</Link>
),
}),
];
export default function PermissionList({permissions}: Props) {
const table = useReactTable({
data: permissions,
columns: defaultColumns,
getCoreRowModel: getCoreRowModel(),
});
return (
<div className="w-full">
<div className="w-full flex flex-col gap-2">
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="py-4 px-4 text-left" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2 items-center w-fit" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
import clsx from 'clsx';
import React from 'react';
interface RadialProgressBarProps {
percentage: number;
text: string;
color: string;
spanClassName?: string;
size?: number;
strokeWidth?: number;
strokeOpacity?: number;
}
// https://gist.github.com/eYinka/873be69fae3ef27b103681b8a9f5e379 Omarmarei's answer
const RadialProgressBar: React.FC<RadialProgressBarProps> = ({
percentage,
text,
color,
spanClassName = "",
size = 100,
strokeWidth = 10,
strokeOpacity = 0.5
}) => {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (percentage / 100) * circumference;
return (
<div className='relative flex items-center justify-center' style={{ width: size, height: size}}>
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`
}
className="circular-progress-bar"
>
<circle className="circle-bg" stroke="#e6e6e6" strokeWidth={strokeWidth}
fill="none"
cx={size / 2}
cy={size / 2}
r={radius}
strokeOpacity={strokeOpacity}
/>
<circle
className="circle-progress"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
fill="none"
cx={size / 2}
cy={size / 2}
r={radius}
strokeDasharray={circumference}
strokeDashoffset={offset}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
strokeOpacity={strokeOpacity}
/>
</svg>
<span className={clsx('absolute', spanClassName)}>{text}</span>
</div>
);
};
export default RadialProgressBar;

View File

@@ -0,0 +1,48 @@
import React from 'react';
import clsx from 'clsx';
interface Segment {
percentage: number;
subtitle: string;
color: string;
}
interface SegmentedProgressBarProps {
segments: Segment[];
height?: number;
className?: string;
}
const SegmentedProgressBar: React.FC<SegmentedProgressBarProps> = ({ segments, height=15, className="" }) => {
return (
<div className={className}>
<div className="relative flex rounded-full overflow-hidden bg-gray-200" style={{height: `${height}px`}}>
{segments.map((segment, index) => (
<div
key={index}
className={clsx(
'h-full opacity-50',
'transition-all duration-500 ease-out',
`bg-${segment.color}`,
{
'rounded-l-full': index === 0,
'rounded-r-full': index === segments.length - 1,
'rounded-none': index !== 0 && index !== segments.length - 1
}
)}
style={{width: `${segment.percentage}%`}}
></div>
))}
</div>
<div className="mt-2 flex text-sm justify-between">
{segments.map((segment, index) => (
<div
key={index}
className="flex flex-col text-center w-fit"
>
<span className={clsx('font-semibold',`text-${segment.color}`)}>{segment.subtitle}</span>
<span>{`${segment.percentage}%`}</span>
</div>
))}
</div>
</div>
);
};
export default SegmentedProgressBar;

View File

@@ -12,6 +12,7 @@ import {
BsCloudFill, BsCloudFill,
BsCurrencyDollar, BsCurrencyDollar,
BsClipboardData, BsClipboardData,
BsFileLock,
} from "react-icons/bs"; } from "react-icons/bs";
import {RiLogoutBoxFill} from "react-icons/ri"; import {RiLogoutBoxFill} from "react-icons/ri";
import {SlPencil} from "react-icons/sl"; import {SlPencil} from "react-icons/sl";
@@ -23,16 +24,16 @@ import FocusLayer from "@/components/FocusLayer";
import {preventNavigation} from "@/utils/navigation.disabled"; import {preventNavigation} from "@/utils/navigation.disabled";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import usePreferencesStore from "@/stores/preferencesStore"; import usePreferencesStore from "@/stores/preferencesStore";
import {Type} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import useTicketsListener from "@/hooks/useTicketsListener"; import useTicketsListener from "@/hooks/useTicketsListener";
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
interface Props { interface Props {
path: string; path: string;
navDisabled?: boolean; navDisabled?: boolean;
focusMode?: boolean; focusMode?: boolean;
onFocusLayerMouseEnter?: () => void; onFocusLayerMouseEnter?: () => void;
className?: string; className?: string;
userType?: Type; user: User;
userId?: string;
} }
interface NavProps { interface NavProps {
@@ -72,12 +73,12 @@ const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false,
); );
}; };
export default function Sidebar({path, navDisabled = false, focusMode = false, userType, onFocusLayerMouseEnter, className, userId}: Props) { export default function Sidebar({path, navDisabled = false, focusMode = false, user, onFocusLayerMouseEnter, className}: Props) {
const router = useRouter(); 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); const {totalAssignedTickets} = useTicketsListener(user.id);
const logout = async () => { const logout = async () => {
axios.post("/api/logout").finally(() => { axios.post("/api/logout").finally(() => {
@@ -96,33 +97,19 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
)}> )}>
<div className="-xl:hidden flex-col gap-3 xl:flex"> <div className="-xl:hidden flex-col gap-3 xl:flex">
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} /> <Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
{(userType === "student" || userType === "teacher" || userType === "developer") && ( {checkAccess(user, ["student", "teacher", "developer"], "viewExams") && (
<> <Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={isMinimized} />
<Nav
disabled={disableNavigation}
Icon={BsFileEarmarkText}
label="Exams"
path={path}
keyPath="/exam"
isMinimized={isMinimized}
/>
<Nav
disabled={disableNavigation}
Icon={BsPencil}
label="Exercises"
path={path}
keyPath="/exercises"
isMinimized={isMinimized}
/>
</>
)} )}
{(userType || "") !== 'agent' && ( {checkAccess(user, ["student", "teacher", "developer"], "viewExercises") && (
<> <Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={isMinimized} />
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
</>
)} )}
{["admin", "developer", "agent", "corporate"].includes(userType || "") && ( {checkAccess(user, getTypesOfUser(["agent"]), "viewStats") && (
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
)}
{checkAccess(user, getTypesOfUser(["agent"]), "viewRecords") && (
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
)}
{checkAccess(user, ["admin", "developer", "agent", "corporate", "mastercorporate"], "viewPaymentRecords") && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
Icon={BsCurrencyDollar} Icon={BsCurrencyDollar}
@@ -132,7 +119,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
isMinimized={isMinimized} isMinimized={isMinimized}
/> />
)} )}
{["admin", "developer", "corporate", "teacher"].includes(userType || "") && ( {checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"]) && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
Icon={BsShieldFill} Icon={BsShieldFill}
@@ -142,7 +129,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
isMinimized={isMinimized} isMinimized={isMinimized}
/> />
)} )}
{["admin", "developer", "agent"].includes(userType || "") && ( {checkAccess(user, ["admin", "developer", "agent"], "viewTickets") && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
Icon={BsClipboardData} Icon={BsClipboardData}
@@ -153,32 +140,62 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
badge={totalAssignedTickets} badge={totalAssignedTickets}
/> />
)} )}
{userType === "developer" && ( {checkAccess(user, ["developer", "admin"]) && (
<Nav <>
disabled={disableNavigation} <Nav
Icon={BsCloudFill} disabled={disableNavigation}
label="Generation" Icon={BsCloudFill}
path={path} label="Generation"
keyPath="/generation" path={path}
isMinimized={isMinimized} keyPath="/generation"
/> isMinimized={isMinimized}
/>
<Nav
disabled={disableNavigation}
Icon={BsFileLock}
label="Permissions"
path={path}
keyPath="/permissions"
isMinimized={isMinimized}
/>
</>
)} )}
</div> </div>
<div className="-xl:flex flex-col gap-3 xl:hidden"> <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={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={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={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
{(userType || "") !== 'agent' && ( {checkAccess(user, getTypesOfUser(["agent"]), "viewStats") && (
<> <Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
</>
)} )}
{userType !== "student" && ( {checkAccess(user, getTypesOfUser(["agent"]), "viewRecords") && (
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
)}
{checkAccess(user, getTypesOfUser(["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" && ( {checkAccess(user, getTypesOfUser(["student"])) && (
<Nav disabled={disableNavigation} Icon={BsCloudFill} label="Generation" path={path} keyPath="/generation" isMinimized={true} /> <Nav disabled={disableNavigation} Icon={BsShieldFill} label="Permissions" path={path} keyPath="/permissions" isMinimized={true} />
)}
{checkAccess(user, ["developer"]) && (
<>
<Nav
disabled={disableNavigation}
Icon={BsCloudFill}
label="Generation"
path={path}
keyPath="/generation"
isMinimized={true}
/>
<Nav
disabled={disableNavigation}
Icon={BsFileLock}
label="Permissions"
path={path}
keyPath="/permissions"
isMinimized={true}
/>
</>
)} )}
</div> </div>

View File

@@ -5,12 +5,30 @@ import {CommonProps} from ".";
import {Fragment} from "react"; import {Fragment} from "react";
import Button from "../Low/Button"; import Button from "../Low/Button";
export default function FillBlanksSolutions({id, type, prompt, solutions, text, userSolutions, onNext, onBack}: FillBlanksExercise & CommonProps) { export default function FillBlanksSolutions({
id,
type,
prompt,
solutions,
words,
text,
userSolutions,
onNext,
onBack,
}: FillBlanksExercise & CommonProps) {
const calculateScore = () => { const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0; const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = userSolutions.filter( const correct = userSolutions.filter((x) => {
(x) => solutions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false, const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution.toLowerCase();
).length; if (!solution) return false;
const option = words.find((w) =>
typeof w === "string" ? w.toLowerCase() === x.solution.toLowerCase() : w.letter.toLowerCase() === x.solution.toLowerCase(),
);
if (!option) return false;
return solution === (typeof option === "string" ? option.toLowerCase() : option.word.toLowerCase());
}).length;
const missing = total - userSolutions.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length; const missing = total - userSolutions.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return {total, correct, missing}; return {total, correct, missing};
@@ -35,7 +53,14 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
); );
} }
if (userSolution.solution === solution.solution) { const userSolutionWord = words.find((w) =>
typeof w === "string"
? w.toLowerCase() === userSolution.solution.toLowerCase()
: w.letter.toLowerCase() === userSolution.solution.toLowerCase(),
);
const userSolutionText = typeof userSolutionWord === "string" ? userSolutionWord : userSolutionWord?.word;
if (userSolutionText === solution.solution) {
return ( return (
<button <button
className={clsx( className={clsx(
@@ -47,7 +72,7 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
); );
} }
if (userSolution.solution !== solution.solution) { if (userSolutionText !== solution.solution) {
return ( return (
<> <>
<button <button
@@ -55,7 +80,7 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
"rounded-full hover:text-white hover:bg-mti-rose transition duration-300 ease-in-out my-1 mr-1", "rounded-full hover:text-white hover:bg-mti-rose transition duration-300 ease-in-out my-1 mr-1",
userSolution && "px-5 py-2 text-center text-white bg-mti-rose-light", userSolution && "px-5 py-2 text-center text-white bg-mti-rose-light",
)}> )}>
{userSolution.solution} {userSolutionText}
</button> </button>
<button <button

View File

@@ -132,11 +132,16 @@ export default function InteractiveSpeaking({
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && ( {userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-4 w-full">
<div className="flex gap-4 px-1"> <div className="flex gap-4 px-1">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => ( {Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
<div className="bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2" key={key}> const taskResponse = userSolutions[0].evaluation!.task_response[key];
{key}: Level {userSolutions[0].evaluation!.task_response[key]} const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
</div>
))} return (
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2")} key={key}>
{key}: Level {grade}
</div>
);
})}
</div> </div>
{userSolutions[0].evaluation && {userSolutions[0].evaluation &&
Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length === 3 ? ( Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length === 3 ? (
@@ -186,6 +191,17 @@ export default function InteractiveSpeaking({
}> }>
Recommended Answer (Prompt 3) Recommended Answer (Prompt 3)
</Tab> </Tab>
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
)
}>
Global Overview
</Tab>
</Tab.List> </Tab.List>
<Tab.Panels> <Tab.Panels>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4"> <Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
@@ -193,19 +209,36 @@ export default function InteractiveSpeaking({
</Tab.Panel> </Tab.Panel>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4"> <Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap"> <span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
{userSolutions[0].evaluation!.perfect_answer_1!.replaceAll(/\s{2,}/g, "\n\n")} {userSolutions[0].evaluation!.perfect_answer_1!.answer.replaceAll(/\s{2,}/g, "\n\n")}
</span> </span>
</Tab.Panel> </Tab.Panel>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4"> <Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap"> <span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
{userSolutions[0].evaluation!.perfect_answer_2!.replaceAll(/\s{2,}/g, "\n\n")} {userSolutions[0].evaluation!.perfect_answer_2!.answer.replaceAll(/\s{2,}/g, "\n\n")}
</span> </span>
</Tab.Panel> </Tab.Panel>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4"> <Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap"> <span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
{userSolutions[0].evaluation!.perfect_answer_3!.replaceAll(/\s{2,}/g, "\n\n")} {userSolutions[0].evaluation!.perfect_answer_3!.answer.replaceAll(/\s{2,}/g, "\n\n")}
</span> </span>
</Tab.Panel> </Tab.Panel>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<div className="flex flex-col gap-4">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
const taskResponse = userSolutions[0].evaluation!.task_response[key];
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
return (
<div key={key} className="flex flex-col gap-2">
<span className={"font-semibold"}>
{key}: Level {grade}
</span>
{typeof taskResponse !== "number" && <span>{taskResponse.comment}</span>}
</div>
);
})}
</div>
</Tab.Panel>
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
) : ( ) : (

View File

@@ -1,7 +1,9 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam"; import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import clsx from "clsx"; import clsx from "clsx";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import reactStringReplace from "react-string-replace";
import {CommonProps} from "."; import {CommonProps} from ".";
import Button from "../Low/Button"; import Button from "../Low/Button";
@@ -13,6 +15,13 @@ function Question({
options, options,
userSolution, userSolution,
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) { }: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
const renderPrompt = (prompt: string) => {
return reactStringReplace(prompt, /((<u>)[\w\s']+(<\/u>))/g, (match) => {
const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
return word.length > 0 ? <u>{word}</u> : null;
});
};
const optionColor = (option: string) => { const optionColor = (option: string) => {
if (option === solution && !userSolution) { if (option === solution && !userSolution) {
return "!border-mti-gray-davy !text-mti-gray-davy"; return "!border-mti-gray-davy !text-mti-gray-davy";
@@ -27,9 +36,15 @@ function Question({
return ( return (
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<span> {isNaN(Number(id)) ? (
{id} - {prompt} <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
</span> ) : (
<span className="">
<>
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
</>
</span>
)}
<div className="grid grid-cols-4 gap-4 place-items-center"> <div className="grid grid-cols-4 gap-4 place-items-center">
{variant === "image" && {variant === "image" &&
options.map((option) => ( options.map((option) => (
@@ -57,17 +72,8 @@ function Question({
); );
} }
export default function MultipleChoice({ export default function MultipleChoice({id, type, prompt, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
id, const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
type,
prompt,
questions,
userSolutions,
updateIndex,
onNext,
onBack,
}: MultipleChoiceExercise & CommonProps) {
const [questionIndex, setQuestionIndex] = useState(0);
const calculateScore = () => { const calculateScore = () => {
const total = questions.length; const total = questions.length;
@@ -79,15 +85,11 @@ export default function MultipleChoice({
return {total, correct, missing}; return {total, correct, missing};
}; };
useEffect(() => {
if (updateIndex) updateIndex(questionIndex);
}, [questionIndex, updateIndex]);
const next = () => { const next = () => {
if (questionIndex === questions.length - 1) { if (questionIndex === questions.length - 1) {
onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type}); onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
} else { } else {
setQuestionIndex((prev) => prev + 1); setQuestionIndex(questionIndex + 1);
} }
}; };
@@ -95,7 +97,7 @@ export default function MultipleChoice({
if (questionIndex === 0) { if (questionIndex === 0) {
onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type}); onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type});
} else { } else {
setQuestionIndex((prev) => prev - 1); setQuestionIndex(questionIndex - 1);
} }
}; };

View File

@@ -126,11 +126,16 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && ( {userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-4 w-full">
<div className="flex gap-4 px-1"> <div className="flex gap-4 px-1">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => ( {Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
<div className="bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2" key={key}> const taskResponse = userSolutions[0].evaluation!.task_response[key];
{key}: Level {userSolutions[0].evaluation!.task_response[key]} const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
</div>
))} return (
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2")} key={key}>
{key}: Level {grade}
</div>
);
})}
</div> </div>
{userSolutions[0].evaluation && {userSolutions[0].evaluation &&
(userSolutions[0].evaluation.perfect_answer || userSolutions[0].evaluation.perfect_answer_1) ? ( (userSolutions[0].evaluation.perfect_answer || userSolutions[0].evaluation.perfect_answer_1) ? (
@@ -158,6 +163,17 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
}> }>
Recommended Answer Recommended Answer
</Tab> </Tab>
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
)
}>
Global Overview
</Tab>
</Tab.List> </Tab.List>
<Tab.Panels> <Tab.Panels>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4"> <Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
@@ -171,6 +187,23 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
userSolutions[0].evaluation!.perfect_answer_1.replaceAll(/\s{2,}/g, "\n\n")} userSolutions[0].evaluation!.perfect_answer_1.replaceAll(/\s{2,}/g, "\n\n")}
</span> </span>
</Tab.Panel> </Tab.Panel>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<div className="flex flex-col gap-4">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
const taskResponse = userSolutions[0].evaluation!.task_response[key];
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
return (
<div key={key} className="flex flex-col gap-2">
<span className={"font-semibold"}>
{key}: Level {grade}
</span>
{typeof taskResponse !== "number" && <span>{taskResponse.comment}</span>}
</div>
);
})}
</div>
</Tab.Panel>
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
) : ( ) : (

View File

@@ -7,11 +7,17 @@ import {Dialog, Tab, Transition} from "@headlessui/react";
import {writingReverseMarking} from "@/utils/score"; import {writingReverseMarking} from "@/utils/score";
import clsx from "clsx"; import clsx from "clsx";
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer"; import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
import useUser from "@/hooks/useUser";
import AIDetection from "../AIDetection";
export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) { export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [showDiff, setShowDiff] = useState(false); const [showDiff, setShowDiff] = useState(false);
const {user} = useUser();
const aiEval = userSolutions && userSolutions.length > 0 ? userSolutions[0].evaluation?.ai_detection : undefined;
return ( return (
<> <>
{attachment && ( {attachment && (
@@ -117,11 +123,16 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && ( {userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-4 w-full">
<div className="flex gap-4 px-1"> <div className="flex gap-4 px-1">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => ( {Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
<div className="bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2" key={key}> const taskResponse = userSolutions[0].evaluation!.task_response[key];
{key}: Level {userSolutions[0].evaluation!.task_response[key]} const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
</div>
))} return (
<div className={clsx("bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2")} key={key}>
{key}: Level {grade}
</div>
);
})}
</div> </div>
{userSolutions[0].evaluation && userSolutions[0].evaluation.perfect_answer ? ( {userSolutions[0].evaluation && userSolutions[0].evaluation.perfect_answer ? (
<Tab.Group> <Tab.Group>
@@ -148,6 +159,30 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
}> }>
Recommended Answer Recommended Answer
</Tab> </Tab>
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
)
}>
Global Overview
</Tab>
{aiEval && user?.type !== "student" && (
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
)
}>
AI Use
</Tab>
)}
</Tab.List> </Tab.List>
<Tab.Panels> <Tab.Panels>
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4"> <Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
@@ -158,6 +193,28 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
{userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n").replaceAll("\\n", "\n")} {userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n").replaceAll("\\n", "\n")}
</span> </span>
</Tab.Panel> </Tab.Panel>
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<div className="flex flex-col gap-4">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
const taskResponse = userSolutions[0].evaluation!.task_response[key];
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
return (
<div key={key} className="flex flex-col gap-2">
<span className={"font-semibold"}>
{key}: Level {grade}
</span>
{typeof taskResponse !== "number" && <span>{taskResponse.comment}</span>}
</div>
);
})}
</div>
</Tab.Panel>
{aiEval && user?.type !== "student" && (
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<AIDetection {...aiEval} />
</Tab.Panel>
)}
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
) : ( ) : (

View File

@@ -22,7 +22,6 @@ import Writing from "./Writing";
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), {ssr: false}); const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), {ssr: false});
export interface CommonProps { export interface CommonProps {
updateIndex?: (internalIndex: number) => void;
onNext: (userSolutions: UserSolution) => void; onNext: (userSolutions: UserSolution) => void;
onBack: (userSolutions: UserSolution) => void; onBack: (userSolutions: UserSolution) => void;
} }
@@ -36,15 +35,7 @@ export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: (
case "matchSentences": case "matchSentences":
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />; return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
case "multipleChoice": case "multipleChoice":
return ( return <MultipleChoice key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
<MultipleChoice
key={exercise.id}
{...(exercise as MultipleChoiceExercise)}
updateIndex={updateIndex}
onNext={onNext}
onBack={onBack}
/>
);
case "writeBlanks": case "writeBlanks":
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />; return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
case "writing": case "writing":

File diff suppressed because it is too large Load Diff

View File

@@ -10,4 +10,4 @@
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-3ml0u%40storied-phalanx-349916.iam.gserviceaccount.com", "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-3ml0u%40storied-phalanx-349916.iam.gserviceaccount.com",
"universe_domain": "googleapis.com" "universe_domain": "googleapis.com"
} }

View File

@@ -0,0 +1,13 @@
{
"type": "service_account",
"project_id": "encoach-staging",
"private_key_id": "5718a649419776df9637589f8696a258a6a70f6c",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC2C6Es2gY8lLvH\ndVilNtRNm9glSaPXMNw2PzZZbSGuG1uGPFaCzlq1lOb2u17YfMG4GriKIMjIQKXF\nqdvxA8CAmAFRuDjUGmpbO/X1ZW7amOs5Bjed2BYmL01dEqzzwwh7rEfNDjeghRPx\n1uKzH8A6TLT5xq+74I5K1CIgiljBpZimsERu2SDawjkdtZfA7qoylA46Nq66LuwQ\nVyv9CK2SZNpBcT3sunCmRsrCzmSTzKdbcqRPdqUKgZOH/Rjp0sw9VuUgwoxdGZV3\n5SJjObo5ceZ1OSiJm7GwLzp7uq16sqycgSYwppNLI5OtzOfSuWbGD4+a044t2Mlq\n9PHXv7H/AgMBAAECggEAAfhKlFwq8MaL6PggRJq9HbaKgQ4fcOmCmy8AQmPNF1UM\nyVKSKGndjxUfPLCWsaaunUnjZlHoKkvndKXxDyttuVaBE9EiWEqNjRLZ3KpuJ9Jm\nH+CtLbmUCnISQb1n1AlvvZAwhLZbLBL/PhYyWiLapybZAdJAaOWLVKGgBD8gVRQW\nJFCqnszX1O2YlpWHutb979R4qoY/XAf94gyMkTpXZwuETvFqZbau2vxRZ8qARix3\nmic881PwiF6Cod8UPCS9yMK+Q+Se6SomwXU9PCmlummn9xmQBAxYy8gIAVs/J9Fg\n5SvhnImAPDd+zIzzw2cHCiruNWIhroMVZDZJgWdY1QKBgQDjTKKeFOur3ijJJL2/\nWg1SE2jLP0GpXzM5YMx6jdOCNDCzugPngRucRXiTkJ2FnUgyMcQyi6hyrbWXN/6z\nXhx5fwLB4tnTcqOMvNfcay5mDk3RW9ZZJxayB54Sf1Nm/4xiDBnGPT+iHQvK+/pT\nwScWznFkmk60E796o76OLn3PEwKBgQDNCC2uPq+uOcCopIO8HH88gqdxTvpbeHUU\nrdJOmr1VtGNuvay/mfpva9+VEtGbZTFzjhfvfCEIjpj3Llh8Flb9EYa6BmscBiyp\ngszEeFuB3zHndlSCZPnGJ7JiRAdPAEgG3Gl/r9th6PDaEMq0MFS5i7GGhPBIRYCG\nUtmY5eVy5QKBgH5Nuls/YsnJFD7ZNLscziQadvPhvZnhNbSfjmBXaP2EBMAKEFtX\nCcGndN4C0RVLFbAWqWAw7LR0xGA4FEcVd5snsZ+Nb98oZ6sv0H9B67F4J1O7xXsa\n1mitBPBgYjbsr9RXxwa6SB7MJx5vMGXUAeWRZ78wY6V7B76dOKkHOo+TAoGBAJf5\nBOsPueZZFm2qK58GPGVcrsI0+StNuPLP+H+dANQC9mTCIMaQWmm2Oq5jmYwmUKZH\nX4R6rH2MPOOSrbGkWWwRTpyaX1ARX49xzVefoqw8BOB8/Bz+vYjcKcPeitBK9Bhp\nzaUAc4s6PzRTl/xBirtRSQ/df8ECC0cFKBbF6PHlAoGAGqnlpo+k8vAtg6ulCuGu\nx2Y/c5UmvXGHk60pccnW3UtENSDnl99OgMfBz8/qLAMWs6DUQ/kvSlHQPmMBHRWZ\nNTr6ceGXyNs4KdYoj1K7AU3c0Lm0wyQ2giQMoOOUQAm98Xr8z5aiihj10hHPmzzL\n9kwpOmZpjNmC/ERD69imWhY=\n-----END PRIVATE KEY-----\n",
"client_email": "firebase-adminsdk-8rs9e@encoach-staging.iam.gserviceaccount.com",
"client_id": "108221424237414412378",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-8rs9e%40encoach-staging.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}

View File

@@ -1,39 +1,91 @@
import {Type} from "@/interfaces/user"; import { Type } from "@/interfaces/user";
export const PERMISSIONS = { export const PERMISSIONS = {
generateCode: { generateCode: {
student: ["corporate", "developer", "admin"], student: ["corporate", "developer", "admin", "mastercorporate"],
teacher: ["corporate", "developer", "admin"], teacher: ["corporate", "developer", "admin", "mastercorporate"],
corporate: ["admin", "developer"], corporate: ["admin", "developer"],
admin: ["developer", "admin"], mastercorporate: ["admin", "developer"],
agent: ["developer", "admin"],
developer: ["developer"], admin: ["developer", "admin"],
}, agent: ["developer", "admin"],
deleteUser: { developer: ["developer"],
student: ["corporate", "developer", "admin"], },
teacher: ["corporate", "developer", "admin"], deleteUser: {
corporate: ["admin", "developer"], student: {
admin: ["developer", "admin"], perm: "deleteStudent",
agent: ["developer", "admin"], list: ["corporate", "developer", "admin", "mastercorporate"],
developer: ["developer"], },
}, teacher: {
updateUser: { perm: "deleteTeacher",
student: ["developer", "admin"], list: ["corporate", "developer", "admin", "mastercorporate"],
teacher: ["developer", "admin"], },
corporate: ["admin", "developer"], corporate: {
admin: ["developer", "admin"], perm: "deleteCorporate",
agent: ["developer", "admin"], list: ["admin", "developer"],
developer: ["developer"], },
}, mastercorporate: {
updateExpiryDate: { perm: undefined,
student: ["developer", "admin"], list: ["admin", "developer"],
teacher: ["developer", "admin"], },
corporate: ["admin", "developer"],
admin: ["developer", "admin"], admin: {
agent: ["developer", "admin"], perm: "deleteAdmin",
developer: ["developer"], list: ["developer", "admin"],
}, },
examManagement: { agent: {
delete: ["developer", "admin"], perm: "deleteCountryManager",
}, list: ["developer", "admin"],
},
developer: {
perm: undefined,
list: ["developer"],
},
},
updateUser: {
student: {
perm: "editStudent",
list: ["developer", "admin"],
},
teacher: {
perm: "editTeacher",
list: ["developer", "admin"],
},
corporate: {
perm: "editCorporate",
list: ["admin", "developer"],
},
mastercorporate: {
perm: undefined,
list: ["admin", "developer"],
},
admin: {
perm: "editAdmin",
list: ["developer", "admin"],
},
agent: {
perm: "editCountryManager",
list: ["developer", "admin"],
},
developer: {
perm: undefined,
list: ["developer"],
},
},
updateExpiryDate: {
student: ["developer", "admin"],
teacher: ["developer", "admin"],
corporate: ["admin", "developer"],
mastercorporate: ["admin", "developer"],
admin: ["developer", "admin"],
agent: ["developer", "admin"],
developer: ["developer"],
},
examManagement: {
delete: ["developer", "admin"],
},
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -2,243 +2,294 @@
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import useStats from "@/hooks/useStats"; import useStats from "@/hooks/useStats";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import { User} from "@/interfaces/user"; import { User } from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList"; import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils"; import { dateSorter } from "@/utils";
import moment from "moment"; import moment from "moment";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import {BsArrowLeft, BsPersonFill, BsBank, BsCurrencyDollar} from "react-icons/bs"; import {
BsArrowLeft,
BsPersonFill,
BsBank,
BsCurrencyDollar,
} from "react-icons/bs";
import UserCard from "@/components/UserCard"; import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import IconCard from "./IconCard"; import IconCard from "./IconCard";
import usePaymentStatusUsers from '@/hooks/usePaymentStatusUsers'; import usePaymentStatusUsers from "@/hooks/usePaymentStatusUsers";
interface Props { interface Props {
user: User; user: User;
} }
export default function AgentDashboard({user}: Props) { export default function AgentDashboard({ user }: Props) {
const [page, setPage] = useState(""); const [page, setPage] = useState("");
const [selectedUser, setSelectedUser] = useState<User>(); const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const {stats} = useStats(); const { stats } = useStats();
const {users, reload} = useUsers(); const { users, reload } = useUsers();
const {groups} = useGroups(user.id); const { groups } = useGroups(user.id);
const { pending, done } = usePaymentStatusUsers(); const { pending, done } = usePaymentStatusUsers();
useEffect(() => { useEffect(() => {
setShowModal(!!selectedUser && page === ""); setShowModal(!!selectedUser && page === "");
}, [selectedUser, page]); }, [selectedUser, page]);
const corporateFilter = (user: User) => user.type === "corporate"; const corporateFilter = (user: User) => user.type === "corporate";
const referredCorporateFilter = (x: User) => const referredCorporateFilter = (x: User) =>
x.type === "corporate" && !!x.corporateInformation && x.corporateInformation.referralAgent === user.id; x.type === "corporate" &&
const inactiveReferredCorporateFilter = (x: User) => !!x.corporateInformation &&
referredCorporateFilter(x) && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)); x.corporateInformation.referralAgent === user.id;
const inactiveReferredCorporateFilter = (x: User) =>
referredCorporateFilter(x) &&
(x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
const UserDisplay = ({ displayUser, allowClick = true }: {displayUser: User, allowClick?: boolean}) => ( const UserDisplay = ({
<div displayUser,
onClick={() => allowClick && setSelectedUser(displayUser)} allowClick = true,
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300"> }: {
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" /> displayUser: User;
<div className="flex flex-col gap-1 items-start"> allowClick?: boolean;
<span> }) => (
{displayUser.type === "corporate" <div
? displayUser.corporateInformation?.companyInformation?.name || displayUser.name onClick={() => allowClick && setSelectedUser(displayUser)}
: displayUser.name} className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300"
</span> >
<span className="text-sm opacity-75">{displayUser.email}</span> <img
</div> src={displayUser.profilePicture}
</div> alt={displayUser.name}
); className="rounded-full w-10 h-10"
/>
<div className="flex flex-col gap-1 items-start">
<span>
{displayUser.type === "corporate"
? displayUser.corporateInformation?.companyInformation?.name ||
displayUser.name
: displayUser.name}
</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
const ReferredCorporateList = () => { const ReferredCorporateList = () => {
return ( return (
<> <UserList
<div className="flex flex-col gap-4"> user={user}
<div filters={[referredCorporateFilter]}
onClick={() => setPage("")} renderHeader={(total) => (
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> <div className="flex flex-col gap-4">
<BsArrowLeft className="text-xl" /> <div
<span>Back</span> onClick={() => setPage("")}
</div> className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
<h2 className="text-2xl font-semibold">Referred Corporate ({users.filter(referredCorporateFilter).length})</h2> >
</div> <BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">
Referred Corporate ({total})
</h2>
</div>
)}
/>
);
};
<UserList user={user} filters={[referredCorporateFilter]} /> const InactiveReferredCorporateList = () => {
</> return (
); <UserList
}; user={user}
filters={[inactiveReferredCorporateFilter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">
Inactive Referred Corporate ({total})
</h2>
</div>
)}
/>
);
};
const InactiveReferredCorporateList = () => { const CorporateList = () => {
return ( const filter = (x: User) => x.type === "corporate";
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Inactive Referred Corporate ({users.filter(inactiveReferredCorporateFilter).length})</h2>
</div>
<UserList user={user} filters={[inactiveReferredCorporateFilter]} /> return (
</> <UserList
); user={user}
}; filters={[filter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Corporate ({total})</h2>
</div>
)}
/>
);
};
const CorporateList = () => { const CorporatePaidStatusList = ({ paid }: { paid: Boolean }) => {
const filter = (x: User) => x.type === "corporate"; const list = paid ? done : pending;
const filter = (x: User) => x.type === "corporate" && list.includes(x.id);
return ( return (
<> <UserList
<div className="flex flex-col gap-4"> user={user}
<div filters={[filter]}
onClick={() => setPage("")} renderHeader={(total) => (
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> <div className="flex flex-col gap-4">
<BsArrowLeft className="text-xl" /> <div
<span>Back</span> onClick={() => setPage("")}
</div> className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
<h2 className="text-2xl font-semibold">Corporate ({users.filter(filter).length})</h2> >
</div> <BsArrowLeft className="text-xl" />
<UserList user={user} filters={[filter]} /> <span>Back</span>
</> </div>
); <h2 className="text-2xl font-semibold">
}; {paid ? "Payment Done" : "Pending Payment"} ({total})
</h2>
</div>
)}
/>
);
};
const CorporatePaidStatusList = ({ paid }: {paid: Boolean}) => { const DefaultDashboard = () => (
const list = paid ? done : pending; <>
const filter = (x: User) => x.type === "corporate" && list.includes(x.id); <section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:gap-4 text-center">
<IconCard
onClick={() => setPage("referredCorporate")}
Icon={BsBank}
label="Referred Corporate"
value={users.filter(referredCorporateFilter).length}
color="purple"
/>
<IconCard
onClick={() => setPage("inactiveReferredCorporate")}
Icon={BsBank}
label="Inactive Referred Corporate"
value={users.filter(inactiveReferredCorporateFilter).length}
color="rose"
/>
<IconCard
onClick={() => setPage("corporate")}
Icon={BsBank}
label="Corporate"
value={users.filter(corporateFilter).length}
color="purple"
/>
<IconCard
onClick={() => setPage("paymentdone")}
Icon={BsCurrencyDollar}
label="Payment Done"
value={done.length}
color="purple"
/>
<IconCard
onClick={() => setPage("paymentpending")}
Icon={BsCurrencyDollar}
label="Pending Payment"
value={pending.length}
color="rose"
/>
</section>
return ( <section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<> <div className="bg-white shadow flex flex-col rounded-xl w-full">
<div className="flex flex-col gap-4"> <span className="p-4">Latest Referred Corporate</span>
<div <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
onClick={() => setPage("")} {users
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> .filter(referredCorporateFilter)
<BsArrowLeft className="text-xl" /> .sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
<span>Back</span> .map((x) => (
</div> <UserDisplay key={x.id} displayUser={x} />
<h2 className="text-2xl font-semibold">{paid ? 'Payment Done' : 'Pending Payment'} ({list.length})</h2> ))}
</div> </div>
<UserList user={user} filters={[filter]} /> </div>
</> <div className="bg-white shadow flex flex-col rounded-xl w-full">
); <span className="p-4">Latest corporate</span>
}; <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(corporateFilter)
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} displayUser={x} allowClick={false} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Referenced corporate expiring in 1 month</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(
(x) =>
referredCorporateFilter(x) &&
moment().isAfter(
moment(x.subscriptionExpirationDate).subtract(30, "days")
) &&
moment().isBefore(moment(x.subscriptionExpirationDate))
)
.map((x) => (
<UserDisplay key={x.id} displayUser={x} />
))}
</div>
</div>
</section>
</>
);
const DefaultDashboard = () => ( return (
<> <>
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:gap-4 text-center"> <Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
<IconCard <>
onClick={() => setPage("referredCorporate")} {selectedUser && (
Icon={BsBank} <div className="w-full flex flex-col gap-8">
label="Referred Corporate" <UserCard
value={users.filter(referredCorporateFilter).length} loggedInUser={user}
color="purple" onClose={(shouldReload) => {
/> setSelectedUser(undefined);
<IconCard if (shouldReload) reload();
onClick={() => setPage("inactiveReferredCorporate")} }}
Icon={BsBank} onViewStudents={
label="Inactive Referred Corporate" selectedUser.type === "corporate" ||
value={users.filter(inactiveReferredCorporateFilter).length} selectedUser.type === "teacher"
color="rose" ? () => setPage("students")
/> : undefined
<IconCard }
onClick={() => setPage("corporate")} onViewTeachers={
Icon={BsBank} selectedUser.type === "corporate"
label="Corporate" ? () => setPage("teachers")
value={users.filter(corporateFilter).length} : undefined
color="purple" }
/> user={selectedUser}
<IconCard />
onClick={() => setPage("paymentdone")} </div>
Icon={BsCurrencyDollar} )}
label="Payment Done" </>
value={done.length} </Modal>
color="purple" {page === "referredCorporate" && <ReferredCorporateList />}
/> {page === "corporate" && <CorporateList />}
<IconCard {page === "inactiveReferredCorporate" && (
onClick={() => setPage("paymentpending")} <InactiveReferredCorporateList />
Icon={BsCurrencyDollar} )}
label="Pending Payment" {page === "paymentdone" && <CorporatePaidStatusList paid={true} />}
value={pending.length} {page === "paymentpending" && <CorporatePaidStatusList paid={false} />}
color="rose" {page === "" && <DefaultDashboard />}
/> </>
</section> );
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest Referred Corporate</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(referredCorporateFilter)
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} displayUser={x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest corporate</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(corporateFilter)
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} displayUser={x} allowClick={false} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Referenced corporate expiring in 1 month</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(
(x) =>
referredCorporateFilter(x) &&
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
moment().isBefore(moment(x.subscriptionExpirationDate)),
)
.map((x) => (
<UserDisplay key={x.id} displayUser={x} />
))}
</div>
</div>
</section>
</>
);
return (
<>
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
<>
{selectedUser && (
<div className="w-full flex flex-col gap-8">
<UserCard
loggedInUser={user}
onClose={(shouldReload) => {
setSelectedUser(undefined);
if (shouldReload) reload();
}}
onViewStudents={
selectedUser.type === "corporate" || selectedUser.type === "teacher" ? () => setPage("students") : undefined
}
onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined}
user={selectedUser}
/>
</div>
)}
</>
</Modal>
{page === "referredCorporate" && <ReferredCorporateList />}
{page === "corporate" && <CorporateList />}
{page === "inactiveReferredCorporate" && <InactiveReferredCorporateList />}
{page === "paymentdone" && <CorporatePaidStatusList paid={true} />}
{page === "paymentpending" && <CorporatePaidStatusList paid={false} />}
{page === "" && <DefaultDashboard />}
</>
);
} }

View File

@@ -2,7 +2,7 @@ import Input from "@/components/Low/Input";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import clsx from "clsx"; import clsx from "clsx";
import {useState} from "react"; import {useEffect, useState} from "react";
import {BsBook, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs"; import {BsBook, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
import {generate} from "random-words"; import {generate} from "random-words";
import {capitalize} from "lodash"; import {capitalize} from "lodash";
@@ -16,11 +16,11 @@ import moment from "moment";
import axios from "axios"; import axios from "axios";
import {getExam} from "@/utils/exams"; import {getExam} from "@/utils/exams";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import {uuidv4} from "@firebase/util";
import {Assignment} from "@/interfaces/results"; import {Assignment} from "@/interfaces/results";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import {InstructorGender, Variant} from "@/interfaces/exam"; import {InstructorGender, Variant} from "@/interfaces/exam";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import useExams from "@/hooks/useExams";
interface Props { interface Props {
isCreating: boolean; isCreating: boolean;
@@ -44,6 +44,14 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
const [instructorGender, setInstructorGender] = useState<InstructorGender>(assignment?.instructorGender || "varied"); const [instructorGender, setInstructorGender] = useState<InstructorGender>(assignment?.instructorGender || "varied");
// creates a new exam for each assignee or just one exam for all assignees // creates a new exam for each assignee or just one exam for all assignees
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false); const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
const [useRandomExams, setUseRandomExams] = useState(true);
const [examIDs, setExamIDs] = useState<{id: string; module: Module}[]>([]);
const {exams} = useExams();
useEffect(() => {
setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module)));
}, [selectedModules]);
const toggleModule = (module: Module) => { const toggleModule = (module: Module) => {
const modules = selectedModules.filter((x) => x !== module); const modules = selectedModules.filter((x) => x !== module);
@@ -61,6 +69,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
assignees, assignees,
name, name,
startDate, startDate,
examIDs: !useRandomExams ? examIDs : undefined,
endDate, endDate,
selectedModules, selectedModules,
generateMultiple, generateMultiple,
@@ -229,19 +238,52 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
</div> </div>
</div> </div>
<div className="flex flex-col gap-3 w-full"> {selectedModules.includes("speaking") && (
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor&apos;s Gender</label> <div className="flex flex-col gap-3 w-full">
<Select <label className="font-normal text-base text-mti-gray-dim">Speaking Instructor&apos;s Gender</label>
value={{value: instructorGender, label: capitalize(instructorGender)}} <Select
onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)} value={{value: instructorGender, label: capitalize(instructorGender)}}
disabled={!selectedModules.includes("speaking") || !!assignment} onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)}
options={[ disabled={!selectedModules.includes("speaking") || !!assignment}
{value: "male", label: "Male"}, options={[
{value: "female", label: "Female"}, {value: "male", label: "Male"},
{value: "varied", label: "Varied"}, {value: "female", label: "Female"},
]} {value: "varied", label: "Varied"},
/> ]}
</div> />
</div>
)}
{selectedModules.length > 0 && (
<div className="flex flex-col gap-3 w-full">
<Checkbox isChecked={useRandomExams} onChange={setUseRandomExams}>
Random Exams
</Checkbox>
{!useRandomExams && (
<div className="grid md:grid-cols-2 w-full gap-4">
{selectedModules.map((module) => (
<div key={module} className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">{capitalize(module)} Exam</label>
<Select
value={{
value: examIDs.find((e) => e.module === module)?.id || null,
label: examIDs.find((e) => e.module === module)?.id || "",
}}
onChange={(value) =>
value
? setExamIDs((prev) => [...prev.filter((x) => x.module !== module), {id: value.value!, module}])
: setExamIDs((prev) => prev.filter((x) => x.module !== module))
}
options={exams
.filter((x) => !x.isDiagnostic && x.module === module)
.map((x) => ({value: x.id, label: x.id}))}
/>
</div>
))}
</div>
)}
</div>
)}
<section className="w-full flex flex-col gap-3"> <section className="w-full flex flex-col gap-3">
<span className="font-semibold">Assignees ({assignees.length} selected)</span> <span className="font-semibold">Assignees ({assignees.length} selected)</span>
@@ -323,7 +365,14 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
</Button> </Button>
)} )}
<Button <Button
disabled={selectedModules.length === 0 || !name || !startDate || !endDate || assignees.length === 0} disabled={
selectedModules.length === 0 ||
!name ||
!startDate ||
!endDate ||
assignees.length === 0 ||
(!!examIDs && examIDs.length < selectedModules.length)
}
className="w-full max-w-[200px]" className="w-full max-w-[200px]"
onClick={createAssignment} onClick={createAssignment}
isLoading={isLoading}> isLoading={isLoading}>

View File

@@ -2,325 +2,412 @@
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import useStats from "@/hooks/useStats"; import useStats from "@/hooks/useStats";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import {CorporateUser, Group, Stat, User} from "@/interfaces/user"; import { CorporateUser, Group, Stat, User } from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList"; import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils"; import { dateSorter } from "@/utils";
import moment from "moment"; import moment from "moment";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import { import {
BsArrowLeft, BsArrowLeft,
BsClipboard2Data, BsClipboard2Data,
BsClipboard2DataFill, BsClipboard2DataFill,
BsClock, BsClock,
BsGlobeCentralSouthAsia, BsGlobeCentralSouthAsia,
BsPaperclip, BsPaperclip,
BsPerson, BsPerson,
BsPersonAdd, BsPersonAdd,
BsPersonFill, BsPersonFill,
BsPersonFillGear, BsPersonFillGear,
BsPersonGear, BsPersonGear,
BsPencilSquare, BsPencilSquare,
BsPersonBadge, BsPersonBadge,
BsPersonCheck, BsPersonCheck,
BsPeople, BsPeople,
} from "react-icons/bs"; } from "react-icons/bs";
import UserCard from "@/components/UserCard"; import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import {calculateAverageLevel, calculateBandScore} from "@/utils/score"; import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
import {MODULE_ARRAY} from "@/utils/moduleUtils"; import { MODULE_ARRAY } from "@/utils/moduleUtils";
import {Module} from "@/interfaces"; import { Module } from "@/interfaces";
import {groupByExam} from "@/utils/stats"; import { groupByExam } from "@/utils/stats";
import IconCard from "./IconCard"; import IconCard from "./IconCard";
import GroupList from "@/pages/(admin)/Lists/GroupList"; import GroupList from "@/pages/(admin)/Lists/GroupList";
import useFilterStore from "@/stores/listFilterStore"; import useFilterStore from "@/stores/listFilterStore";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import useCodes from "@/hooks/useCodes"; import useCodes from "@/hooks/useCodes";
import { getUserCorporate } from "@/utils/groups";
interface Props { interface Props {
user: CorporateUser; user: CorporateUser;
} }
export default function CorporateDashboard({user}: Props) { export default function CorporateDashboard({ user }: Props) {
const [page, setPage] = useState(""); const [page, setPage] = useState("");
const [selectedUser, setSelectedUser] = useState<User>(); const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [corporateUserToShow, setCorporateUserToShow] =
useState<CorporateUser>();
const {stats} = useStats(); const { stats } = useStats();
const {users, reload} = useUsers(); const { users, reload } = useUsers();
const {codes} = useCodes(user.id); const { codes } = useCodes(user.id);
const {groups} = useGroups(user.id); const { groups } = useGroups(user.id);
const appendUserFilters = useFilterStore((state) => state.appendUserFilter); const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
setShowModal(!!selectedUser && page === ""); setShowModal(!!selectedUser && page === "");
}, [selectedUser, page]); }, [selectedUser, page]);
const studentFilter = (user: User) => user.type === "student" && groups.flatMap((g) => g.participants).includes(user.id); useEffect(() => {
const teacherFilter = (user: User) => user.type === "teacher" && groups.flatMap((g) => g.participants).includes(user.id); // in this case it fetches the master corporate account
getUserCorporate(user.id).then(setCorporateUserToShow);
}, [user]);
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id); const studentFilter = (user: User) =>
user.type === "student" &&
groups.flatMap((g) => g.participants).includes(user.id);
const teacherFilter = (user: User) =>
user.type === "teacher" &&
groups.flatMap((g) => g.participants).includes(user.id);
const UserDisplay = (displayUser: User) => ( const getStatsByStudent = (user: User) =>
<div stats.filter((s) => s.user === user.id);
onClick={() => setSelectedUser(displayUser)}
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
<div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
const StudentsList = () => { const UserDisplay = (displayUser: User) => (
const filter = (x: User) => <div
x.type === "student" && onClick={() => setSelectedUser(displayUser)}
(!!selectedUser className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300"
? groups >
.filter((g) => g.admin === selectedUser.id) <img
.flatMap((g) => g.participants) src={displayUser.profilePicture}
.includes(x.id) || false alt={displayUser.name}
: groups.flatMap((g) => g.participants).includes(x.id)); className="rounded-full w-10 h-10"
/>
<div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
return ( const StudentsList = () => {
<> const filter = (x: User) =>
<div className="flex flex-col gap-4"> x.type === "student" &&
<div (!!selectedUser
onClick={() => setPage("")} ? groups
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> .filter((g) => g.admin === selectedUser.id)
<BsArrowLeft className="text-xl" /> .flatMap((g) => g.participants)
<span>Back</span> .includes(x.id) || false
</div> : groups.flatMap((g) => g.participants).includes(x.id));
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
</div>
<UserList user={user} filters={[filter]} /> return (
</> <UserList
); user={user}
}; filters={[filter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Students ({total})</h2>
</div>
)}
/>
);
};
const TeachersList = () => { const TeachersList = () => {
const filter = (x: User) => const filter = (x: User) =>
x.type === "teacher" && x.type === "teacher" &&
(!!selectedUser (!!selectedUser
? groups ? groups
.filter((g) => g.admin === selectedUser.id) .filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants) .flatMap((g) => g.participants)
.includes(x.id) || false .includes(x.id) || false
: groups.flatMap((g) => g.participants).includes(x.id)); : groups.flatMap((g) => g.participants).includes(x.id));
return ( return (
<> <UserList
<div className="flex flex-col gap-4"> user={user}
<div filters={[filter]}
onClick={() => setPage("")} renderHeader={(total) => (
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> <div className="flex flex-col gap-4">
<BsArrowLeft className="text-xl" /> <div
<span>Back</span> onClick={() => setPage("")}
</div> className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
<h2 className="text-2xl font-semibold">Teachers ({users.filter(filter).length})</h2> >
</div> <BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Teachers ({total})</h2>
</div>
)}
/>
);
};
<UserList user={user} filters={[filter]} /> const GroupsList = () => {
</> const filter = (x: Group) =>
); x.admin === user.id || x.participants.includes(user.id);
};
const GroupsList = () => { return (
const filter = (x: Group) => x.admin === user.id || x.participants.includes(user.id); <>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">
Groups ({groups.filter(filter).length})
</h2>
</div>
return ( <GroupList user={user} />
<> </>
<div className="flex flex-col gap-4"> );
<div };
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Groups ({groups.filter(filter).length})</h2>
</div>
<GroupList user={user} /> const averageLevelCalculator = (studentStats: Stat[]) => {
</> const formattedStats = studentStats
); .map((s) => ({
}; focus: users.find((u) => u.id === s.user)?.focus,
score: s.score,
module: s.module,
}))
.filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({
module: s.module,
level: calculateBandScore(
s.score.correct,
s.score.total,
s.module,
s.focus!
),
}));
const averageLevelCalculator = (studentStats: Stat[]) => { const levels: { [key in Module]: number } = {
const formattedStats = studentStats reading: 0,
.map((s) => ({focus: users.find((u) => u.id === s.user)?.focus, score: s.score, module: s.module})) listening: 0,
.filter((f) => !!f.focus); writing: 0,
const bandScores = formattedStats.map((s) => ({ speaking: 0,
module: s.module, level: 0,
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!), };
})); bandScores.forEach((b) => (levels[b.module] += b.level));
const levels: {[key in Module]: number} = {reading: 0, listening: 0, writing: 0, speaking: 0, level: 0}; return calculateAverageLevel(levels);
bandScores.forEach((b) => (levels[b.module] += b.level)); };
return calculateAverageLevel(levels); const DefaultDashboard = () => (
}; <>
{corporateUserToShow && (
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
Linked to:{" "}
<b>
{corporateUserToShow?.corporateInformation?.companyInformation
.name || corporateUserToShow.name}
</b>
</div>
)}
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:justify-between text-center">
<IconCard
onClick={() => setPage("students")}
Icon={BsPersonFill}
label="Students"
value={users.filter(studentFilter).length}
color="purple"
/>
<IconCard
onClick={() => setPage("teachers")}
Icon={BsPencilSquare}
label="Teachers"
value={users.filter(teacherFilter).length}
color="purple"
/>
<IconCard
Icon={BsClipboard2Data}
label="Exams Performed"
value={
stats.filter((s) =>
groups.flatMap((g) => g.participants).includes(s.user)
).length
}
color="purple"
/>
<IconCard
Icon={BsPaperclip}
label="Average Level"
value={averageLevelCalculator(
stats.filter((s) =>
groups.flatMap((g) => g.participants).includes(s.user)
)
).toFixed(1)}
color="purple"
/>
<IconCard
onClick={() => setPage("groups")}
Icon={BsPeople}
label="Groups"
value={groups.length}
color="purple"
/>
<IconCard
Icon={BsPersonCheck}
label="User Balance"
value={`${codes.length}/${
user.corporateInformation?.companyInformation?.userAmount || 0
}`}
color="purple"
/>
<IconCard
Icon={BsClock}
label="Expiration Date"
value={
user.subscriptionExpirationDate
? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy")
: "Unlimited"
}
color="rose"
/>
</section>
const DefaultDashboard = () => ( <section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<> <div className="bg-white shadow flex flex-col rounded-xl w-full">
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:justify-between text-center"> <span className="p-4">Latest students</span>
<IconCard <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
onClick={() => setPage("students")} {users
Icon={BsPersonFill} .filter(studentFilter)
label="Students" .sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
value={users.filter(studentFilter).length} .map((x) => (
color="purple" <UserDisplay key={x.id} {...x} />
/> ))}
<IconCard </div>
onClick={() => setPage("teachers")} </div>
Icon={BsPencilSquare} <div className="bg-white shadow flex flex-col rounded-xl w-full">
label="Teachers" <span className="p-4">Latest teachers</span>
value={users.filter(teacherFilter).length} <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
color="purple" {users
/> .filter(teacherFilter)
<IconCard .sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
Icon={BsClipboard2Data} .map((x) => (
label="Exams Performed" <UserDisplay key={x.id} {...x} />
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length} ))}
color="purple" </div>
/> </div>
<IconCard <div className="bg-white shadow flex flex-col rounded-xl w-full">
Icon={BsPaperclip} <span className="p-4">Highest level students</span>
label="Average Level" <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)} {users
color="purple" .filter(studentFilter)
/> .sort(
<IconCard onClick={() => setPage("groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" /> (a, b) =>
<IconCard calculateAverageLevel(b.levels) -
Icon={BsPersonCheck} calculateAverageLevel(a.levels)
label="User Balance" )
value={`${codes.length}/${user.corporateInformation?.companyInformation?.userAmount || 0}`} .map((x) => (
color="purple" <UserDisplay key={x.id} {...x} />
/> ))}
<IconCard </div>
Icon={BsClock} </div>
label="Expiration Date" <div className="bg-white shadow flex flex-col rounded-xl w-full">
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"} <span className="p-4">Highest exam count students</span>
color="rose" <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
/> {users
</section> .filter(studentFilter)
.sort(
(a, b) =>
Object.keys(groupByExam(getStatsByStudent(b))).length -
Object.keys(groupByExam(getStatsByStudent(a))).length
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
</section>
</>
);
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between"> return (
<div className="bg-white shadow flex flex-col rounded-xl w-full"> <>
<span className="p-4">Latest students</span> <Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <>
{users {selectedUser && (
.filter(studentFilter) <div className="w-full flex flex-col gap-8">
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) <UserCard
.map((x) => ( loggedInUser={user}
<UserDisplay key={x.id} {...x} /> onClose={(shouldReload) => {
))} setSelectedUser(undefined);
</div> if (shouldReload) reload();
</div> }}
<div className="bg-white shadow flex flex-col rounded-xl w-full"> onViewStudents={
<span className="p-4">Latest teachers</span> selectedUser.type === "corporate" ||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> selectedUser.type === "teacher"
{users ? () => {
.filter(teacherFilter) appendUserFilters({
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) id: "view-students",
.map((x) => ( filter: (x: User) => x.type === "student",
<UserDisplay key={x.id} {...x} /> });
))} appendUserFilters({
</div> id: "belongs-to-admin",
</div> filter: (x: User) =>
<div className="bg-white shadow flex flex-col rounded-xl w-full"> groups
<span className="p-4">Highest level students</span> .filter(
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> (g) =>
{users g.admin === selectedUser.id ||
.filter(studentFilter) g.participants.includes(selectedUser.id)
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels)) )
.map((x) => ( .flatMap((g) => g.participants)
<UserDisplay key={x.id} {...x} /> .includes(x.id),
))} });
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest exam count students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(studentFilter)
.sort(
(a, b) =>
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
</section>
</>
);
return ( router.push("/list/users");
<> }
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}> : undefined
<> }
{selectedUser && ( onViewTeachers={
<div className="w-full flex flex-col gap-8"> selectedUser.type === "corporate" ||
<UserCard selectedUser.type === "student"
loggedInUser={user} ? () => {
onClose={(shouldReload) => { appendUserFilters({
setSelectedUser(undefined); id: "view-teachers",
if (shouldReload) reload(); filter: (x: User) => x.type === "teacher",
}} });
onViewStudents={ appendUserFilters({
selectedUser.type === "corporate" || selectedUser.type === "teacher" id: "belongs-to-admin",
? () => { filter: (x: User) =>
appendUserFilters({ groups
id: "view-students", .filter(
filter: (x: User) => x.type === "student", (g) =>
}); g.admin === selectedUser.id ||
appendUserFilters({ g.participants.includes(selectedUser.id)
id: "belongs-to-admin", )
filter: (x: User) => .flatMap((g) => g.participants)
groups .includes(x.id),
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id)) });
.flatMap((g) => g.participants)
.includes(x.id),
});
router.push("/list/users"); router.push("/list/users");
} }
: undefined : undefined
} }
onViewTeachers={ user={selectedUser}
selectedUser.type === "corporate" || selectedUser.type === "student" />
? () => { </div>
appendUserFilters({ )}
id: "view-teachers", </>
filter: (x: User) => x.type === "teacher", </Modal>
}); {page === "students" && <StudentsList />}
appendUserFilters({ {page === "teachers" && <TeachersList />}
id: "belongs-to-admin", {page === "groups" && <GroupsList />}
filter: (x: User) => {page === "" && <DefaultDashboard />}
groups </>
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id)) );
.flatMap((g) => g.participants)
.includes(x.id),
});
router.push("/list/users");
}
: undefined
}
user={selectedUser}
/>
</div>
)}
</>
</Modal>
{page === "students" && <StudentsList />}
{page === "teachers" && <TeachersList />}
{page === "groups" && <GroupsList />}
{page === "" && <DefaultDashboard />}
</>
);
} }

View File

@@ -0,0 +1,140 @@
import React from "react";
import useUsers from "@/hooks/useUsers";
import useGroups from "@/hooks/useGroups";
import { User } from "@/interfaces/user";
import Select from "@/components/Low/Select";
import ProgressBar from "@/components/Low/ProgressBar";
import {
BsBook,
BsClipboard,
BsHeadphones,
BsMegaphone,
BsPen,
} from "react-icons/bs";
import { MODULE_ARRAY } from "@/utils/moduleUtils";
import { capitalize } from "lodash";
import { getLevelLabel } from "@/utils/score";
const Card = ({ user }: { user: User }) => {
return (
<div className="border-mti-gray-platinum flex flex-col h-fit w-full cursor-pointer flex-col gap-6 rounded-xl border bg-white p-4 transition duration-300 ease-in-out hover:drop-shadow">
<div className="flex flex-col gap-3">
<h3 className="text-xl font-semibold">{user.name}</h3>
</div>
<div className="flex w-full gap-3 flex-wrap">
{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 min-w-[250px]"
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">
{module === "reading" && (
<BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" />
)}
{module === "listening" && (
<BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />
)}
{module === "writing" && (
<BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" />
)}
{module === "speaking" && (
<BsMegaphone className="text-ielts-speaking h-4 w-4 md:h-5 md:w-5" />
)}
{module === "level" && (
<BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" />
)}
</div>
<div className="flex w-full flex-col">
<span className="text-sm font-bold md:font-extrabold w-full">
{capitalize(module)}
</span>
<div className="text-mti-gray-dim text-sm font-normal">
{module === "level" && (
<span>
English Level: {getLevelLabel(level).join(" / ")}
</span>
)}
{module !== "level" && (
<div className="flex flex-col">
<span>Level {level} / Level 9</span>
<span>Desired Level: {desiredLevel}</span>
</div>
)}
</div>
</div>
</div>
<div className="md:pl-14">
<ProgressBar
color={module}
label=""
mark={Math.round((desiredLevel * 100) / 9)}
markLabel={`Desired Level: ${desiredLevel}`}
percentage={Math.round((level * 100) / 9)}
className="h-2 w-full"
/>
</div>
</div>
);
})}
</div>
</div>
);
};
const CorporateStudentsLevels = () => {
const { users } = useUsers();
const { groups } = useGroups();
const corporateUsers = users.filter((u) => u.type === "corporate") as User[];
const [corporateId, setCorporateId] = React.useState<string>("");
const corporate =
corporateUsers.find((u) => u.id === corporateId) || corporateUsers[0];
const groupsFromCorporate = corporate
? groups.filter((g) => g.admin === corporate.id)
: [];
const groupsParticipants = groupsFromCorporate
.flatMap((g) => g.participants)
.reduce((accm: User[], p) => {
const user = users.find((u) => u.id === p) as User;
if (user) {
return [...accm, user];
}
return accm;
}, []);
return (
<>
<Select
options={corporateUsers.map((x: User) => ({
value: x.id,
label: `${x.name} - ${x.email}`,
}))}
value={corporate ? { value: corporate.id, label: corporate.name } : null}
onChange={(value) => setCorporateId(value?.value!)}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused
? "#D5D9F0"
: state.isSelected
? "#7872BF"
: "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
{groupsParticipants.map((u) => (
<Card user={u} key={u.id} />
))}
</>
);
};
export default CorporateStudentsLevels;

View File

@@ -0,0 +1,424 @@
/* eslint-disable @next/next/no-img-element */
import Modal from "@/components/Modal";
import useStats from "@/hooks/useStats";
import useUsers from "@/hooks/useUsers";
import { Group, MasterCorporateUser, Stat, User } from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList";
import { dateSorter } from "@/utils";
import moment from "moment";
import { useEffect, useState } from "react";
import {
BsArrowLeft,
BsClipboard2Data,
BsClock,
BsPaperclip,
BsPersonFill,
BsPencilSquare,
BsPersonCheck,
BsPeople,
BsBank,
} from "react-icons/bs";
import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups";
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
import { MODULE_ARRAY } from "@/utils/moduleUtils";
import { Module } from "@/interfaces";
import { groupByExam } from "@/utils/stats";
import IconCard from "./IconCard";
import GroupList from "@/pages/(admin)/Lists/GroupList";
import useFilterStore from "@/stores/listFilterStore";
import { useRouter } from "next/router";
import useCodes from "@/hooks/useCodes";
interface Props {
user: MasterCorporateUser;
}
export default function MasterCorporateDashboard({ user }: Props) {
const [page, setPage] = useState("");
const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false);
const { stats } = useStats();
const { users, reload } = useUsers();
const { codes } = useCodes(user.id);
const { groups } = useGroups(user.id, user.type);
const masterCorporateUserGroups = [
...new Set(
groups.filter((u) => u.admin === user.id).flatMap((g) => g.participants)
),
];
const corporateUserGroups = [
...new Set(groups.flatMap((g) => g.participants)),
];
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter();
useEffect(() => {
setShowModal(!!selectedUser && page === "");
}, [selectedUser, page]);
const studentFilter = (user: User) =>
user.type === "student" && corporateUserGroups.includes(user.id);
const teacherFilter = (user: User) =>
user.type === "teacher" && corporateUserGroups.includes(user.id);
const getStatsByStudent = (user: User) =>
stats.filter((s) => s.user === user.id);
const UserDisplay = (displayUser: User) => (
<div
onClick={() => setSelectedUser(displayUser)}
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300"
>
<img
src={displayUser.profilePicture}
alt={displayUser.name}
className="rounded-full w-10 h-10"
/>
<div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
const StudentsList = () => {
const filter = (x: User) =>
x.type === "student" &&
(!!selectedUser
? corporateUserGroups.includes(x.id) || false
: corporateUserGroups.includes(x.id));
return (
<UserList
user={user}
filters={[filter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Students ({total})</h2>
</div>
)}
/>
);
};
const TeachersList = () => {
const filter = (x: User) =>
x.type === "teacher" &&
(!!selectedUser
? corporateUserGroups.includes(x.id) || false
: corporateUserGroups.includes(x.id));
return (
<UserList
user={user}
filters={[filter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Teachers ({total})</h2>
</div>
)}
/>
);
};
const corporateUserFilter = (x: User) =>
x.type === "corporate" &&
(!!selectedUser
? masterCorporateUserGroups.includes(x.id) || false
: masterCorporateUserGroups.includes(x.id));
const CorporateList = () => {
return (
<UserList
user={user}
filters={[corporateUserFilter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Corporates ({total})</h2>
</div>
)}
/>
);
};
const GroupsList = () => {
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">
Groups ({groups.length})
</h2>
</div>
<GroupList user={user} />
</>
);
};
const averageLevelCalculator = (studentStats: Stat[]) => {
const formattedStats = studentStats
.map((s) => ({
focus: users.find((u) => u.id === s.user)?.focus,
score: s.score,
module: s.module,
}))
.filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({
module: s.module,
level: calculateBandScore(
s.score.correct,
s.score.total,
s.module,
s.focus!
),
}));
const levels: { [key in Module]: number } = {
reading: 0,
listening: 0,
writing: 0,
speaking: 0,
level: 0,
};
bandScores.forEach((b) => (levels[b.module] += b.level));
return calculateAverageLevel(levels);
};
const DefaultDashboard = () => (
<>
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:justify-between text-center">
<IconCard
onClick={() => setPage("students")}
Icon={BsPersonFill}
label="Students"
value={users.filter(studentFilter).length}
color="purple"
/>
<IconCard
onClick={() => setPage("teachers")}
Icon={BsPencilSquare}
label="Teachers"
value={users.filter(teacherFilter).length}
color="purple"
/>
<IconCard
Icon={BsClipboard2Data}
label="Exams Performed"
value={
stats.filter((s) =>
groups.flatMap((g) => g.participants).includes(s.user)
).length
}
color="purple"
/>
<IconCard
Icon={BsPaperclip}
label="Average Level"
value={averageLevelCalculator(
stats.filter((s) =>
groups.flatMap((g) => g.participants).includes(s.user)
)
).toFixed(1)}
color="purple"
/>
<IconCard
onClick={() => setPage("groups")}
Icon={BsPeople}
label="Groups"
value={groups.length}
color="purple"
/>
<IconCard
Icon={BsPersonCheck}
label="User Balance"
value={`${codes.length}/${
user.corporateInformation?.companyInformation?.userAmount || 0
}`}
color="purple"
/>
<IconCard
Icon={BsClock}
label="Expiration Date"
value={
user.subscriptionExpirationDate
? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy")
: "Unlimited"
}
color="rose"
/>
<IconCard
Icon={BsBank}
label="Corporate"
value={masterCorporateUserGroups.length}
color="purple"
onClick={() => setPage("corporate")}
/>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(studentFilter)
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest teachers</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(teacherFilter)
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest level students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(studentFilter)
.sort(
(a, b) =>
calculateAverageLevel(b.levels) -
calculateAverageLevel(a.levels)
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest exam count students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(studentFilter)
.sort(
(a, b) =>
Object.keys(groupByExam(getStatsByStudent(b))).length -
Object.keys(groupByExam(getStatsByStudent(a))).length
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
</section>
</>
);
return (
<>
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
<>
{selectedUser && (
<div className="w-full flex flex-col gap-8">
<UserCard
loggedInUser={user}
onClose={(shouldReload) => {
setSelectedUser(undefined);
if (shouldReload) reload();
}}
onViewStudents={
selectedUser.type === "corporate" ||
selectedUser.type === "teacher"
? () => {
appendUserFilters({
id: "view-students",
filter: (x: User) => x.type === "student",
});
appendUserFilters({
id: "belongs-to-admin",
filter: (x: User) =>
groups
.filter(
(g) =>
g.admin === selectedUser.id ||
g.participants.includes(selectedUser.id)
)
.flatMap((g) => g.participants)
.includes(x.id),
});
router.push("/list/users");
}
: undefined
}
onViewTeachers={
selectedUser.type === "corporate" ||
selectedUser.type === "student"
? () => {
appendUserFilters({
id: "view-teachers",
filter: (x: User) => x.type === "teacher",
});
appendUserFilters({
id: "belongs-to-admin",
filter: (x: User) =>
groups
.filter(
(g) =>
g.admin === selectedUser.id ||
g.participants.includes(selectedUser.id)
)
.flatMap((g) => g.participants)
.includes(x.id),
});
router.push("/list/users");
}
: undefined
}
user={selectedUser}
/>
</div>
)}
</>
</Modal>
{page === "students" && <StudentsList />}
{page === "teachers" && <TeachersList />}
{page === "groups" && <GroupsList />}
{page === "corporate" && <CorporateList />}
{page === "" && <DefaultDashboard />}
</>
);
}

View File

@@ -1,7 +1,6 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import ProgressBar from "@/components/Low/ProgressBar"; import ProgressBar from "@/components/Low/ProgressBar";
import InviteCard from "@/components/Medium/InviteCard"; import InviteCard from "@/components/Medium/InviteCard";
import PayPalPayment from "@/components/PayPalPayment";
import ProfileSummary from "@/components/ProfileSummary"; import ProfileSummary from "@/components/ProfileSummary";
import useAssignments from "@/hooks/useAssignments"; import useAssignments from "@/hooks/useAssignments";
import useInvites from "@/hooks/useInvites"; import useInvites from "@/hooks/useInvites";

View File

@@ -2,380 +2,477 @@
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import useStats from "@/hooks/useStats"; import useStats from "@/hooks/useStats";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import {CorporateUser, Group, Stat, User} from "@/interfaces/user"; import { CorporateUser, Group, Stat, User } from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList"; import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils"; import { dateSorter } from "@/utils";
import moment from "moment"; import moment from "moment";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import { import {
BsArrowLeft, BsArrowLeft,
BsArrowRepeat, BsArrowRepeat,
BsClipboard2Data, BsClipboard2Data,
BsClipboard2DataFill, BsClipboard2DataFill,
BsClipboard2Heart, BsClipboard2Heart,
BsClipboard2X, BsClipboard2X,
BsClipboardPulse, BsClipboardPulse,
BsClock, BsClock,
BsEnvelopePaper, BsEnvelopePaper,
BsGlobeCentralSouthAsia, BsGlobeCentralSouthAsia,
BsPaperclip, BsPaperclip,
BsPeople, BsPeople,
BsPerson, BsPerson,
BsPersonAdd, BsPersonAdd,
BsPersonFill, BsPersonFill,
BsPersonFillGear, BsPersonFillGear,
BsPersonGear, BsPersonGear,
BsPlus, BsPlus,
BsRepeat, BsRepeat,
BsRepeat1, BsRepeat1,
} from "react-icons/bs"; } from "react-icons/bs";
import UserCard from "@/components/UserCard"; import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import {calculateAverageLevel, calculateBandScore} from "@/utils/score"; import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
import {MODULE_ARRAY} from "@/utils/moduleUtils"; import { MODULE_ARRAY } from "@/utils/moduleUtils";
import {Module} from "@/interfaces"; import { Module } from "@/interfaces";
import {groupByExam} from "@/utils/stats"; import { groupByExam } from "@/utils/stats";
import IconCard from "./IconCard"; import IconCard from "./IconCard";
import GroupList from "@/pages/(admin)/Lists/GroupList"; import GroupList from "@/pages/(admin)/Lists/GroupList";
import useAssignments from "@/hooks/useAssignments"; import useAssignments from "@/hooks/useAssignments";
import {Assignment} from "@/interfaces/results"; import { Assignment } from "@/interfaces/results";
import AssignmentCard from "./AssignmentCard"; import AssignmentCard from "./AssignmentCard";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import clsx from "clsx"; import clsx from "clsx";
import ProgressBar from "@/components/Low/ProgressBar"; import ProgressBar from "@/components/Low/ProgressBar";
import AssignmentCreator from "./AssignmentCreator"; import AssignmentCreator from "./AssignmentCreator";
import AssignmentView from "./AssignmentView"; import AssignmentView from "./AssignmentView";
import {getUserCorporate} from "@/utils/groups"; import { getUserCorporate } from "@/utils/groups";
interface Props { interface Props {
user: User; user: User;
} }
export default function TeacherDashboard({user}: Props) { export default function TeacherDashboard({ user }: Props) {
const [page, setPage] = useState(""); const [page, setPage] = useState("");
const [selectedUser, setSelectedUser] = useState<User>(); const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>(); const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false); const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>(); const [corporateUserToShow, setCorporateUserToShow] =
useState<CorporateUser>();
const {stats} = useStats(); const { stats } = useStats();
const {users, reload} = useUsers(); const { users, reload } = useUsers();
const {groups} = useGroups(user.id); const { groups } = useGroups(user.id);
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assigner: user.id}); const {
assignments,
isLoading: isAssignmentsLoading,
reload: reloadAssignments,
} = useAssignments({ assigner: user.id });
useEffect(() => { useEffect(() => {
setShowModal(!!selectedUser && page === ""); setShowModal(!!selectedUser && page === "");
}, [selectedUser, page]); }, [selectedUser, page]);
useEffect(() => { useEffect(() => {
getUserCorporate(user.id).then(setCorporateUserToShow); getUserCorporate(user.id).then(setCorporateUserToShow);
}, [user]); }, [user]);
const studentFilter = (user: User) => user.type === "student" && groups.flatMap((g) => g.participants).includes(user.id); const studentFilter = (user: User) =>
user.type === "student" &&
groups.flatMap((g) => g.participants).includes(user.id);
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id); const getStatsByStudent = (user: User) =>
stats.filter((s) => s.user === user.id);
const UserDisplay = (displayUser: User) => ( const UserDisplay = (displayUser: User) => (
<div <div
onClick={() => setSelectedUser(displayUser)} onClick={() => setSelectedUser(displayUser)}
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300"> className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300"
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" /> >
<div className="flex flex-col gap-1 items-start"> <img
<span>{displayUser.name}</span> src={displayUser.profilePicture}
<span className="text-sm opacity-75">{displayUser.email}</span> alt={displayUser.name}
</div> className="rounded-full w-10 h-10"
</div> />
); <div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
const StudentsList = () => { const StudentsList = () => {
const filter = (x: User) => const filter = (x: User) =>
x.type === "student" && x.type === "student" &&
(!!selectedUser (!!selectedUser
? groups ? groups
.filter((g) => g.admin === selectedUser.id) .filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants) .flatMap((g) => g.participants)
.includes(x.id) || false .includes(x.id) || false
: groups.flatMap((g) => g.participants).includes(x.id)); : groups.flatMap((g) => g.participants).includes(x.id));
return ( return (
<> <UserList
<div className="flex flex-col gap-4"> user={user}
<div filters={[filter]}
onClick={() => setPage("")} renderHeader={(total) => (
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> <div className="flex flex-col gap-4">
<BsArrowLeft className="text-xl" /> <div
<span>Back</span> onClick={() => setPage("")}
</div> className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2> >
</div> <BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Students ({total})</h2>
</div>
)}
/>
);
};
<UserList user={user} filters={[filter]} /> const GroupsList = () => {
</> const filter = (x: Group) =>
); x.admin === user.id || x.participants.includes(user.id);
};
const GroupsList = () => { return (
const filter = (x: Group) => x.admin === user.id || x.participants.includes(user.id); <>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">
Groups ({groups.filter(filter).length})
</h2>
</div>
return ( <GroupList user={user} />
<> </>
<div className="flex flex-col gap-4"> );
<div };
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Groups ({groups.filter(filter).length})</h2>
</div>
<GroupList user={user} /> const averageLevelCalculator = (studentStats: Stat[]) => {
</> const formattedStats = studentStats
); .map((s) => ({
}; focus: users.find((u) => u.id === s.user)?.focus,
score: s.score,
module: s.module,
}))
.filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({
module: s.module,
level: calculateBandScore(
s.score.correct,
s.score.total,
s.module,
s.focus!
),
}));
const averageLevelCalculator = (studentStats: Stat[]) => { const levels: { [key in Module]: number } = {
const formattedStats = studentStats reading: 0,
.map((s) => ({focus: users.find((u) => u.id === s.user)?.focus, score: s.score, module: s.module})) listening: 0,
.filter((f) => !!f.focus); writing: 0,
const bandScores = formattedStats.map((s) => ({ speaking: 0,
module: s.module, level: 0,
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!), };
})); bandScores.forEach((b) => (levels[b.module] += b.level));
const levels: {[key in Module]: number} = {reading: 0, listening: 0, writing: 0, speaking: 0, level: 0}; return calculateAverageLevel(levels);
bandScores.forEach((b) => (levels[b.module] += b.level)); };
return calculateAverageLevel(levels); 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) &&
!a.archived;
const archivedFilter = (a: Assignment) => a.archived;
const futureFilter = (a: Assignment) =>
moment(a.startDate).isAfter(moment());
const AssignmentsPage = () => { return (
const activeFilter = (a: Assignment) => <>
moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()) && a.assignees.length > a.results.length; <AssignmentView
const pastFilter = (a: Assignment) => (moment(a.endDate).isBefore(moment()) || a.assignees.length === a.results.length) && !a.archived; isOpen={!!selectedAssignment && !isCreatingAssignment}
const archivedFilter = (a: Assignment) => a.archived; onClose={() => {
const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment()); setSelectedAssignment(undefined);
setIsCreatingAssignment(false);
reloadAssignments();
}}
assignment={selectedAssignment}
/>
<AssignmentCreator
assignment={selectedAssignment}
groups={groups.filter(
(x) => x.admin === user.id || x.participants.includes(user.id)
)}
users={users.filter(
(x) =>
x.type === "student" &&
(!!selectedUser
? groups
.filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants)
.includes(x.id) || false
: groups.flatMap((g) => g.participants).includes(x.id))
)}
assigner={user.id}
isCreating={isCreatingAssignment}
cancelCreation={() => {
setIsCreatingAssignment(false);
setSelectedAssignment(undefined);
reloadAssignments();
}}
/>
<div className="w-full flex justify-between items-center">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<div
onClick={reloadAssignments}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<span>Reload</span>
<BsArrowRepeat
className={clsx(
"text-xl",
isAssignmentsLoading && "animate-spin"
)}
/>
</div>
</div>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">
Active Assignments ({assignments.filter(activeFilter).length})
</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(activeFilter).map((a) => (
<AssignmentCard
{...a}
onClick={() => setSelectedAssignment(a)}
key={a.id}
/>
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">
Planned Assignments ({assignments.filter(futureFilter).length})
</h2>
<div className="flex flex-wrap gap-2">
<div
onClick={() => setIsCreatingAssignment(true)}
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300"
>
<BsPlus className="text-6xl" />
<span className="text-lg">New Assignment</span>
</div>
{assignments.filter(futureFilter).map((a) => (
<AssignmentCard
{...a}
onClick={() => {
setSelectedAssignment(a);
setIsCreatingAssignment(true);
}}
key={a.id}
/>
))}
</div>
</section>
<section className="flex flex-col gap-4">
<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
reload={reloadAssignments}
allowArchive
/>
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">
Archived Assignments ({assignments.filter(archivedFilter).length})
</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(archivedFilter).map((a) => (
<AssignmentCard
{...a}
onClick={() => setSelectedAssignment(a)}
key={a.id}
allowDownload
reload={reloadAssignments}
allowUnarchive
/>
))}
</div>
</section>
</>
);
};
return ( const DefaultDashboard = () => (
<> <>
<AssignmentView {corporateUserToShow && (
isOpen={!!selectedAssignment && !isCreatingAssignment} <div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
onClose={() => { Linked to:{" "}
setSelectedAssignment(undefined); <b>
setIsCreatingAssignment(false); {corporateUserToShow?.corporateInformation?.companyInformation
reloadAssignments(); .name || corporateUserToShow.name}
}} </b>
assignment={selectedAssignment} </div>
/> )}
<AssignmentCreator <section
assignment={selectedAssignment} className={clsx(
groups={groups.filter((x) => x.admin === user.id || x.participants.includes(user.id))} "flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center",
users={users.filter( !!corporateUserToShow && "mt-12 xl:mt-6"
(x) => )}
x.type === "student" && >
(!!selectedUser <IconCard
? groups onClick={() => setPage("students")}
.filter((g) => g.admin === selectedUser.id) Icon={BsPersonFill}
.flatMap((g) => g.participants) label="Students"
.includes(x.id) || false value={users.filter(studentFilter).length}
: groups.flatMap((g) => g.participants).includes(x.id)), color="purple"
)} />
assigner={user.id} <IconCard
isCreating={isCreatingAssignment} Icon={BsClipboard2Data}
cancelCreation={() => { label="Exams Performed"
setIsCreatingAssignment(false); value={
setSelectedAssignment(undefined); stats.filter((s) =>
reloadAssignments(); groups.flatMap((g) => g.participants).includes(s.user)
}} ).length
/> }
<div className="w-full flex justify-between items-center"> color="purple"
<div />
onClick={() => setPage("")} <IconCard
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> Icon={BsPaperclip}
<BsArrowLeft className="text-xl" /> label="Average Level"
<span>Back</span> value={averageLevelCalculator(
</div> stats.filter((s) =>
<div groups.flatMap((g) => g.participants).includes(s.user)
onClick={reloadAssignments} )
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> ).toFixed(1)}
<span>Reload</span> color="purple"
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} /> />
</div> <IconCard
</div> Icon={BsPeople}
<section className="flex flex-col gap-4"> label="Groups"
<h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeFilter).length})</h2> value={groups.length}
<div className="flex flex-wrap gap-2"> color="purple"
{assignments.filter(activeFilter).map((a) => ( onClick={() => setPage("groups")}
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} /> />
))} <div
</div> onClick={() => setPage("assignments")}
</section> className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300"
<section className="flex flex-col gap-4"> >
<h2 className="text-2xl font-semibold">Planned Assignments ({assignments.filter(futureFilter).length})</h2> <BsEnvelopePaper className="text-6xl text-mti-purple-light" />
<div className="flex flex-wrap gap-2"> <span className="flex flex-col gap-1 items-center text-xl">
<div <span className="text-lg">Assignments</span>
onClick={() => setIsCreatingAssignment(true)} <span className="font-semibold text-mti-purple-light">
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300"> {assignments.filter((a) => !a.archived).length}
<BsPlus className="text-6xl" /> </span>
<span className="text-lg">New Assignment</span> </span>
</div> </div>
{assignments.filter(futureFilter).map((a) => ( </section>
<AssignmentCard
{...a}
onClick={() => {
setSelectedAssignment(a);
setIsCreatingAssignment(true);
}}
key={a.id}
/>
))}
</div>
</section>
<section className="flex flex-col gap-4">
<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
reload={reloadAssignments}
allowArchive
/>
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Archived Assignments ({assignments.filter(archivedFilter).length})</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(archivedFilter).map((a) => (
<AssignmentCard
{...a}
onClick={() => setSelectedAssignment(a)}
key={a.id}
allowDownload
reload={reloadAssignments}
allowUnarchive
/>
))}
</div>
</section>
</>
);
};
const DefaultDashboard = () => ( <section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between">
<> <div className="bg-white shadow flex flex-col rounded-xl w-full">
{corporateUserToShow && ( <span className="p-4">Latest students</span>
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
Linked to: <b>{corporateUserToShow?.corporateInformation?.companyInformation.name || corporateUserToShow.name}</b> {users
</div> .filter(studentFilter)
)} .sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
<section .map((x) => (
className={clsx( <UserDisplay key={x.id} {...x} />
"flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center", ))}
!!corporateUserToShow && "mt-12 xl:mt-6", </div>
)}> </div>
<IconCard <div className="bg-white shadow flex flex-col rounded-xl w-full">
onClick={() => setPage("students")} <span className="p-4">Highest level students</span>
Icon={BsPersonFill} <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
label="Students" {users
value={users.filter(studentFilter).length} .filter(studentFilter)
color="purple" .sort(
/> (a, b) =>
<IconCard calculateAverageLevel(b.levels) -
Icon={BsClipboard2Data} calculateAverageLevel(a.levels)
label="Exams Performed" )
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length} .map((x) => (
color="purple" <UserDisplay key={x.id} {...x} />
/> ))}
<IconCard </div>
Icon={BsPaperclip} </div>
label="Average Level" <div className="bg-white shadow flex flex-col rounded-xl w-full">
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)} <span className="p-4">Highest exam count students</span>
color="purple" <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
/> {users
<IconCard Icon={BsPeople} label="Groups" value={groups.length} color="purple" onClick={() => setPage("groups")} /> .filter(studentFilter)
<div .sort(
onClick={() => setPage("assignments")} (a, b) =>
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300"> Object.keys(groupByExam(getStatsByStudent(b))).length -
<BsEnvelopePaper className="text-6xl text-mti-purple-light" /> Object.keys(groupByExam(getStatsByStudent(a))).length
<span className="flex flex-col gap-1 items-center text-xl"> )
<span className="text-lg">Assignments</span> .map((x) => (
<span className="font-semibold text-mti-purple-light">{assignments.filter((a) => !a.archived).length}</span> <UserDisplay key={x.id} {...x} />
</span> ))}
</div> </div>
</section> </div>
</section>
</>
);
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between"> return (
<div className="bg-white shadow flex flex-col rounded-xl w-full"> <>
<span className="p-4">Latest students</span> <Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <>
{users {selectedUser && (
.filter(studentFilter) <div className="w-full flex flex-col gap-8">
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) <UserCard
.map((x) => ( loggedInUser={user}
<UserDisplay key={x.id} {...x} /> onClose={(shouldReload) => {
))} setSelectedUser(undefined);
</div> if (shouldReload) reload();
</div> }}
<div className="bg-white shadow flex flex-col rounded-xl w-full"> onViewStudents={
<span className="p-4">Highest level students</span> selectedUser.type === "corporate" ||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> selectedUser.type === "teacher"
{users ? () => setPage("students")
.filter(studentFilter) : undefined
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels)) }
.map((x) => ( onViewTeachers={
<UserDisplay key={x.id} {...x} /> selectedUser.type === "corporate"
))} ? () => setPage("teachers")
</div> : undefined
</div> }
<div className="bg-white shadow flex flex-col rounded-xl w-full"> user={selectedUser}
<span className="p-4">Highest exam count students</span> />
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> </div>
{users )}
.filter(studentFilter) </>
.sort( </Modal>
(a, b) => {page === "students" && <StudentsList />}
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length, {page === "groups" && <GroupsList />}
) {page === "assignments" && <AssignmentsPage />}
.map((x) => ( {page === "" && <DefaultDashboard />}
<UserDisplay key={x.id} {...x} /> </>
))} );
</div>
</div>
</section>
</>
);
return (
<>
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
<>
{selectedUser && (
<div className="w-full flex flex-col gap-8">
<UserCard
loggedInUser={user}
onClose={(shouldReload) => {
setSelectedUser(undefined);
if (shouldReload) reload();
}}
onViewStudents={
selectedUser.type === "corporate" || selectedUser.type === "teacher" ? () => setPage("students") : undefined
}
onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined}
user={selectedUser}
/>
</div>
)}
</>
</Modal>
{page === "students" && <StudentsList />}
{page === "groups" && <GroupsList />}
{page === "assignments" && <AssignmentsPage />}
{page === "" && <DefaultDashboard />}
</>
);
} }

View File

@@ -9,10 +9,23 @@ import clsx from "clsx";
import Link from "next/link"; import Link from "next/link";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {Fragment, useEffect, useState} from "react"; import {Fragment, useEffect, useState} from "react";
import {BsArrowCounterclockwise, BsBook, BsClipboard, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs"; import {
BsArrowCounterclockwise,
BsBook,
BsClipboard,
BsClipboardFill,
BsEyeFill,
BsHeadphones,
BsMegaphone,
BsPen,
BsShareFill,
} from "react-icons/bs";
import {LevelScore} from "@/constants/ielts"; import {LevelScore} from "@/constants/ielts";
import {getLevelScore} from "@/utils/score"; import {getLevelScore} from "@/utils/score";
import {capitalize} from "lodash"; import {capitalize} from "lodash";
import Modal from "@/components/Modal";
import { UserSolution } from "@/interfaces/exam";
import ai_usage from "@/utils/ai.detection";
interface Score { interface Score {
module: Module; module: Module;
@@ -25,13 +38,21 @@ interface Props {
user: User; user: User;
modules: Module[]; modules: Module[];
scores: Score[]; scores: Score[];
information: {
timeSpent?: number;
inactivity?: number;
};
solutions: UserSolution[];
isLoading: boolean; isLoading: boolean;
onViewResults: (moduleIndex?: number) => void; onViewResults: (moduleIndex?: number) => void;
} }
export default function Finish({user, scores, modules, isLoading, onViewResults}: Props) { export default function Finish({user, scores, modules, information, solutions, isLoading, onViewResults}: Props) {
const [selectedModule, setSelectedModule] = useState(modules[0]); const [selectedModule, setSelectedModule] = useState(modules[0]);
const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!); const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!);
const [isExtraInformationOpen, setIsExtraInformationOpen] = useState(false);
const aiUsage = Math.round(ai_usage(solutions) * 100);
const exams = useExamStore((state) => state.exams); const exams = useExamStore((state) => state.exams);
@@ -62,7 +83,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
const getTotalExercises = () => { const getTotalExercises = () => {
const exam = exams.find((x) => x.module === selectedModule)!; const exam = exams.find((x) => x.module === selectedModule)!;
if (exam.module === "reading" || exam.module === "listening") { if (exam.module === "reading" || exam.module === "listening" || exam.module === "level") {
return exam.parts.flatMap((x) => x.exercises).length; return exam.parts.flatMap((x) => x.exercises).length;
} }
@@ -86,6 +107,21 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
return ( return (
<> <>
<Modal title="Extra Information" isOpen={isExtraInformationOpen} onClose={() => setIsExtraInformationOpen(false)}>
<div className="flex flex-col gap-2 mt-4">
{!!information.timeSpent && (
<span>
<b>Time Spent:</b> {Math.floor(information.timeSpent / 60)} minute(s)
</span>
)}
{!!information.inactivity && (
<span>
<b>Inactivity:</b> {Math.floor(information.inactivity / 60)} minute(s)
</span>
)}
</div>
</Modal>
<div className="flex h-fit min-h-full w-full flex-col items-center justify-between gap-8"> <div className="flex h-fit min-h-full w-full flex-col items-center justify-between gap-8">
<ModuleTitle <ModuleTitle
module={selectedModule} module={selectedModule}
@@ -94,7 +130,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
minTimer={exams.find((x) => x.module === selectedModule)!.minTimer} minTimer={exams.find((x) => x.module === selectedModule)!.minTimer}
disableTimer disableTimer
/> />
<div className="flex gap-4 self-start"> <div className="flex gap-4 self-start w-full">
{modules.includes("reading") && ( {modules.includes("reading") && (
<div <div
onClick={() => setSelectedModule("reading")} onClick={() => setSelectedModule("reading")}
@@ -118,6 +154,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
</div> </div>
)} )}
{modules.includes("writing") && ( {modules.includes("writing") && (
<div className="flex w-full justify-between items-center">
<div <div
onClick={() => setSelectedModule("writing")} onClick={() => setSelectedModule("writing")}
className={clsx( className={clsx(
@@ -127,6 +164,18 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
<BsPen className="h-6 w-6" /> <BsPen className="h-6 w-6" />
<span className="font-semibold">Writing</span> <span className="font-semibold">Writing</span>
</div> </div>
{aiUsage >= 50 && user.type !== "student" && (
<div className={clsx(
"flex items-center justify-center border px-3 h-full rounded",
{
'bg-orange-100 border-orange-400 text-orange-700': aiUsage < 80,
'bg-red-100 border-red-400 text-red-700': aiUsage >= 80,
}
)}>
<span className="text-xs">AI Usage</span>
</div>
)}
</div>
)} )}
{modules.includes("speaking") && ( {modules.includes("speaking") && (
<div <div
@@ -247,6 +296,16 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
</button> </button>
<span>Review {capitalize(selectedModule)}</span> <span>Review {capitalize(selectedModule)}</span>
</div> </div>
{(!!information.inactivity || !!information.timeSpent) && (
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
<button
onClick={() => setIsExtraInformationOpen(true)}
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
<BsClipboardFill className="h-7 w-7 text-white" />
</button>
<span>Extra Information</span>
</div>
)}
</div> </div>
<Link href="/" className="w-full max-w-[200px] self-end"> <Link href="/" className="w-full max-w-[200px] self-end">

View File

@@ -1,8 +1,10 @@
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
import {renderExercise} from "@/components/Exercises"; import {renderExercise} from "@/components/Exercises";
import Button from "@/components/Low/Button";
import ModuleTitle from "@/components/Medium/ModuleTitle"; import ModuleTitle from "@/components/Medium/ModuleTitle";
import {renderSolution} from "@/components/Solutions"; import {renderSolution} from "@/components/Solutions";
import {infoButtonStyle} from "@/constants/buttonStyles"; import {infoButtonStyle} from "@/constants/buttonStyles";
import {LevelExam, UserSolution, WritingExam} from "@/interfaces/exam"; import {LevelExam, LevelPart, UserSolution, WritingExam} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams"; import {defaultUserSolutions} from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils"; import {countExercises} from "@/utils/moduleUtils";
@@ -10,6 +12,7 @@ import {mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react"; import Icon from "@mdi/react";
import clsx from "clsx"; import clsx from "clsx";
import {Fragment, useEffect, useState} from "react"; import {Fragment, useEffect, useState} from "react";
import {BsChevronDown, BsChevronUp} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
interface Props { interface Props {
@@ -18,36 +21,84 @@ interface Props {
onFinish: (userSolutions: UserSolution[]) => void; onFinish: (userSolutions: UserSolution[]) => void;
} }
function TextComponent({part}: {part: LevelPart}) {
return (
<div className="flex flex-col gap-2 w-full">
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
{!!part.context &&
part.context
.split(/\n|(\\n)/g)
.filter((x) => x && x.length > 0 && x !== "\\n")
.map((line, index) => (
<Fragment key={index}>
<p key={index}>{line}</p>
</Fragment>
))}
</div>
);
}
export default function Level({exam, showSolutions = false, onFinish}: Props) { export default function Level({exam, showSolutions = false, onFinish}: Props) {
const [questionIndex, setQuestionIndex] = useState(0); const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); const [showBlankModal, setShowBlankModal] = useState(false);
const [exerciseIndex, setExerciseIndex] = useState(0);
const {userSolutions, setUserSolutions} = useExamStore((state) => state); const {userSolutions, setUserSolutions} = useExamStore((state) => state);
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state); const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
const {partIndex, setPartIndex} = useExamStore((state) => state);
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]);
useEffect(() => { const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
setCurrentQuestionIndex(0);
}, [questionIndex]);
useEffect(() => { useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) { if (hasExamEnded && exerciseIndex === -1) {
setExerciseIndex((prev) => prev + 1); setExerciseIndex(exerciseIndex + 1);
} }
}, [hasExamEnded, exerciseIndex]); }, [hasExamEnded, exerciseIndex, setExerciseIndex]);
const nextExercise = (solution?: UserSolution) => { const confirmFinishModule = (keepGoing?: boolean) => {
if (solution) { if (!keepGoing) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]); setShowBlankModal(false);
}
setQuestionIndex((prev) => prev + currentQuestionIndex);
if (exerciseIndex + 1 < exam.exercises.length) {
setExerciseIndex((prev) => prev + 1);
return; return;
} }
if (exerciseIndex >= exam.exercises.length) return; onFinish(userSolutions);
};
const nextExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]);
}
if (storeQuestionIndex > 0) {
const exercise = getExercise();
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise.id), {id: exercise.id, amount: storeQuestionIndex}]);
}
setStoreQuestionIndex(0);
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
setExerciseIndex(exerciseIndex + 1);
return;
}
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
setPartIndex(partIndex + 1);
setExerciseIndex(!!exam.parts[partIndex + 1].context ? -1 : 0);
return;
}
if (
solution &&
![...userSolutions.filter((x) => x.exercise !== solution?.exercise).map((x) => x.score.missing), solution?.score.missing].every(
(x) => x === 0,
) &&
!showSolutions &&
!hasExamEnded
) {
setShowBlankModal(true);
return;
}
setHasExamEnded(false); setHasExamEnded(false);
@@ -59,41 +110,111 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
}; };
const previousExercise = (solution?: UserSolution) => { const previousExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) { if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]); setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]);
} }
if (exerciseIndex > 0) { if (storeQuestionIndex > 0) {
setExerciseIndex((prev) => prev - 1); const exercise = getExercise();
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise.id), {id: exercise.id, amount: storeQuestionIndex}]);
} }
setStoreQuestionIndex(0);
setExerciseIndex(exerciseIndex - 1);
}; };
const getExercise = () => { const getExercise = () => {
const exercise = exam.exercises[exerciseIndex]; const exercise = exam.parts[partIndex].exercises[exerciseIndex];
return { return {
...exercise, ...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [], userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
}; };
}; };
const calculateExerciseIndex = () => {
if (partIndex === 0)
return (
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) + storeQuestionIndex + multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
);
const exercisesPerPart = exam.parts.map((x) => x.exercises.length);
const exercisesDone = exercisesPerPart.filter((_, index) => index < partIndex).reduce((acc, curr) => curr + acc, 0);
return (
exercisesDone +
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) +
storeQuestionIndex +
multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
);
};
const renderText = () => (
<div className={clsx("flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl mt-4 relative py-8 px-16")}>
<>
<div className="flex flex-col w-full gap-2">
<h4 className="text-xl font-semibold">
Please read the following excerpt attentively, you will then be asked questions about the text you&apos;ve read.
</h4>
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
</div>
<TextComponent part={exam.parts[partIndex]} />
</>
</div>
);
return ( return (
<> <>
<div className="flex flex-col h-full w-full gap-8 items-center"> <div className="flex flex-col h-full w-full gap-8 items-center">
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
<ModuleTitle <ModuleTitle
minTimer={exam.minTimer} minTimer={exam.minTimer}
exerciseIndex={exerciseIndex + 1 + questionIndex + currentQuestionIndex} exerciseIndex={calculateExerciseIndex()}
module="level" module="level"
totalExercises={countExercises(exam.exercises)} totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
disableTimer={showSolutions} disableTimer={showSolutions}
/> />
{exerciseIndex > -1 && <div
exerciseIndex < exam.exercises.length && className={clsx(
!showSolutions && "mb-20 w-full",
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)} partIndex > -1 && exerciseIndex > -1 && !!exam.parts[partIndex].context && "grid grid-cols-2 gap-4",
{exerciseIndex > -1 && )}>
exerciseIndex < exam.exercises.length && {partIndex > -1 && !!exam.parts[partIndex].context && renderText()}
showSolutions &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)} {exerciseIndex > -1 &&
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions &&
renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
{exerciseIndex > -1 &&
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
showSolutions &&
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
</div>
{exerciseIndex === -1 && partIndex > 0 && (
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => {
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
setPartIndex(partIndex - 1);
}}
className="max-w-[200px] w-full">
Back
</Button>
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)}
{exerciseIndex === -1 && partIndex === 0 && (
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
Start now
</Button>
)}
</div> </div>
</> </>
); );

View File

@@ -19,8 +19,6 @@ const INSTRUCTIONS_AUDIO_SRC =
"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"; "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/generic_listening_intro_v2.mp3?alt=media&token=16769f5f-1e9b-4a72-86a9-45a6f0fa9f82";
export default function Listening({exam, showSolutions = false, onFinish}: Props) { export default function Listening({exam, showSolutions = false, onFinish}: Props) {
const [questionIndex, setQuestionIndex] = useState(0);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [timesListened, setTimesListened] = useState(0); const [timesListened, setTimesListened] = useState(0);
const [showBlankModal, setShowBlankModal] = useState(false); const [showBlankModal, setShowBlankModal] = useState(false);
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]); const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]);
@@ -64,10 +62,6 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
} }
}, [hasExamEnded, exerciseIndex, setExerciseIndex]); }, [hasExamEnded, exerciseIndex, setExerciseIndex]);
useEffect(() => {
setCurrentQuestionIndex(0);
}, [questionIndex]);
const confirmFinishModule = (keepGoing?: boolean) => { const confirmFinishModule = (keepGoing?: boolean) => {
if (!keepGoing) { if (!keepGoing) {
setShowBlankModal(false); setShowBlankModal(false);
@@ -220,14 +214,14 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
partIndex > -1 && partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length && exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions && !showSolutions &&
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)} renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
{/* Solution renderer */} {/* Solution renderer */}
{exerciseIndex > -1 && {exerciseIndex > -1 &&
partIndex > -1 && partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length && exerciseIndex < exam.parts[partIndex].exercises.length &&
showSolutions && showSolutions &&
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)} renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
</div> </div>
{exerciseIndex === -1 && partIndex > -1 && exam.variant !== "partial" && ( {exerciseIndex === -1 && partIndex > -1 && exam.variant !== "partial" && (

View File

@@ -89,7 +89,7 @@ function TextComponent({part, exerciseType}: {part: ReadingPart; exerciseType: s
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" /> <div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
{part.text.content {part.text.content
.split(/\n|(\\n)/g) .split(/\n|(\\n)/g)
.filter((x) => x && x.length > 0) .filter((x) => x && x.length > 0 && x !== "\\n")
.map((line, index) => ( .map((line, index) => (
<Fragment key={index}> <Fragment key={index}>
{exerciseType === "matchSentences" && ( {exerciseType === "matchSentences" && (
@@ -106,8 +106,6 @@ function TextComponent({part, exerciseType}: {part: ReadingPart; exerciseType: s
} }
export default function Reading({exam, showSolutions = false, onFinish}: Props) { export default function Reading({exam, showSolutions = false, onFinish}: Props) {
const [questionIndex, setQuestionIndex] = useState(0);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [showTextModal, setShowTextModal] = useState(false); const [showTextModal, setShowTextModal] = useState(false);
const [showBlankModal, setShowBlankModal] = useState(false); const [showBlankModal, setShowBlankModal] = useState(false);
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]); const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]);
@@ -155,10 +153,6 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
}; };
}, []); }, []);
useEffect(() => {
setCurrentQuestionIndex(0);
}, [questionIndex]);
useEffect(() => { useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) { if (hasExamEnded && exerciseIndex === -1) {
setExerciseIndex(exerciseIndex + 1); setExerciseIndex(exerciseIndex + 1);
@@ -314,13 +308,13 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
partIndex > -1 && partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length && exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions && !showSolutions &&
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)} renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
{exerciseIndex > -1 && {exerciseIndex > -1 &&
partIndex > -1 && partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length && exerciseIndex < exam.parts[partIndex].exercises.length &&
showSolutions && showSolutions &&
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)} renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
</div> </div>
{exerciseIndex > -1 && partIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && ( {exerciseIndex > -1 && partIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
<Button <Button

View File

@@ -2,7 +2,7 @@ import {renderExercise} from "@/components/Exercises";
import ModuleTitle from "@/components/Medium/ModuleTitle"; import ModuleTitle from "@/components/Medium/ModuleTitle";
import {renderSolution} from "@/components/Solutions"; import {renderSolution} from "@/components/Solutions";
import {infoButtonStyle} from "@/constants/buttonStyles"; import {infoButtonStyle} from "@/constants/buttonStyles";
import {UserSolution, SpeakingExam} from "@/interfaces/exam"; import {UserSolution, SpeakingExam, SpeakingExercise, InteractiveSpeakingExercise} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams"; import {defaultUserSolutions} from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils"; import {countExercises} from "@/utils/moduleUtils";
@@ -20,25 +20,26 @@ interface Props {
} }
export default function Speaking({exam, showSolutions = false, onFinish}: Props) { export default function Speaking({exam, showSolutions = false, onFinish}: Props) {
const [questionIndex, setQuestionIndex] = useState(0); const [speakingPromptsDone, setSpeakingPromptsDone] = useState<{id: string; amount: number}[]>([]);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const {userSolutions, setUserSolutions} = useExamStore((state) => state); const {userSolutions, setUserSolutions} = useExamStore((state) => state);
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state); const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state); const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
useEffect(() => {
setCurrentQuestionIndex(0);
}, [questionIndex]);
const nextExercise = (solution?: UserSolution) => { const nextExercise = (solution?: UserSolution) => {
scrollToTop(); scrollToTop();
if (solution) { if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "speaking", exam: exam.id}]); setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "speaking", exam: exam.id}]);
} }
setQuestionIndex((prev) => prev + currentQuestionIndex);
if (questionIndex > 0) {
const exercise = getExercise();
setSpeakingPromptsDone((prev) => [...prev.filter((x) => x.id !== exercise.id), {id: exercise.id, amount: questionIndex}]);
}
setQuestionIndex(0);
if (exerciseIndex + 1 < exam.exercises.length) { if (exerciseIndex + 1 < exam.exercises.length) {
setExerciseIndex(exerciseIndex + 1); setExerciseIndex(exerciseIndex + 1);
@@ -71,8 +72,9 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
const exercise = exam.exercises[exerciseIndex]; const exercise = exam.exercises[exerciseIndex];
return { return {
...exercise, ...exercise,
variant: exerciseIndex < 2 && exercise.type === "interactiveSpeaking" ? "initial" : undefined,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [], userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
}; } as SpeakingExercise | InteractiveSpeakingExercise;
}; };
return ( return (
@@ -81,7 +83,7 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
<ModuleTitle <ModuleTitle
label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)} label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
minTimer={exam.minTimer} minTimer={exam.minTimer}
exerciseIndex={exerciseIndex + 1 + questionIndex + currentQuestionIndex} exerciseIndex={exerciseIndex + 1 + questionIndex + speakingPromptsDone.reduce((acc, curr) => acc + curr.amount, 0)}
module="speaking" module="speaking"
totalExercises={countExercises(exam.exercises)} totalExercises={countExercises(exam.exercises)}
disableTimer={showSolutions} disableTimer={showSolutions}
@@ -89,11 +91,11 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
{exerciseIndex > -1 && {exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length && exerciseIndex < exam.exercises.length &&
!showSolutions && !showSolutions &&
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)} renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
{exerciseIndex > -1 && {exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length && exerciseIndex < exam.exercises.length &&
showSolutions && showSolutions &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)} renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
</div> </div>
</> </>
); );

View File

@@ -1,8 +1,9 @@
import {initializeApp} from "firebase/app"; import {initializeApp} from "firebase/app";
import * as admin from "firebase-admin/app"; import * as admin from "firebase-admin/app";
import { getStorage } from "firebase/storage"; import {getStorage} from "firebase/storage";
const serviceAccount = require("@/constants/serviceAccountKey.json"); const stagingServiceAccount = require("@/constants/staging.json");
const platformServiceAccount = require("@/constants/platform.json");
const firebaseConfig = { const firebaseConfig = {
apiKey: process.env.FIREBASE_PUBLIC_API_KEY || "", apiKey: process.env.FIREBASE_PUBLIC_API_KEY || "",
@@ -16,8 +17,8 @@ const firebaseConfig = {
export const app = initializeApp(firebaseConfig, Math.random().toString()); export const app = initializeApp(firebaseConfig, Math.random().toString());
export const adminApp = admin.initializeApp( export const adminApp = admin.initializeApp(
{ {
credential: admin.cert(serviceAccount), credential: admin.cert(process.env.ENVIRONMENT === "platform" ? platformServiceAccount : stagingServiceAccount),
}, },
Math.random().toString(), Math.random().toString(),
); );
export const storage = getStorage(app); export const storage = getStorage(app);

View File

@@ -2,16 +2,23 @@ import {Group, User} from "@/interfaces/user";
import axios from "axios"; import axios from "axios";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
export default function useGroups(admin?: string) { export default function useGroups(admin?: string, userType?: string) {
const [groups, setGroups] = useState<Group[]>([]); const [groups, setGroups] = useState<Group[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false); const [isError, setIsError] = useState(false);
const isMasterType = userType?.startsWith('master');
const getData = () => { const getData = () => {
setIsLoading(true); setIsLoading(true);
const url = admin ? `/api/groups?admin=${admin}` : "/api/groups";
axios axios
.get<Group[]>("/api/groups") .get<Group[]>(url)
.then((response) => { .then((response) => {
if(isMasterType) {
return setGroups(response.data);
}
const filter = (g: Group) => g.admin === admin || g.participants.includes(admin || ""); const filter = (g: Group) => g.admin === admin || g.participants.includes(admin || "");
const filteredGroups = admin ? response.data.filter(filter) : response.data; const filteredGroups = admin ? response.data.filter(filter) : response.data;
@@ -20,7 +27,7 @@ export default function useGroups(admin?: string) {
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
useEffect(getData, [admin]); useEffect(getData, [admin, isMasterType]);
return {groups, isLoading, isError, reload: getData}; return {groups, isLoading, isError, reload: getData};
} }

View File

@@ -27,13 +27,18 @@ export interface ReadingPart {
export interface LevelExam { export interface LevelExam {
module: "level"; module: "level";
id: string; id: string;
exercises: Exercise[]; parts: LevelPart[];
minTimer: number; minTimer: number;
isDiagnostic: boolean; isDiagnostic: boolean;
variant?: Variant; variant?: Variant;
difficulty?: Difficulty; difficulty?: Difficulty;
} }
export interface LevelPart {
context?: string;
exercises: Exercise[];
}
export interface ListeningExam { export interface ListeningExam {
parts: ListeningPart[]; parts: ListeningPart[];
id: string; id: string;
@@ -106,18 +111,18 @@ export type Exercise =
export interface Evaluation { export interface Evaluation {
comment: string; comment: string;
overall: number; overall: number;
task_response: {[key: string]: number}; task_response: {[key: string]: number | {grade: number; comment: string}};
misspelled_pairs?: {correction: string | null; misspelled: string}[]; misspelled_pairs?: {correction: string | null; misspelled: string}[];
} }
interface InteractiveSpeakingEvaluation extends Evaluation { interface InteractiveSpeakingEvaluation extends Evaluation {
perfect_answer_1?: string; perfect_answer_1?: {answer: string};
transcript_1?: string; transcript_1?: string;
fixed_text_1?: string; fixed_text_1?: string;
perfect_answer_2?: string; perfect_answer_2?: {answer: string};
transcript_2?: string; transcript_2?: string;
fixed_text_2?: string; fixed_text_2?: string;
perfect_answer_3?: string; perfect_answer_3?: {answer: string};
transcript_3?: string; transcript_3?: string;
fixed_text_3?: string; fixed_text_3?: string;
} }
@@ -147,17 +152,36 @@ export interface WritingExercise {
userSolutions: { userSolutions: {
id: string; id: string;
solution: string; solution: string;
evaluation?: CommonEvaluation; evaluation?: WritingEvaluation;
}[]; }[];
topic?: string; topic?: string;
} }
export interface AIDetectionAttributes {
predicted_class: "ai" | "mixed" | "human";
confidence_category: "high" | "medium" | "low";
class_probabilities: {
ai: number;
human: number;
mixed: number;
};
sentences: {
sentence: string;
highlight_sentence_for_ai: boolean;
}[];
}
export interface WritingEvaluation extends CommonEvaluation {
ai_detection?: AIDetectionAttributes;
}
export interface SpeakingExercise { export interface SpeakingExercise {
id: string; id: string;
type: "speaking"; type: "speaking";
title: string; title: string;
text: string; text: string;
prompts: string[]; prompts: string[];
suffix?: string;
video_url: string; video_url: string;
userSolutions: { userSolutions: {
id: string; id: string;
@@ -171,6 +195,8 @@ export interface InteractiveSpeakingExercise {
id: string; id: string;
type: "interactiveSpeaking"; type: "interactiveSpeaking";
title: string; title: string;
first_title?: string;
second_title?: string;
text: string; text: string;
prompts: {text: string; video_url: string}[]; prompts: {text: string; video_url: string}[];
userSolutions: { userSolutions: {
@@ -179,13 +205,16 @@ export interface InteractiveSpeakingExercise {
evaluation?: InteractiveSpeakingEvaluation; evaluation?: InteractiveSpeakingEvaluation;
}[]; }[];
topic?: string; topic?: string;
first_topic?: string;
second_topic?: string;
variant?: "initial" | "final";
} }
export interface FillBlanksExercise { export interface FillBlanksExercise {
prompt: string; // *EXAMPLE: "Complete the summary below. Click a blank to select the corresponding word for it." prompt: string; // *EXAMPLE: "Complete the summary below. Click a blank to select the corresponding word for it."
type: "fillBlanks"; type: "fillBlanks";
id: string; id: string;
words: string[]; // *EXAMPLE: ["preserve", "unaware"] words: (string | {letter: string; word: string})[]; // *EXAMPLE: ["preserve", "unaware"]
text: string; // *EXAMPLE: "They tried to {{1}} burning" text: string; // *EXAMPLE: "They tried to {{1}} burning"
allowRepetition: boolean; allowRepetition: boolean;
solutions: { solutions: {

View File

@@ -1,55 +1,56 @@
export interface TokenSuccess { export interface TokenSuccess {
scope: string; scope: string;
access_token: string; access_token: string;
token_type: string; token_type: string;
app_id: string; app_id: string;
expires_in: number; expires_in: number;
nonce: string; nonce: string;
} }
export interface TokenError { export interface TokenError {
error: string; error: string;
error_description: string; error_description: string;
} }
export interface Package { export interface Package {
id: string; id: string;
currency: string; currency: string;
duration: number; duration: number;
duration_unit: DurationUnit; duration_unit: DurationUnit;
price: number; price: number;
} }
export interface Discount { export interface Discount {
id: string; id: string;
percentage: number; percentage: number;
domain: string; domain: string;
validUntil?: Date;
} }
export type DurationUnit = "weeks" | "days" | "months" | "years"; export type DurationUnit = "weeks" | "days" | "months" | "years";
export interface Payment { export interface Payment {
id: string; id: string;
corporate: string; corporate: string;
agent?: string; agent?: string;
agentCommission: number; agentCommission: number;
agentValue: number; agentValue: number;
currency: string; currency: string;
value: number; value: number;
isPaid: boolean; isPaid: boolean;
date: Date | string; date: Date | string;
corporateTransfer?: string; corporateTransfer?: string;
commissionTransfer?: string; commissionTransfer?: string;
} }
export interface PaypalPayment { export interface PaypalPayment {
orderId: string; orderId: string;
userId: string; userId: string;
status: string; status: string;
createdAt: Date; createdAt: Date;
value: number; value: number;
currency: string; currency: string;
subscriptionDuration: number; subscriptionDuration: number;
subscriptionDurationUnit: DurationUnit; subscriptionDurationUnit: DurationUnit;
subscriptionExpirationDate: Date; subscriptionExpirationDate: Date;
} }

View File

@@ -0,0 +1,49 @@
export const markets = ["au", "br", "de"] as const;
export const permissions = [
// generate codes are basicly invites
"createCodeStudent",
"createCodeTeacher",
"createCodeCorporate",
"createCodeCountryManager",
"createCodeAdmin",
// exams
"createReadingExam",
"createListeningExam",
"createWritingExam",
"createSpeakingExam",
"createLevelExam",
// view pages
"viewExams",
"viewExercises",
"viewRecords",
"viewStats",
"viewTickets",
"viewPaymentRecords",
// view data
"viewStudent",
"viewTeacher",
"viewCorporate",
"viewCountryManager",
"viewAdmin",
// edit data
"editStudent",
"editTeacher",
"editCorporate",
"editCountryManager",
"editAdmin",
// delete data
"deleteStudent",
"deleteTeacher",
"deleteCorporate",
"deleteCountryManager",
"deleteAdmin",
] as const;
export type PermissionType = (typeof permissions)[keyof typeof permissions];
export interface Permission {
id: string;
type: PermissionType;
users: string[];
}

View File

@@ -1,5 +1,6 @@
import { Module } from "."; import { Module } from ".";
import { InstructorGender } from "./exam"; import { InstructorGender } from "./exam";
import { PermissionType } from "./permissions";
export type User = export type User =
| StudentUser | StudentUser
@@ -7,7 +8,8 @@ export type User =
| CorporateUser | CorporateUser
| AgentUser | AgentUser
| AdminUser | AdminUser
| DeveloperUser; | DeveloperUser
| MasterCorporateUser;
export type UserStatus = "active" | "disabled" | "paymentDue"; export type UserStatus = "active" | "disabled" | "paymentDue";
export interface BasicUser { export interface BasicUser {
@@ -25,6 +27,7 @@ export interface BasicUser {
subscriptionExpirationDate?: null | Date; subscriptionExpirationDate?: null | Date;
registrationDate?: Date; registrationDate?: Date;
status: UserStatus; status: UserStatus;
permissions: PermissionType[],
} }
export interface StudentUser extends BasicUser { export interface StudentUser extends BasicUser {
@@ -45,6 +48,12 @@ export interface CorporateUser extends BasicUser {
demographicInformation?: DemographicCorporateInformation; demographicInformation?: DemographicCorporateInformation;
} }
export interface MasterCorporateUser extends BasicUser {
type: "mastercorporate";
corporateInformation: CorporateInformation;
demographicInformation?: DemographicCorporateInformation;
}
export interface AgentUser extends BasicUser { export interface AgentUser extends BasicUser {
type: "agent"; type: "agent";
agentInformation: AgentInformation; agentInformation: AgentInformation;
@@ -131,6 +140,7 @@ export interface Stat {
solutions: any[]; solutions: any[];
type: string; type: string;
timeSpent?: number; timeSpent?: number;
inactivity?: number;
assignment?: string; assignment?: string;
score: { score: {
correct: number; correct: number;
@@ -166,7 +176,8 @@ export type Type =
| "corporate" | "corporate"
| "admin" | "admin"
| "developer" | "developer"
| "agent"; | "agent"
| "mastercorporate";
export const userTypes: Type[] = [ export const userTypes: Type[] = [
"student", "student",
"teacher", "teacher",
@@ -174,4 +185,5 @@ export const userTypes: Type[] = [
"admin", "admin",
"developer", "developer",
"agent", "agent",
"mastercorporate",
]; ];

View File

@@ -1,250 +1,366 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import {PERMISSIONS} from "@/constants/userPermissions"; import { PERMISSIONS } from "@/constants/userPermissions";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import {Type, User} from "@/interfaces/user"; import { Type, User } from "@/interfaces/user";
import {USER_TYPE_LABELS} from "@/resources/user"; import { USER_TYPE_LABELS } from "@/resources/user";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize, uniqBy} from "lodash"; import { capitalize, uniqBy } from "lodash";
import moment from "moment"; import moment from "moment";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import ReactDatePicker from "react-datepicker"; import ReactDatePicker from "react-datepicker";
import {toast} from "react-toastify"; import { toast } from "react-toastify";
import ShortUniqueId from "short-unique-id"; import ShortUniqueId from "short-unique-id";
import {useFilePicker} from "use-file-picker"; import { useFilePicker } from "use-file-picker";
import readXlsxFile from "read-excel-file"; import readXlsxFile from "read-excel-file";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import {BsFileEarmarkEaselFill, BsQuestionCircleFill} from "react-icons/bs"; import { BsFileEarmarkEaselFill, BsQuestionCircleFill } from "react-icons/bs";
import { checkAccess } from "@/utils/permissions";
import { PermissionType } from "@/interfaces/permissions";
const EMAIL_REGEX = new RegExp(
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/
);
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/); const USER_TYPE_PERMISSIONS: {
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
const USER_TYPE_PERMISSIONS: {[key in Type]: Type[]} = { } = {
student: [], student: {
teacher: [], perm: "createCodeStudent",
agent: [], list: [],
corporate: ["student", "teacher"], },
admin: ["student", "teacher", "agent", "corporate", "admin"], teacher: {
developer: ["student", "teacher", "agent", "corporate", "admin", "developer"], perm: "createCodeTeacher",
list: [],
},
agent: {
perm: "createCodeCountryManager",
list: [],
},
corporate: {
perm: "createCodeCorporate",
list: ["student", "teacher"],
},
mastercorporate: {
perm: undefined,
list: ["student", "teacher", "corporate"],
},
admin: {
perm: "createCodeAdmin",
list: [
"student",
"teacher",
"agent",
"corporate",
"admin",
"mastercorporate",
],
},
developer: {
perm: undefined,
list: [
"student",
"teacher",
"agent",
"corporate",
"admin",
"developer",
"mastercorporate",
],
},
}; };
export default function BatchCodeGenerator({user}: {user: User}) { export default function BatchCodeGenerator({ user }: { user: User }) {
const [infos, setInfos] = useState<{email: string; name: string; passport_id: string}[]>([]); const [infos, setInfos] = useState<
const [isLoading, setIsLoading] = useState(false); { email: string; name: string; passport_id: string }[]
const [expiryDate, setExpiryDate] = useState<Date | null>( >([]);
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null, const [isLoading, setIsLoading] = useState(false);
); const [expiryDate, setExpiryDate] = useState<Date | null>(
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); user?.subscriptionExpirationDate
const [type, setType] = useState<Type>("student"); ? moment(user.subscriptionExpirationDate).toDate()
const [showHelp, setShowHelp] = useState(false); : null
);
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [type, setType] = useState<Type>("student");
const [showHelp, setShowHelp] = useState(false);
const {users} = useUsers(); const { users } = useUsers();
const {openFilePicker, filesContent, clear} = useFilePicker({ const { openFilePicker, filesContent, clear } = useFilePicker({
accept: ".xlsx", accept: ".xlsx",
multiple: false, multiple: false,
readAs: "ArrayBuffer", readAs: "ArrayBuffer",
}); });
useEffect(() => console.log(expiryDate), [expiryDate]); useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null);
}, [isExpiryDateEnabled]);
useEffect(() => { useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null); if (filesContent.length > 0) {
}, [isExpiryDateEnabled]); const file = filesContent[0];
readXlsxFile(file.content).then((rows) => {
try {
const information = uniqBy(
rows
.map((row) => {
const [
firstName,
lastName,
country,
passport_id,
email,
...phone
] = row as string[];
return EMAIL_REGEX.test(email.toString().trim())
? {
email: email.toString().trim().toLowerCase(),
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
passport_id: passport_id?.toString().trim() || undefined,
}
: undefined;
})
.filter((x) => !!x) as typeof infos,
(x) => x.email
);
useEffect(() => { if (information.length === 0) {
if (filesContent.length > 0) { toast.error(
const file = filesContent[0]; "Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!"
readXlsxFile(file.content).then((rows) => { );
try { return clear();
const information = uniqBy( }
rows
.map((row) => {
const [firstName, lastName, country, passport_id, email, ...phone] = row as string[];
return EMAIL_REGEX.test(email.toString().trim())
? {
email: email.toString().trim().toLowerCase(),
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
passport_id: passport_id?.toString().trim() || undefined,
}
: undefined;
})
.filter((x) => !!x) as typeof infos,
(x) => x.email,
);
if (information.length === 0) { setInfos(information);
toast.error( } catch {
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!", toast.error(
); "Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!"
return clear(); );
} return clear();
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filesContent]);
setInfos(information); const generateAndInvite = async () => {
} catch { const newUsers = infos.filter(
toast.error( (x) => !users.map((u) => u.email).includes(x.email)
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!", );
); const existingUsers = infos
return clear(); .filter((x) => users.map((u) => u.email).includes(x.email))
} .map((i) => users.find((u) => u.email === i.email))
}); .filter((x) => !!x && x.type === "student") as User[];
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filesContent]);
const generateAndInvite = async () => { const newUsersSentence =
const newUsers = infos.filter((x) => !users.map((u) => u.email).includes(x.email)); newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined;
const existingUsers = infos const existingUsersSentence =
.filter((x) => users.map((u) => u.email).includes(x.email)) existingUsers.length > 0
.map((i) => users.find((u) => u.email === i.email)) ? `invite ${existingUsers.length} registered student(s)`
.filter((x) => !!x && x.type === "student") as User[]; : undefined;
if (
!confirm(
`You are about to ${[newUsersSentence, existingUsersSentence]
.filter((x) => !!x)
.join(" and ")}, are you sure you want to continue?`
)
)
return;
const newUsersSentence = newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined; setIsLoading(true);
const existingUsersSentence = existingUsers.length > 0 ? `invite ${existingUsers.length} registered student(s)` : undefined; Promise.all(
if ( existingUsers.map(
!confirm( async (u) =>
`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`, await axios.post(`/api/invites`, { to: u.id, from: user.id })
) )
) )
return; .then(() =>
toast.success(
`Successfully invited ${existingUsers.length} registered student(s)!`
)
)
.finally(() => {
if (newUsers.length === 0) setIsLoading(false);
});
setIsLoading(true); if (newUsers.length > 0) generateCode(type, newUsers);
Promise.all(existingUsers.map(async (u) => await axios.post(`/api/invites`, {to: u.id, from: user.id}))) setInfos([]);
.then(() => toast.success(`Successfully invited ${existingUsers.length} registered student(s)!`)) };
.finally(() => {
if (newUsers.length === 0) setIsLoading(false);
});
if (newUsers.length > 0) generateCode(type, newUsers); const generateCode = (type: Type, informations: typeof infos) => {
setInfos([]); const uid = new ShortUniqueId();
}; const codes = informations.map(() => uid.randomUUID(6));
const generateCode = (type: Type, informations: typeof infos) => { setIsLoading(true);
const uid = new ShortUniqueId(); axios
const codes = informations.map(() => uid.randomUUID(6)); .post<{ ok: boolean; valid?: number; reason?: string }>("/api/code", {
type,
codes,
infos: informations,
expiryDate,
})
.then(({ data, status }) => {
if (data.ok) {
toast.success(
`Successfully generated${
data.valid ? ` ${data.valid}/${informations.length}` : ""
} ${capitalize(type)} codes and they have been notified by e-mail!`,
{ toastId: "success" }
);
return;
}
setIsLoading(true); if (status === 403) {
axios toast.error(data.reason, { toastId: "forbidden" });
.post<{ok: boolean; valid?: number; reason?: string}>("/api/code", { }
type, })
codes, .catch(({ response: { status, data } }) => {
infos: informations, if (status === 403) {
expiryDate, toast.error(data.reason, { toastId: "forbidden" });
}) return;
.then(({data, status}) => { }
if (data.ok) {
toast.success(
`Successfully generated${data.valid ? ` ${data.valid}/${informations.length}` : ""} ${capitalize(
type,
)} codes and they have been notified by e-mail!`,
{toastId: "success"},
);
return;
}
if (status === 403) { toast.error(`Something went wrong, please try again later!`, {
toast.error(data.reason, {toastId: "forbidden"}); toastId: "error",
} });
}) })
.catch(({response: {status, data}}) => { .finally(() => {
if (status === 403) { setIsLoading(false);
toast.error(data.reason, {toastId: "forbidden"}); return clear();
return; });
} };
toast.error(`Something went wrong, please try again later!`, { return (
toastId: "error", <>
}); <Modal
}) isOpen={showHelp}
.finally(() => { onClose={() => setShowHelp(false)}
setIsLoading(false); title="Excel File Format"
return clear(); >
}); <div className="mt-4 flex flex-col gap-2">
}; <span>Please upload an Excel file with the following format:</span>
<table className="w-full">
return ( <thead>
<> <tr>
<Modal isOpen={showHelp} onClose={() => setShowHelp(false)} title="Excel File Format"> <th className="border border-neutral-200 px-2 py-1">
<div className="mt-4 flex flex-col gap-2"> First Name
<span>Please upload an Excel file with the following format:</span> </th>
<table className="w-full"> <th className="border border-neutral-200 px-2 py-1">
<thead> Last Name
<tr> </th>
<th className="border border-neutral-200 px-2 py-1">First Name</th> <th className="border border-neutral-200 px-2 py-1">Country</th>
<th className="border border-neutral-200 px-2 py-1">Last Name</th> <th className="border border-neutral-200 px-2 py-1">
<th className="border border-neutral-200 px-2 py-1">Country</th> Passport/National ID
<th className="border border-neutral-200 px-2 py-1">Passport/National ID</th> </th>
<th className="border border-neutral-200 px-2 py-1">E-mail</th> <th className="border border-neutral-200 px-2 py-1">E-mail</th>
<th className="border border-neutral-200 px-2 py-1">Phone Number</th> <th className="border border-neutral-200 px-2 py-1">
</tr> Phone Number
</thead> </th>
</table> </tr>
<span className="mt-4"> </thead>
<b>Notes:</b> </table>
<ul> <span className="mt-4">
<li>- All incorrect e-mails will be ignored;</li> <b>Notes:</b>
<li>- All already registered e-mails will be ignored;</li> <ul>
<li>- You may have a header row with the format above, however, it is not necessary;</li> <li>- All incorrect e-mails will be ignored;</li>
<li>- All of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below.</li> <li>- All already registered e-mails will be ignored;</li>
</ul> <li>
</span> - You may have a header row with the format above, however, it
</div> is not necessary;
</Modal> </li>
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4"> <li>
<div className="flex items-end justify-between"> - All of the e-mails in the file will receive an e-mail to join
<label className="text-mti-gray-dim text-base font-normal">Choose an Excel file</label> EnCoach with the role selected below.
<div className="tooltip cursor-pointer" data-tip="Excel File Format" onClick={() => setShowHelp(true)}> </li>
<BsQuestionCircleFill /> </ul>
</div> </span>
</div> </div>
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}> </Modal>
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"} <div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
</Button> <div className="flex items-end justify-between">
{user && (user.type === "developer" || user.type === "admin" || user.type === "corporate") && ( <label className="text-mti-gray-dim text-base font-normal">
<> Choose an Excel file
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center"> </label>
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label> <div
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}> className="tooltip cursor-pointer"
Enabled data-tip="Excel File Format"
</Checkbox> onClick={() => setShowHelp(true)}
</div> >
{isExpiryDateEnabled && ( <BsQuestionCircleFill />
<ReactDatePicker </div>
className={clsx( </div>
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none", <Button
"hover:border-mti-purple tooltip", onClick={openFilePicker}
"transition duration-300 ease-in-out", isLoading={isLoading}
)} disabled={isLoading}
filterDate={(date) => >
moment(date).isAfter(new Date()) && {filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true) </Button>
} {user &&
dateFormat="dd/MM/yyyy" checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
selected={expiryDate} <>
onChange={(date) => setExpiryDate(date)} <div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
/> <label className="text-mti-gray-dim text-base font-normal">
)} Expiry Date
</> </label>
)} <Checkbox
<label className="text-mti-gray-dim text-base font-normal">Select the type of user they should be</label> isChecked={isExpiryDateEnabled}
{user && ( onChange={setIsExpiryDateEnabled}
<select disabled={!!user.subscriptionExpirationDate}
defaultValue="student" >
onChange={(e) => setType(e.target.value as typeof user.type)} Enabled
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none"> </Checkbox>
{Object.keys(USER_TYPE_LABELS) </div>
.filter((x) => USER_TYPE_PERMISSIONS[user.type].includes(x as Type)) {isExpiryDateEnabled && (
.map((type) => ( <ReactDatePicker
<option key={type} value={type}> className={clsx(
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]} "flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
</option> "hover:border-mti-purple tooltip",
))} "transition duration-300 ease-in-out"
</select> )}
)} filterDate={(date) =>
<Button onClick={generateAndInvite} disabled={infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}> moment(date).isAfter(new Date()) &&
Generate & Send (user.subscriptionExpirationDate
</Button> ? moment(date).isBefore(user.subscriptionExpirationDate)
</div> : true)
</> }
); dateFormat="dd/MM/yyyy"
selected={expiryDate}
onChange={(date) => setExpiryDate(date)}
/>
)}
</>
)}
<label className="text-mti-gray-dim text-base font-normal">
Select the type of user they should be
</label>
{user && (
<select
defaultValue="student"
onChange={(e) => setType(e.target.value as typeof user.type)}
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none"
>
{Object.keys(USER_TYPE_LABELS)
.filter((x) => {
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
return checkAccess(user, list, perm);
})
.map((type) => (
<option key={type} value={type}>
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
</option>
))}
</select>
)}
<Button
onClick={generateAndInvite}
disabled={
infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)
}
>
Generate & Send
</Button>
</div>
</>
);
} }

View File

@@ -1,125 +1,197 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import {PERMISSIONS} from "@/constants/userPermissions"; import { PERMISSIONS } from "@/constants/userPermissions";
import {Type, User} from "@/interfaces/user"; import { Type, User } from "@/interfaces/user";
import {USER_TYPE_LABELS} from "@/resources/user"; import { USER_TYPE_LABELS } from "@/resources/user";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize} from "lodash"; import { capitalize } from "lodash";
import moment from "moment"; import moment from "moment";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import ReactDatePicker from "react-datepicker"; import ReactDatePicker from "react-datepicker";
import {toast} from "react-toastify"; import { toast } from "react-toastify";
import ShortUniqueId from "short-unique-id"; import ShortUniqueId from "short-unique-id";
import { checkAccess } from "@/utils/permissions";
import { PermissionType } from "@/interfaces/permissions";
const USER_TYPE_PERMISSIONS: {[key in Type]: Type[]} = { const USER_TYPE_PERMISSIONS: {
student: [], [key in Type]: { perm: PermissionType | undefined; list: Type[] };
teacher: [], } = {
agent: [], student: {
corporate: ["student", "teacher"], perm: "createCodeStudent",
admin: ["student", "teacher", "agent", "corporate", "admin"], list: [],
developer: ["student", "teacher", "agent", "corporate", "admin", "developer"], },
teacher: {
perm: "createCodeTeacher",
list: [],
},
agent: {
perm: "createCodeCountryManager",
list: [],
},
corporate: {
perm: "createCodeCorporate",
list: ["student", "teacher"],
},
mastercorporate: {
perm: undefined,
list: ["student", "teacher", "corporate"],
},
admin: {
perm: "createCodeAdmin",
list: [
"student",
"teacher",
"agent",
"corporate",
"admin",
"mastercorporate",
],
},
developer: {
perm: undefined,
list: [
"student",
"teacher",
"agent",
"corporate",
"admin",
"developer",
"mastercorporate",
],
},
}; };
export default function CodeGenerator({user}: {user: User}) { export default function CodeGenerator({ user }: { user: User }) {
const [generatedCode, setGeneratedCode] = useState<string>(); const [generatedCode, setGeneratedCode] = useState<string>();
const [expiryDate, setExpiryDate] = useState<Date | null>( const [expiryDate, setExpiryDate] = useState<Date | null>(
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null, user?.subscriptionExpirationDate
); ? moment(user.subscriptionExpirationDate).toDate()
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); : null
const [type, setType] = useState<Type>("student"); );
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [type, setType] = useState<Type>("student");
useEffect(() => { useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null); if (!isExpiryDateEnabled) setExpiryDate(null);
}, [isExpiryDateEnabled]); }, [isExpiryDateEnabled]);
const generateCode = (type: Type) => { const generateCode = (type: Type) => {
const uid = new ShortUniqueId(); const uid = new ShortUniqueId();
const code = uid.randomUUID(6); const code = uid.randomUUID(6);
axios axios
.post("/api/code", {type, codes: [code], expiryDate}) .post("/api/code", { type, codes: [code], expiryDate })
.then(({data, status}) => { .then(({ data, status }) => {
if (data.ok) { if (data.ok) {
toast.success(`Successfully generated a ${capitalize(type)} code!`, {toastId: "success"}); toast.success(`Successfully generated a ${capitalize(type)} code!`, {
setGeneratedCode(code); toastId: "success",
return; });
} setGeneratedCode(code);
return;
}
if (status === 403) { if (status === 403) {
toast.error(data.reason, {toastId: "forbidden"}); toast.error(data.reason, { toastId: "forbidden" });
} }
}) })
.catch(({response: {status, data}}) => { .catch(({ response: { status, data } }) => {
if (status === 403) { if (status === 403) {
toast.error(data.reason, {toastId: "forbidden"}); toast.error(data.reason, { toastId: "forbidden" });
return; return;
} }
toast.error(`Something went wrong, please try again later!`, {toastId: "error"}); toast.error(`Something went wrong, please try again later!`, {
}); toastId: "error",
}; });
});
};
return ( return (
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl"> <div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
<label className="font-normal text-base text-mti-gray-dim">User Code Generator</label> <label className="font-normal text-base text-mti-gray-dim">
{user && ( User Code Generator
<select </label>
defaultValue="student" {user && (
onChange={(e) => setType(e.target.value as typeof user.type)} <select
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white"> defaultValue="student"
{Object.keys(USER_TYPE_LABELS) onChange={(e) => setType(e.target.value as typeof user.type)}
.filter((x) => USER_TYPE_PERMISSIONS[user.type].includes(x as Type)) className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white"
.map((type) => ( >
<option key={type} value={type}> {Object.keys(USER_TYPE_LABELS)
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]} .filter((x) => {
</option> const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
))} return checkAccess(user, list, perm);
</select> })
)} .map((type) => (
{user && (user.type === "developer" || user.type === "admin" || user.type === "corporate") && ( <option key={type} value={type}>
<> {USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center"> </option>
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label> ))}
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}> </select>
Enabled )}
</Checkbox> {user &&
</div> checkAccess(user, ["developer", "admin", "corporate"]) && (
{isExpiryDateEnabled && ( <>
<ReactDatePicker <div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
className={clsx( <label className="text-mti-gray-dim text-base font-normal">
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none", Expiry Date
"hover:border-mti-purple tooltip", </label>
"transition duration-300 ease-in-out", <Checkbox
)} isChecked={isExpiryDateEnabled}
filterDate={(date) => onChange={setIsExpiryDateEnabled}
moment(date).isAfter(new Date()) && disabled={!!user.subscriptionExpirationDate}
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true) >
} Enabled
dateFormat="dd/MM/yyyy" </Checkbox>
selected={expiryDate} </div>
onChange={(date) => setExpiryDate(date)} {isExpiryDateEnabled && (
/> <ReactDatePicker
)} className={clsx(
</> "flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
)} "hover:border-mti-purple tooltip",
<Button onClick={() => generateCode(type)} disabled={isExpiryDateEnabled ? !expiryDate : false}> "transition duration-300 ease-in-out"
Generate )}
</Button> filterDate={(date) =>
<label className="font-normal text-base text-mti-gray-dim">Generated Code:</label> moment(date).isAfter(new Date()) &&
<div (user.subscriptionExpirationDate
className={clsx( ? moment(date).isBefore(user.subscriptionExpirationDate)
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", : true)
"hover:border-mti-purple tooltip", }
"transition duration-300 ease-in-out", dateFormat="dd/MM/yyyy"
)} selected={expiryDate}
data-tip="Click to copy" onChange={(date) => setExpiryDate(date)}
onClick={() => { />
if (generatedCode) navigator.clipboard.writeText(generatedCode); )}
}}> </>
{generatedCode} )}
</div> <Button
{generatedCode && <span className="text-sm text-mti-gray-dim font-light">Give this code to the user to complete their registration</span>} onClick={() => generateCode(type)}
</div> disabled={isExpiryDateEnabled ? !expiryDate : false}
); >
Generate
</Button>
<label className="font-normal text-base text-mti-gray-dim">
Generated Code:
</label>
<div
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out"
)}
data-tip="Click to copy"
onClick={() => {
if (generatedCode) navigator.clipboard.writeText(generatedCode);
}}
>
{generatedCode}
</div>
{generatedCode && (
<span className="text-sm text-mti-gray-dim font-light">
Give this code to the user to complete their registration
</span>
)}
</div>
);
} }

View File

@@ -14,9 +14,11 @@ import {
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import axios from "axios"; import axios from "axios";
import moment from "moment"; import moment from "moment";
import { useEffect, useState } from "react"; import { useEffect, useState, useMemo } from "react";
import { BsTrash } from "react-icons/bs"; import { BsTrash } from "react-icons/bs";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import ReactDatePicker from "react-datepicker";
import clsx from "clsx";
const columnHelper = createColumnHelper<Code>(); const columnHelper = createColumnHelper<Code>();
@@ -41,41 +43,51 @@ export default function CodeList({ user }: { user: User }) {
const [selectedCodes, setSelectedCodes] = useState<string[]>([]); const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
const [filteredCorporate, setFilteredCorporate] = useState<User | undefined>( const [filteredCorporate, setFilteredCorporate] = useState<User | undefined>(
user?.type === "corporate" ? user : undefined, user?.type === "corporate" ? user : undefined
); );
const [filterAvailability, setFilterAvailability] = useState< const [filterAvailability, setFilterAvailability] = useState<
"in-use" | "unused" "in-use" | "unused"
>(); >();
const [filteredCodes, setFilteredCodes] = useState<Code[]>([]); // const [filteredCodes, setFilteredCodes] = useState<Code[]>([]);
const { users } = useUsers(); const { users } = useUsers();
const { codes, reload } = useCodes( const { codes, reload } = useCodes(
user?.type === "corporate" ? user?.id : undefined, user?.type === "corporate" ? user?.id : undefined
); );
useEffect(() => { const [startDate, setStartDate] = useState<Date | null>(moment("01/01/2023").toDate());
let result = [...codes]; const [endDate, setEndDate] = useState<Date | null>(moment().endOf("day").toDate());
if (filteredCorporate) const filteredCodes = useMemo(() => {
result = result.filter((x) => x.creator === filteredCorporate.id); return codes.filter((x) => {
if (filterAvailability) // TODO: if the expiry date is missing, it does not make sense to filter by date
result = result.filter((x) => // so we need to find a way to handle this edge case
filterAvailability === "in-use" ? !!x.userId : !x.userId, if(startDate && endDate && x.expiryDate) {
); const date = moment(x.expiryDate);
if(date.isBefore(startDate) || date.isAfter(endDate)) {
return false;
}
}
if (filteredCorporate && x.creator !== filteredCorporate.id) return false;
if (filterAvailability) {
if (filterAvailability === "in-use" && !x.userId) return false;
if (filterAvailability === "unused" && x.userId) return false;
}
setFilteredCodes(result); return true;
}, [codes, filteredCorporate, filterAvailability]); });
}, [codes, startDate, endDate, filteredCorporate, filterAvailability]);
const toggleCode = (id: string) => { const toggleCode = (id: string) => {
setSelectedCodes((prev) => setSelectedCodes((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
); );
}; };
const toggleAllCodes = (checked: boolean) => { const toggleAllCodes = (checked: boolean) => {
if (checked) if (checked)
return setSelectedCodes( return setSelectedCodes(
filteredCodes.filter((x) => !x.userId).map((x) => x.code), filteredCodes.filter((x) => !x.userId).map((x) => x.code)
); );
return setSelectedCodes([]); return setSelectedCodes([]);
@@ -137,7 +149,7 @@ export default function CodeList({ user }: { user: User }) {
const defaultColumns = [ const defaultColumns = [
columnHelper.accessor("code", { columnHelper.accessor("code", {
id: "code", id: "codeCheckbox",
header: () => ( header: () => (
<Checkbox <Checkbox
disabled={filteredCodes.filter((x) => !x.userId).length === 0} disabled={filteredCodes.filter((x) => !x.userId).length === 0}
@@ -242,18 +254,20 @@ export default function CodeList({ user }: { user: User }) {
} }
options={users options={users
.filter((x) => .filter((x) =>
["admin", "developer", "corporate"].includes(x.type), ["admin", "developer", "corporate"].includes(x.type)
) )
.map((x) => ({ .map((x) => ({
label: `${x.type === "corporate" ? x.corporateInformation?.companyInformation?.name || x.name : x.name} (${ label: `${
USER_TYPE_LABELS[x.type] x.type === "corporate"
})`, ? x.corporateInformation?.companyInformation?.name || x.name
: x.name
} (${USER_TYPE_LABELS[x.type]})`,
value: x.id, value: x.id,
user: x, user: x,
}))} }))}
onChange={(value) => onChange={(value) =>
setFilteredCorporate( setFilteredCorporate(
value ? users.find((x) => x.id === value?.value) : undefined, value ? users.find((x) => x.id === value?.value) : undefined
) )
} }
/> />
@@ -267,10 +281,32 @@ export default function CodeList({ user }: { user: User }) {
]} ]}
onChange={(value) => onChange={(value) =>
setFilterAvailability( setFilterAvailability(
value ? (value.value as typeof filterAvailability) : undefined, value ? (value.value as typeof filterAvailability) : undefined
) )
} }
/> />
<ReactDatePicker
dateFormat="dd/MM/yyyy"
className="px-4 py-6 w-full text-sm text-center font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed rounded-full border border-mti-gray-platinum focus:outline-none"
selected={startDate}
startDate={startDate}
endDate={endDate}
selectsRange
showMonthDropdown
filterDate={(date: Date) =>
moment(date).isSameOrBefore(moment(new Date()))
}
onChange={([initialDate, finalDate]: [Date, Date]) => {
setStartDate(initialDate ?? moment("01/01/2023").toDate());
if (finalDate) {
// basicly selecting a final day works as if I'm selecting the first
// minute of that day. this way it covers the whole day
setEndDate(moment(finalDate).endOf("day").toDate());
return;
}
setEndDate(null);
}}
/>
</div> </div>
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<span>{selectedCodes.length} code(s) selected</span> <span>{selectedCodes.length} code(s) selected</span>
@@ -295,7 +331,7 @@ export default function CodeList({ user }: { user: User }) {
? null ? null
: flexRender( : flexRender(
header.column.columnDef.header, header.column.columnDef.header,
header.getContext(), header.getContext()
)} )}
</th> </th>
))} ))}

View File

@@ -7,336 +7,301 @@ import useCodes from "@/hooks/useCodes";
import useDiscounts from "@/hooks/useDiscounts"; import useDiscounts from "@/hooks/useDiscounts";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import { Discount } from "@/interfaces/paypal"; import {Discount} from "@/interfaces/paypal";
import { Code, User } from "@/interfaces/user"; import {Code, User} from "@/interfaces/user";
import { USER_TYPE_LABELS } from "@/resources/user"; import {USER_TYPE_LABELS} from "@/resources/user";
import { import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import axios from "axios"; import axios from "axios";
import clsx from "clsx";
import moment from "moment"; import moment from "moment";
import { useEffect, useState } from "react"; import {useEffect, useState} from "react";
import { BsPencil, BsTrash } from "react-icons/bs"; import ReactDatePicker from "react-datepicker";
import { toast } from "react-toastify"; import {BsPencil, BsTrash} from "react-icons/bs";
import {toast} from "react-toastify";
const columnHelper = createColumnHelper<Discount>(); const columnHelper = createColumnHelper<Discount>();
const DiscountCreator = ({ const DiscountCreator = ({discount, onClose}: {discount?: Discount; onClose: () => void}) => {
discount, const [percentage, setPercentage] = useState(discount?.percentage);
onClose, const [domain, setDomain] = useState(discount?.domain);
}: { const [validUntil, setValidUntil] = useState(discount?.validUntil);
discount?: Discount;
onClose: () => void;
}) => {
const [percentage, setPercentage] = useState(discount?.percentage);
const [domain, setDomain] = useState(discount?.domain);
const submit = async () => { const submit = async () => {
const body = { percentage, domain }; const body = {percentage, domain, validUntil: validUntil?.toISOString() || undefined};
if (discount) { if (discount) {
return axios return axios
.patch(`/api/discounts/${discount.id}`, body) .patch(`/api/discounts/${discount.id}`, body)
.then(() => { .then(() => {
toast.success("Discount has been edited successfully!"); toast.success("Discount has been edited successfully!");
onClose(); onClose();
}) })
.catch(() => { .catch(() => {
toast.error("Something went wrong, please try again later!"); toast.error("Something went wrong, please try again later!");
}); });
} }
return axios return axios
.post(`/api/discounts`, body) .post(`/api/discounts`, body)
.then(() => { .then(() => {
toast.success("New discount has been created successfully!"); toast.success("New discount has been created successfully!");
onClose(); onClose();
}) })
.catch(() => { .catch(() => {
toast.error("Something went wrong, please try again later!"); toast.error("Something went wrong, please try again later!");
}); });
}; };
return ( return (
<div className="flex flex-col gap-8 py-8"> <div className="flex flex-col gap-8 py-8">
<div className="w-full grid grid-cols-1 md:grid-cols-2 gap-8"> <div className="w-full grid grid-cols-1 gap-8">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim"> <label className="font-normal text-base text-mti-gray-dim">Domain *</label>
Domain * <div className="flex gap-4 items-center">
</label> <Input
<div className="flex gap-4 items-center"> defaultValue={domain}
<Input placeholder="encoach.com"
defaultValue={domain} name="domain"
placeholder="encoach.com" type="text"
name="domain" onChange={(e) => setDomain(e.replaceAll("@", ""))}
type="text" />
onChange={(e) => setDomain(e.replaceAll("@", ""))} </div>
/> </div>
</div> <div className="flex flex-col gap-3">
</div> <label className="font-normal text-base text-mti-gray-dim">Percentage (in %) *</label>
<div className="flex flex-col gap-3"> <div className="flex gap-4 items-center">
<label className="font-normal text-base text-mti-gray-dim"> <Input
Percentage (in %) * defaultValue={percentage}
</label> placeholder="20"
<div className="flex gap-4 items-center"> name="percentage"
<Input type="number"
defaultValue={percentage} onChange={(e) => setPercentage(parseFloat(e))}
placeholder="20" />
name="percentage" </div>
type="number" </div>
onChange={(e) => setPercentage(parseFloat(e))} <div className="flex flex-col gap-3 w-full">
/> <label className="font-normal text-base text-mti-gray-dim">Valid Until</label>
</div> <div className="flex gap-4 items-center w-full">
</div> <ReactDatePicker
</div> wrapperClassName="w-full z-[900]"
<div className="flex w-full justify-end items-center gap-8 mt-8"> calendarClassName="z-[900]"
<Button popperClassName="z-[900]"
variant="outline" isClearable
color="red" className={clsx(
className="w-full max-w-[200px]" "flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
onClick={onClose} "hover:border-mti-purple tooltip",
> "transition duration-300 ease-in-out",
Cancel )}
</Button> filterDate={(date) => moment(date).isAfter(new Date())}
<Button dateFormat="dd/MM/yyyy"
className="w-full max-w-[200px]" selected={validUntil}
onClick={submit} onChange={(date) => setValidUntil(date ? moment(date).endOf("day").toDate() : undefined)}
disabled={!percentage || !domain} />
> </div>
Submit </div>
</Button> </div>
</div> <div className="flex w-full justify-end items-center gap-8 mt-8">
</div> <Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={onClose}>
); Cancel
</Button>
<Button className="w-full max-w-[200px]" onClick={submit} disabled={!percentage || !domain}>
Submit
</Button>
</div>
</div>
);
}; };
export default function DiscountList({ user }: { user: User }) { export default function DiscountList({user}: {user: User}) {
const [selectedDiscounts, setSelectedDiscounts] = useState<string[]>([]); const [selectedDiscounts, setSelectedDiscounts] = useState<string[]>([]);
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [editingDiscount, setEditingDiscount] = useState<Discount>(); const [editingDiscount, setEditingDiscount] = useState<Discount>();
const [filteredDiscounts, setFilteredDiscounts] = useState<Discount[]>([]); const [filteredDiscounts, setFilteredDiscounts] = useState<Discount[]>([]);
const { users } = useUsers(); const {users} = useUsers();
const { discounts, reload } = useDiscounts(); const {discounts, reload} = useDiscounts();
useEffect(() => { useEffect(() => {
setFilteredDiscounts(discounts); setFilteredDiscounts(discounts);
}, [discounts]); }, [discounts]);
const toggleDiscount = (id: string) => { const toggleDiscount = (id: string) => {
setSelectedDiscounts((prev) => setSelectedDiscounts((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], };
);
};
const toggleAllDiscounts = (checked: boolean) => { const toggleAllDiscounts = (checked: boolean) => {
if (checked) if (checked) return setSelectedDiscounts(filteredDiscounts.map((x) => x.id));
return setSelectedDiscounts(filteredDiscounts.map((x) => x.id));
return setSelectedDiscounts([]); return setSelectedDiscounts([]);
}; };
const deleteDiscounts = async (discounts: string[]) => { const deleteDiscounts = async (discounts: string[]) => {
if ( if (!confirm(`Are you sure you want to delete these ${discounts.length} discount(s)?`)) return;
!confirm(
`Are you sure you want to delete these ${discounts.length} discount(s)?`,
)
)
return;
const params = new URLSearchParams(); const params = new URLSearchParams();
discounts.forEach((code) => params.append("discount", code)); discounts.forEach((code) => params.append("discount", code));
axios axios
.delete(`/api/discounts?${params.toString()}`) .delete(`/api/discounts?${params.toString()}`)
.then(() => toast.success(`Deleted the discount(s)!`)) .then(() => toast.success(`Deleted the discount(s)!`))
.catch((reason) => { .catch((reason) => {
if (reason.response.status === 404) { if (reason.response.status === 404) {
toast.error("Discount not found!"); toast.error("Discount not found!");
return; return;
} }
if (reason.response.status === 403) { if (reason.response.status === 403) {
toast.error("You do not have permission to delete this discount!"); toast.error("You do not have permission to delete this discount!");
return; return;
} }
toast.error("Something went wrong, please try again later."); toast.error("Something went wrong, please try again later.");
}) })
.finally(reload); .finally(reload);
}; };
const deleteDiscount = async (discount: Discount) => { const deleteDiscount = async (discount: Discount) => {
if ( if (!confirm(`Are you sure you want to delete this "${discount.id}" discount?`)) return;
!confirm(
`Are you sure you want to delete this "${discount.id}" discount?`,
)
)
return;
axios axios
.delete(`/api/discounts/${discount.id}`) .delete(`/api/discounts/${discount.id}`)
.then(() => toast.success(`Deleted the "${discount.id}" discount`)) .then(() => toast.success(`Deleted the "${discount.id}" discount`))
.catch((reason) => { .catch((reason) => {
if (reason.response.status === 404) { if (reason.response.status === 404) {
toast.error("Code not found!"); toast.error("Code not found!");
return; return;
} }
if (reason.response.status === 403) { if (reason.response.status === 403) {
toast.error("You do not have permission to delete this discount!"); toast.error("You do not have permission to delete this discount!");
return; return;
} }
toast.error("Something went wrong, please try again later."); toast.error("Something went wrong, please try again later.");
}) })
.finally(reload); .finally(reload);
}; };
const defaultColumns = [ const defaultColumns = [
columnHelper.accessor("id", { columnHelper.accessor("id", {
id: "id", id: "id",
header: () => ( header: () => (
<Checkbox <Checkbox
disabled={filteredDiscounts.length === 0} disabled={filteredDiscounts.length === 0}
isChecked={ isChecked={selectedDiscounts.length === filteredDiscounts.length && filteredDiscounts.length > 0}
selectedDiscounts.length === filteredDiscounts.length && onChange={(checked) => toggleAllDiscounts(checked)}>
filteredDiscounts.length > 0 {""}
} </Checkbox>
onChange={(checked) => toggleAllDiscounts(checked)} ),
> cell: (info) => (
{""} <Checkbox isChecked={selectedDiscounts.includes(info.getValue())} onChange={() => toggleDiscount(info.getValue())}>
</Checkbox> {""}
), </Checkbox>
cell: (info) => ( ),
<Checkbox }),
isChecked={selectedDiscounts.includes(info.getValue())} columnHelper.accessor("id", {
onChange={() => toggleDiscount(info.getValue())} header: "ID",
> cell: (info) => info.getValue(),
{""} }),
</Checkbox> columnHelper.accessor("domain", {
), header: "Domain",
}), cell: (info) => `@${info.getValue()}`,
columnHelper.accessor("id", { }),
header: "ID", columnHelper.accessor("percentage", {
cell: (info) => info.getValue(), header: "Percentage",
}), cell: (info) => `${info.getValue()}%`,
columnHelper.accessor("domain", { }),
header: "Domain", columnHelper.accessor("validUntil", {
cell: (info) => `@${info.getValue()}`, header: "Valid Until",
}), cell: (info) => (info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : ""),
columnHelper.accessor("percentage", { }),
header: "Percentage", {
cell: (info) => `${info.getValue()}%`, header: "",
}), id: "actions",
{ cell: ({row}: {row: {original: Discount}}) => {
header: "", return (
id: "actions", <div className="flex gap-4">
cell: ({ row }: { row: { original: Discount } }) => { <div
return ( data-tip="Delete"
<div className="flex gap-4"> className="cursor-pointer tooltip"
<div onClick={() => {
data-tip="Delete" setEditingDiscount(row.original);
className="cursor-pointer tooltip" }}>
onClick={() => { <BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" />
setEditingDiscount(row.original); </div>
}} <div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteDiscount(row.original)}>
> <BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
<BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" /> </div>
</div> </div>
<div );
data-tip="Delete" },
className="cursor-pointer tooltip" },
onClick={() => deleteDiscount(row.original)} ];
>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
</div>
);
},
},
];
const table = useReactTable({ const table = useReactTable({
data: filteredDiscounts, data: filteredDiscounts,
columns: defaultColumns, columns: defaultColumns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}); });
const closeModal = () => { const closeModal = () => {
setIsCreating(false); setIsCreating(false);
setEditingDiscount(undefined); setEditingDiscount(undefined);
reload(); reload();
}; };
return ( return (
<> <>
<Modal <Modal
isOpen={isCreating || !!editingDiscount} isOpen={isCreating || !!editingDiscount}
onClose={closeModal} onClose={closeModal}
title={ title={editingDiscount ? `Editing ${editingDiscount.id}` : "New Discount"}>
editingDiscount ? `Editing ${editingDiscount.id}` : "New Discount" <DiscountCreator onClose={closeModal} discount={editingDiscount} />
} </Modal>
> <div className="flex items-center justify-end pb-4 pt-1">
<DiscountCreator onClose={closeModal} discount={editingDiscount} /> <div className="flex gap-4 items-center">
</Modal> <span>{selectedDiscounts.length} code(s) selected</span>
<div className="flex items-center justify-end pb-4 pt-1"> <Button
<div className="flex gap-4 items-center"> disabled={selectedDiscounts.length === 0}
<span>{selectedDiscounts.length} code(s) selected</span> variant="outline"
<Button color="red"
disabled={selectedDiscounts.length === 0} className="!py-1 px-10"
variant="outline" onClick={() => deleteDiscounts(selectedDiscounts)}>
color="red" Delete
className="!py-1 px-10" </Button>
onClick={() => deleteDiscounts(selectedDiscounts)} </div>
> </div>
Delete <table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
</Button> <thead>
</div> {table.getHeaderGroups().map((headerGroup) => (
</div> <tr key={headerGroup.id}>
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full"> {headerGroup.headers.map((header) => (
<thead> <th className="p-4 text-left" key={header.id}>
{table.getHeaderGroups().map((headerGroup) => ( {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
<tr key={headerGroup.id}> </th>
{headerGroup.headers.map((header) => ( ))}
<th className="p-4 text-left" key={header.id}> </tr>
{header.isPlaceholder ))}
? null </thead>
: flexRender( <tbody className="px-2">
header.column.columnDef.header, {table.getRowModel().rows.map((row) => (
header.getContext(), <tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
)} {row.getVisibleCells().map((cell) => (
</th> <td className="px-4 py-2" key={cell.id}>
))} {flexRender(cell.column.columnDef.cell, cell.getContext())}
</tr> </td>
))} ))}
</thead> </tr>
<tbody className="px-2"> ))}
{table.getRowModel().rows.map((row) => ( </tbody>
<tr </table>
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" <button
key={row.id} onClick={() => setIsCreating(true)}
> className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white">
{row.getVisibleCells().map((cell) => ( New Discount
<td className="px-4 py-2" key={cell.id}> </button>
{flexRender(cell.column.columnDef.cell, cell.getContext())} </>
</td> );
))}
</tr>
))}
</tbody>
</table>
<button
onClick={() => setIsCreating(true)}
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white"
>
New Discount
</button>
</>
);
} }

View File

@@ -72,7 +72,7 @@ export default function ExamList({user}: {user: User}) {
}; };
const getTotalExercises = (exam: Exam) => { const getTotalExercises = (exam: Exam) => {
if (exam.module === "reading" || exam.module === "listening") { if (exam.module === "reading" || exam.module === "listening" || exam.module === "level") {
return countExercises(exam.parts.flatMap((x) => x.exercises)); return countExercises(exam.parts.flatMap((x) => x.exercises));
} }

View File

@@ -86,7 +86,7 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined); const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined);
const filteredUsers = emailUsers.filter( const filteredUsers = emailUsers.filter(
(x) => (x) =>
((user.type === "developer" || user.type === "admin" || user.type === "corporate") && ((user.type === "developer" || user.type === "admin" || user.type === "corporate" || user.type === "mastercorporate") &&
(x?.type === "student" || x?.type === "teacher")) || (x?.type === "student" || x?.type === "teacher")) ||
(user.type === "teacher" && x?.type === "student"), (user.type === "teacher" && x?.type === "student"),
); );
@@ -189,7 +189,7 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
); );
}; };
const filterTypes = ["corporate", "teacher"]; const filterTypes = ["corporate", "teacher", "mastercorporate"];
export default function GroupList({user}: {user: User}) { export default function GroupList({user}: {user: User}) {
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
@@ -197,10 +197,10 @@ export default function GroupList({user}: {user: User}) {
const [filterByUser, setFilterByUser] = useState(false); const [filterByUser, setFilterByUser] = useState(false);
const {users} = useUsers(); const {users} = useUsers();
const {groups, reload} = useGroups(user && filterTypes.includes(user?.type) ? user.id : undefined); const {groups, reload} = useGroups(user && filterTypes.includes(user?.type) ? user.id : undefined, user?.type);
useEffect(() => { useEffect(() => {
if (user && (user.type === "corporate" || user.type === "teacher")) { if (user && (['corporate', 'teacher', 'mastercorporate'].includes(user.type))) {
setFilterByUser(true); setFilterByUser(true);
} }
}, [user]); }, [user]);

View File

@@ -40,7 +40,7 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
const [unit, setUnit] = useState<DurationUnit>(pack?.duration_unit || "months"); const [unit, setUnit] = useState<DurationUnit>(pack?.duration_unit || "months");
const [price, setPrice] = useState(pack?.price || 0); const [price, setPrice] = useState(pack?.price || 0);
const [currency, setCurrency] = useState<string>(pack?.currency || "EUR"); const [currency, setCurrency] = useState<string>(pack?.currency || "OMR");
const submit = () => { const submit = () => {
(pack ? axios.patch : axios.post)(pack ? `/api/packages/${pack.id}` : "/api/packages", { (pack ? axios.patch : axios.post)(pack ? `/api/packages/${pack.id}` : "/api/packages", {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,18 @@
import FillBlanksEdit from "@/components/Generation/fill.blanks.edit";
import MultipleChoiceEdit from "@/components/Generation/multiple.choice.edit";
import WriteBlankEdits from "@/components/Generation/write.blanks.edit";
import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import {Difficulty, LevelExam, MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam"; import {
Difficulty,
LevelExam,
MultipleChoiceExercise,
MultipleChoiceQuestion,
LevelPart,
FillBlanksExercise,
WriteBlanksExercise,
Exercise,
} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams"; import {getExamById} from "@/utils/exams";
import {playSound} from "@/utils/sound"; import {playSound} from "@/utils/sound";
@@ -8,26 +21,48 @@ import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize, sample} from "lodash"; import {capitalize, sample} from "lodash";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {useState} from "react"; import {useEffect, useState} from "react";
import {BsArrowRepeat, BsCheck, BsPencilSquare, BsX} from "react-icons/bs"; import {BsArrowRepeat, BsCheck, BsPencilSquare, BsX} from "react-icons/bs";
import reactStringReplace from "react-string-replace";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import {v4} from "uuid"; import {v4} from "uuid";
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"]; const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
const TYPES: {[key: string]: string} = {
multiple_choice_4: "Multiple Choice",
multiple_choice_blank_space: "Multiple Choice - Blank Space",
multiple_choice_underlined: "Multiple Choice - Underlined",
blank_space_text: "Blank Space",
reading_passage_utas: "Reading Passage",
};
type LevelSection = {type: string; quantity: number; topic?: string; part?: LevelPart};
const QuestionDisplay = ({question, onUpdate}: {question: MultipleChoiceQuestion; onUpdate: (question: MultipleChoiceQuestion) => void}) => { const QuestionDisplay = ({question, onUpdate}: {question: MultipleChoiceQuestion; onUpdate: (question: MultipleChoiceQuestion) => void}) => {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [options, setOptions] = useState(question.options); const [options, setOptions] = useState(question.options);
const [answer, setAnswer] = useState(question.solution);
const renderPrompt = (prompt: string) => {
return reactStringReplace(prompt, /((<u>)[\w\s']+(<\/u>))/g, (match) => {
const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
return word.length > 0 ? <u>{word}</u> : null;
});
};
return ( return (
<div key={question.id} className="flex flex-col gap-1"> <div key={question.id} className="flex flex-col gap-1">
<span className="font-semibold"> <span className="font-semibold">
{question.id}. {question.prompt}{" "} <>
{question.id}. <span>{renderPrompt(question.prompt).filter((x) => x?.toString() !== "<u>")} </span>
</>
</span> </span>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{question.options.map((option, index) => ( {question.options.map((option, index) => (
<span key={option.id} className={clsx(question.solution === option.id && "font-bold")}> <span key={option.id} className={clsx(answer === option.id && "font-bold")}>
<span className={clsx("font-semibold", question.solution === option.id ? "text-mti-green-light" : "text-ielts-level")}> <span
className={clsx("font-semibold", answer === option.id ? "text-mti-green-light" : "text-ielts-level")}
onClick={() => setAnswer(option.id)}>
({option.id}) ({option.id})
</span>{" "} </span>{" "}
{isEditing ? ( {isEditing ? (
@@ -54,7 +89,7 @@ const QuestionDisplay = ({question, onUpdate}: {question: MultipleChoiceQuestion
<> <>
<button <button
onClick={() => { onClick={() => {
onUpdate({...question, options}); onUpdate({...question, options, solution: answer});
setIsEditing(false); setIsEditing(false);
}} }}
className="p-2 border border-neutral-300 bg-white rounded-xl hover:drop-shadow transition ease-in-out duration-300"> className="p-2 border border-neutral-300 bg-white rounded-xl hover:drop-shadow transition ease-in-out duration-300">
@@ -72,61 +107,112 @@ const QuestionDisplay = ({question, onUpdate}: {question: MultipleChoiceQuestion
); );
}; };
const TaskTab = ({exam, difficulty, setExam}: {exam?: LevelExam; difficulty: Difficulty; setExam: (exam: LevelExam) => void}) => { const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (section: LevelSection) => void}) => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const generate = () => {
const url = new URLSearchParams();
url.append("difficulty", difficulty);
setIsLoading(true);
axios
.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.");
setExam(result.data);
})
.catch((error) => {
console.log(error);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
const onUpdate = (question: MultipleChoiceQuestion) => { const onUpdate = (question: MultipleChoiceQuestion) => {
if (!exam) return; if (!section) return;
const updatedExam = { const updatedExam = {
...exam, ...section,
exercises: exam.exercises.map((x) => ({ exercises: section.part?.exercises.map((x) => ({
...x, ...x,
questions: (x as MultipleChoiceExercise).questions.map((q) => (q.id === question.id ? question : q)), questions: (x as MultipleChoiceExercise).questions.map((q) => (q.id === question.id ? question : q)),
})), })),
}; };
console.log(updatedExam); setSection(updatedExam as any);
setExam(updatedExam as any); };
const renderExercise = (exercise: Exercise) => {
if (exercise.type === "multipleChoice")
return (
<div key={exercise.id} className="w-full h-full flex flex-col gap-2">
<div className="flex gap-2">
<span className="text-xl font-semibold">Multiple Choice</span>
<span className="rounded-xl bg-white border border-ielts-level p-1 px-4 w-fit">{exercise.questions.length} questions</span>
</div>
<MultipleChoiceEdit
exercise={exercise}
key={exercise.id}
updateExercise={(data: any) =>
setSection({
...section,
part: {...section.part!, exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x))},
})
}
/>
</div>
);
if (exercise.type === "fillBlanks")
return (
<div key={exercise.id} className="w-full h-full flex flex-col gap-2">
<div className="flex gap-2">
<span className="text-xl font-semibold">Fill Blanks</span>
</div>
<span>{exercise.prompt}</span>
<FillBlanksEdit
exercise={exercise}
key={exercise.id}
updateExercise={(data: any) =>
setSection({
...section,
part: {...section.part!, exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x))},
})
}
/>
</div>
);
if (exercise.type === "writeBlanks")
return (
<div key={exercise.id} className="w-full h-full flex flex-col gap-2">
<div className="flex gap-2">
<span className="text-xl font-semibold">Write Blanks</span>
</div>
<span>{exercise.prompt}</span>
<WriteBlankEdits
exercise={exercise}
key={exercise.id}
updateExercise={(data: any) =>
setSection({
...section,
part: {...section.part!, exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x))},
})
}
/>
</div>
);
}; };
return ( return (
<Tab.Panel className="w-full bg-ielts-level/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4"> <Tab.Panel className="w-full bg-ielts-level/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
<div className="flex gap-4 items-end"> <div className="flex flex-col gap-4">
<button <div className="flex gap-4 w-full">
onClick={generate} <div className="flex flex-col gap-3 w-full">
disabled={isLoading} <label className="font-normal text-base text-mti-gray-dim">Exercise Type</label>
className={clsx( <Select
"bg-ielts-level/70 border border-ielts-level text-white w-full px-6 py-6 rounded-xl h-[70px]", options={Object.keys(TYPES).map((key) => ({value: key, label: TYPES[key]}))}
"hover:bg-ielts-level disabled:bg-ielts-level/40 disabled:cursor-not-allowed", onChange={(e) => setSection({...section, type: e!.value!})}
"transition ease-in-out duration-300", value={{value: section?.type || "multiple_choice_4", label: TYPES[section?.type || "multiple_choice_4"]}}
)}> />
{isLoading ? ( </div>
<div className="flex items-center justify-center"> <div className="flex flex-col gap-3 w-full">
<BsArrowRepeat className="text-white animate-spin" size={25} /> <label className="font-normal text-base text-mti-gray-dim">Number of Questions</label>
</div> <Input
) : ( type="number"
"Generate" name="Number of Questions"
)} onChange={(v) => setSection({...section, quantity: parseInt(v)})}
</button> value={section?.quantity || 10}
/>
</div>
</div>
{section?.type === "reading_passage_utas" && (
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Topic</label>
<Input type="text" name="Topic" onChange={(v) => setSection({...section, topic: v})} value={section?.topic} />
</div>
)}
</div> </div>
{isLoading && ( {isLoading && (
<div className="w-fit h-fit mt-12 self-center animate-pulse flex flex-col gap-8 items-center"> <div className="w-fit h-fit mt-12 self-center animate-pulse flex flex-col gap-8 items-center">
@@ -134,29 +220,10 @@ const TaskTab = ({exam, difficulty, setExam}: {exam?: LevelExam; difficulty: Dif
<span className={clsx("font-bold text-2xl text-ielts-level")}>Generating...</span> <span className={clsx("font-bold text-2xl text-ielts-level")}>Generating...</span>
</div> </div>
)} )}
{exam && ( {section?.part && (
<div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide h-full"> <div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide h-full">
{exam.exercises {section.part.context && <div>{section.part.context}</div>}
.filter((x) => x.type === "multipleChoice") {section.part.exercises.map(renderExercise)}
.map((ex) => {
const exercise = ex as MultipleChoiceExercise;
return (
<div key={ex.id} className="w-full h-full flex flex-col gap-2">
<div className="flex gap-2">
<span className="text-xl font-semibold">Multiple Choice</span>
<span className="rounded-xl bg-white border border-ielts-level p-1 px-4 w-fit">
{exercise.questions.length} questions
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{exercise.questions.map((question) => (
<QuestionDisplay question={question} onUpdate={onUpdate} key={question.id} />
))}
</div>
</div>
);
})}
</div> </div>
)} )}
</Tab.Panel> </Tab.Panel>
@@ -167,7 +234,14 @@ const LevelGeneration = () => {
const [generatedExam, setGeneratedExam] = useState<LevelExam>(); const [generatedExam, setGeneratedExam] = useState<LevelExam>();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<LevelExam>(); const [resultingExam, setResultingExam] = useState<LevelExam>();
const [timer, setTimer] = useState(10);
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!); const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
const [numberOfParts, setNumberOfParts] = useState(1);
const [parts, setParts] = useState<LevelSection[]>([{quantity: 10, type: "multiple_choice_4"}]);
useEffect(() => {
setParts((prev) => Array.from(Array(numberOfParts)).map((_, i) => (!!prev.at(i) ? prev.at(i)! : {quantity: 10, type: "multiple_choice_4"})));
}, [numberOfParts]);
const router = useRouter(); const router = useRouter();
@@ -190,6 +264,156 @@ const LevelGeneration = () => {
router.push("/exercises"); router.push("/exercises");
}; };
const generateExam = () => {
if (parts.length === 0) return;
setIsLoading(true);
let body: any = {};
parts.forEach((part, index) => {
body[`exercise_${index + 1}_type`] = part.type;
body[`exercise_${index + 1}_qty`] = part.quantity;
if (part.topic) body[`exercise_${index + 1}_topic`] = part.topic;
if (part.type === "reading_passage_utas") {
body[`exercise_${index + 1}_sa_qty`] = Math.floor(part.quantity / 2);
body[`exercise_${index + 1}_mc_qty`] = Math.ceil(part.quantity / 2);
}
});
let newParts = [...parts];
axios
.post<{exercises: {[key: string]: any}}>("/api/exam/level/generate/level", {nr_exercises: numberOfParts, ...body})
.then((result) => {
console.log(result.data);
playSound(typeof result.data === "string" ? "error" : "check");
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
const exam: LevelExam = {
id: v4(),
minTimer: timer,
module: "level",
difficulty,
variant: "full",
isDiagnostic: true,
parts: parts
.map((part, index) => {
const currentExercise = result.data.exercises[`exercise_${index + 1}`] as any;
if (
part.type === "multiple_choice_4" ||
part.type === "multiple_choice_blank_space" ||
part.type === "multiple_choice_underlined"
) {
const exercise: MultipleChoiceExercise = {
id: v4(),
prompt:
part.type === "multiple_choice_underlined"
? "Select the wrong part of the sentence."
: "Select the appropriate option.",
questions: currentExercise.questions.map((x: any) => ({...x, variant: "text"})),
type: "multipleChoice",
userSolutions: [],
};
const item = {
exercises: [exercise],
};
newParts = newParts.map((p, i) =>
i === index
? {
...p,
part: item,
}
: p,
);
return item;
}
if (part.type === "blank_space_text") {
console.log({currentExercise});
const exercise: WriteBlanksExercise = {
id: v4(),
prompt: "Complete the text below.",
text: currentExercise.text,
maxWords: 3,
solutions: currentExercise.words.map((x: any) => ({id: x.id, solution: [x.text]})),
type: "writeBlanks",
userSolutions: [],
};
const item = {
exercises: [exercise],
};
newParts = newParts.map((p, i) =>
i === index
? {
...p,
part: item,
}
: p,
);
return item;
}
const mcExercise: MultipleChoiceExercise = {
id: v4(),
prompt: "Select the appropriate option.",
questions: currentExercise.exercises.multipleChoice.questions.map((x: any) => ({...x, variant: "text"})),
type: "multipleChoice",
userSolutions: [],
};
const wbExercise: WriteBlanksExercise = {
id: v4(),
prompt: "Complete the notes below.",
maxWords: 3,
text: currentExercise.exercises.shortAnswer.map((x: any) => `${x.question} {{${x.id}}}`).join("\n"),
solutions: currentExercise.exercises.shortAnswer.map((x: any) => ({
id: x.id,
solution: x.possible_answers,
})),
type: "writeBlanks",
userSolutions: [],
};
const item = {
context: currentExercise.text.content,
exercises: [mcExercise, wbExercise],
};
newParts = newParts.map((p, i) =>
i === index
? {
...p,
part: item,
}
: p,
);
return item;
})
.filter((x) => !!x) as LevelPart[],
};
setParts(newParts);
setGeneratedExam(exam);
})
.catch((error) => {
console.log(error);
playSound("error");
toast.error("Something went wrong, please try again later!");
})
.finally(() => setIsLoading(false));
};
const submitExam = () => { const submitExam = () => {
if (!generatedExam) { if (!generatedExam) {
toast.error("Please generate all tasks before submitting"); toast.error("Please generate all tasks before submitting");
@@ -197,12 +421,10 @@ const LevelGeneration = () => {
} }
setIsLoading(true); setIsLoading(true);
const exam: LevelExam = {
const exam = {
...generatedExam, ...generatedExam,
isDiagnostic: false, parts: generatedExam.parts.map((p, i) => ({...p, exercises: parts[i].part!.exercises})),
minTimer: 25,
module: "level",
id: v4(),
}; };
axios axios
@@ -224,32 +446,55 @@ const LevelGeneration = () => {
return ( return (
<> <>
<div className="flex gap-4 w-1/2"> <div className="flex gap-4 w-full">
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label> <label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
<Select <Select
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))} options={DIFFICULTIES.map((x) => ({
value: x,
label: capitalize(x),
}))}
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)} onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
value={{value: difficulty, label: capitalize(difficulty)}} value={{value: difficulty, label: capitalize(difficulty)}}
/> />
</div> </div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Number of Parts</label>
<Input type="number" name="Number of Parts" onChange={(v) => setNumberOfParts(parseInt(v))} value={numberOfParts} />
</div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Timer (in minutes)</label>
<Input type="number" name="Timer (in minutes)" onChange={(v) => setTimer(parseInt(v))} value={timer} />
</div>
</div> </div>
<Tab.Group> <Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1"> <Tab.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1">
<Tab {Array.from(Array(numberOfParts), (_, index) => index).map((index) => (
className={({selected}) => <Tab
clsx( key={index}
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-level/70", className={({selected}) =>
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-level focus:outline-none focus:ring-2", clsx(
"transition duration-300 ease-in-out", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-level/70",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-level", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-level focus:outline-none focus:ring-2",
) "transition duration-300 ease-in-out",
}> selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-level",
Exam )
</Tab> }>
Part {index + 1}
</Tab>
))}
</Tab.List> </Tab.List>
<Tab.Panels> <Tab.Panels>
<TaskTab difficulty={difficulty} exam={generatedExam} setExam={setGeneratedExam} /> {Array.from(Array(numberOfParts), (_, index) => index).map((index) => (
<TaskTab
key={index}
section={parts[index]}
setSection={(part) => {
console.log(part);
setParts((prev) => prev.map((x, i) => (i === index ? part : x)));
}}
/>
))}
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
<div className="w-full flex justify-end gap-4"> <div className="w-full flex justify-end gap-4">
@@ -265,6 +510,24 @@ const LevelGeneration = () => {
Perform Exam Perform Exam
</button> </button>
)} )}
<button
disabled={parts.length === 0 || isLoading}
data-tip="Please generate all three passages"
onClick={generateExam}
className={clsx(
"bg-ielts-level/70 border border-ielts-level text-white w-full max-w-[200px] rounded-xl h-[70px] self-end",
"hover:bg-ielts-level disabled:bg-ielts-level/40 disabled:cursor-not-allowed",
"transition ease-in-out duration-300",
parts.length === 0 && "tooltip",
)}>
{isLoading ? (
<div className="flex items-center justify-center">
<BsArrowRepeat className="text-white animate-spin" size={25} />
</div>
) : (
"Generate"
)}
</button>
<button <button
disabled={!generatedExam || isLoading} disabled={!generatedExam || isLoading}
data-tip="Please generate all three passages" data-tip="Please generate all three passages"

View File

@@ -1,3 +1,4 @@
import MultipleChoiceEdit from "@/components/Generation/multiple.choice.edit";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import {Difficulty, Exercise, ListeningExam} from "@/interfaces/exam"; import {Difficulty, Exercise, ListeningExam} from "@/interfaces/exam";
@@ -10,27 +11,50 @@ import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize, sample} from "lodash"; import {capitalize, sample} from "lodash";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {useEffect, useState} from "react"; import {useEffect, useState, Dispatch, SetStateAction} from "react";
import {BsArrowRepeat, BsCheck} from "react-icons/bs"; import {BsArrowRepeat, BsCheck} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import WriteBlanksEdit from "@/components/Generation/write.blanks.edit";
import {generate} from "random-words";
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"]; const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
const MULTIPLE_CHOICE = {type: "multipleChoice", label: "Multiple Choice"};
const WRITE_BLANKS_QUESTIONS = {
type: "writeBlanksQuestions",
label: "Write the Blanks: Questions",
};
const WRITE_BLANKS_FILL = {
type: "writeBlanksFill",
label: "Write the Blanks: Fill",
};
const WRITE_BLANKS_FORM = {
type: "writeBlanksForm",
label: "Write the Blanks: Form",
};
const MULTIPLE_CHOICE_3 = {
type: "multipleChoice3Options",
label: "Multiple Choice",
};
const PartTab = ({ const PartTab = ({
part, part,
types,
difficulty, difficulty,
availableTypes,
index, index,
setPart, setPart,
updatePart,
}: { }: {
part?: ListeningPart; part?: ListeningPart;
difficulty: Difficulty; difficulty: Difficulty;
types: string[]; availableTypes: {type: string; label: string}[];
index: number; index: number;
setPart: (part?: ListeningPart) => void; setPart: (part?: ListeningPart) => void;
updatePart: Dispatch<SetStateAction<ListeningPart | undefined>>;
}) => { }) => {
const [topic, setTopic] = useState(""); const [topic, setTopic] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [types, setTypes] = useState<string[]>([]);
const generate = () => { const generate = () => {
const url = new URLSearchParams(); const url = new URLSearchParams();
@@ -55,8 +79,85 @@ const PartTab = ({
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
const renderExercises = () => {
return part?.exercises.map((exercise) => {
switch (exercise.type) {
case "multipleChoice":
return (
<>
<h1>Exercise: Multiple Choice</h1>
<MultipleChoiceEdit
exercise={exercise}
key={exercise.id}
updateExercise={(data: any) =>
updatePart((part?: ListeningPart) => {
if (part) {
const exercises = part.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x)) as Exercise[];
const updatedPart = {
...part,
exercises,
} as ListeningPart;
return updatedPart;
}
return part;
})
}
/>
</>
);
// TODO: This might be broken as they all returns the same
case "writeBlanks":
return (
<>
<h1>Exercise: Write Blanks</h1>
<WriteBlanksEdit
exercise={exercise}
key={exercise.id}
updateExercise={(data: any) => {
updatePart((part?: ListeningPart) => {
if (part) {
return {
...part,
exercises: part.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x)),
} as ListeningPart;
}
return part;
});
}}
/>
</>
);
default:
return null;
}
});
};
const toggleType = (type: string) => setTypes((prev) => (prev.includes(type) ? [...prev.filter((x) => x !== type)] : [...prev, type]));
return ( return (
<Tab.Panel className="w-full bg-ielts-listening/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4"> <Tab.Panel className="w-full bg-ielts-listening/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Exercises</label>
<div className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
{availableTypes.map((x) => (
<span
onClick={() => toggleType(x.type)}
key={x.type}
className={clsx(
"px-6 py-4 w-64 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!types.includes(x.type)
? "bg-white border-mti-gray-platinum"
: "bg-ielts-listening/70 border-ielts-listening text-white",
)}>
{x.label}
</span>
))}
</div>
</div>
<div className="flex gap-4 items-end"> <div className="flex gap-4 items-end">
<Input type="text" placeholder="Grand Canyon..." name="topic" label="Topic" onChange={setTopic} roundness="xl" defaultValue={topic} /> <Input type="text" placeholder="Grand Canyon..." name="topic" label="Topic" onChange={setTopic} roundness="xl" defaultValue={topic} />
<button <button
@@ -85,26 +186,29 @@ const PartTab = ({
</div> </div>
)} )}
{part && ( {part && (
<div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide"> <>
<div className="flex gap-4"> <div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide">
{part.exercises.map((x) => ( <div className="flex gap-4">
<span className="rounded-xl bg-white border border-ielts-listening p-1 px-4" key={x.id}> {part.exercises.map((x) => (
{x.type && convertCamelCaseToReadable(x.type)} <span className="rounded-xl bg-white border border-ielts-listening p-1 px-4" key={x.id}>
</span> {x.type && convertCamelCaseToReadable(x.type)}
))}
</div>
{typeof part.text === "string" && <span className="w-full h-96">{part.text.replaceAll("\n\n", " ")}</span>}
{typeof part.text !== "string" && (
<div className="w-full h-96 flex flex-col gap-2">
{part.text.conversation.map((x, index) => (
<span key={index} className="flex gap-1">
<span className="font-semibold">{x.name}:</span>
{x.text.replaceAll("\n\n", " ")}
</span> </span>
))} ))}
</div> </div>
)} {typeof part.text === "string" && <span className="w-full h-96">{part.text.replaceAll("\n\n", " ")}</span>}
</div> {typeof part.text !== "string" && (
<div className="w-full h-96 flex flex-col gap-2">
{part.text.conversation.map((x, index) => (
<span key={index} className="flex gap-1">
<span className="font-semibold">{x.name}:</span>
{x.text.replaceAll("\n\n", " ")}
</span>
))}
</div>
)}
</div>
{renderExercises()}
</>
)} )}
</Tab.Panel> </Tab.Panel>
); );
@@ -132,7 +236,6 @@ const ListeningGeneration = () => {
const [minTimer, setMinTimer] = useState(30); const [minTimer, setMinTimer] = useState(30);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<ListeningExam>(); const [resultingExam, setResultingExam] = useState<ListeningExam>();
const [types, setTypes] = useState<string[]>([]);
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!); const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
useEffect(() => { useEffect(() => {
@@ -145,20 +248,11 @@ const ListeningGeneration = () => {
setMinTimer(sum > 0 ? sum : 5); setMinTimer(sum > 0 ? sum : 5);
}, [part1, part2, part3, part4]); }, [part1, part2, part3, part4]);
const availableTypes = [
{type: "multipleChoice", label: "Multiple Choice"},
{type: "writeBlanksQuestions", label: "Write the Blanks: Questions"},
{type: "writeBlanksFill", label: "Write the Blanks: Fill"},
{type: "writeBlanksForm", label: "Write the Blanks: Form"},
];
const router = useRouter(); const router = useRouter();
const setExams = useExamStore((state) => state.setExams); const setExams = useExamStore((state) => state.setExams);
const setSelectedModules = useExamStore((state) => state.setSelectedModules); const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const toggleType = (type: string) => setTypes((prev) => (prev.includes(type) ? [...prev.filter((x) => x !== type)] : [...prev, type]));
const submitExam = () => { const submitExam = () => {
const parts = [part1, part2, part3, part4].filter((x) => !!x); const parts = [part1, part2, part3, part4].filter((x) => !!x);
console.log({parts}); console.log({parts});
@@ -167,11 +261,16 @@ const ListeningGeneration = () => {
setIsLoading(true); setIsLoading(true);
axios axios
.post(`/api/exam/listening/generate/listening`, {parts, minTimer, difficulty}) .post(`/api/exam/listening/generate/listening`, {
id: generate({minLength: 4, maxLength: 8, min: 3, max: 5, join: " ", formatter: capitalize}),
parts,
minTimer,
difficulty,
})
.then((result) => { .then((result) => {
playSound("sent"); playSound("sent");
console.log(`Generated Exam ID: ${result.data.id}`); console.log(`Generated Exam ID: ${result.data.id}`);
toast.success("This new exam has been generated successfully! Check the ID in our browser's console."); toast.success(`Generated Exam ID: ${result.data.id}`);
setResultingExam(result.data); setResultingExam(result.data);
setPart1(undefined); setPart1(undefined);
@@ -179,7 +278,6 @@ const ListeningGeneration = () => {
setPart3(undefined); setPart3(undefined);
setPart4(undefined); setPart4(undefined);
setDifficulty(sample(DIFFICULTIES)!); setDifficulty(sample(DIFFICULTIES)!);
setTypes([]);
}) })
.catch((error) => { .catch((error) => {
console.log(error); console.log(error);
@@ -220,34 +318,16 @@ const ListeningGeneration = () => {
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label> <label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
<Select <Select
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))} options={DIFFICULTIES.map((x) => ({
value: x,
label: capitalize(x),
}))}
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)} onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
value={{value: difficulty, label: capitalize(difficulty)}} value={{value: difficulty, label: capitalize(difficulty)}}
disabled={!!part1 || !!part2 || !!part3 || !!part4} disabled={!!part1 || !!part2 || !!part3 || !!part4}
/> />
</div> </div>
</div> </div>
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Exercises</label>
<div className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
{availableTypes.map((x) => (
<span
onClick={() => toggleType(x.type)}
key={x.type}
className={clsx(
"px-6 py-4 w-64 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!types.includes(x.type)
? "bg-white border-mti-gray-platinum"
: "bg-ielts-listening/70 border-ielts-listening text-white",
)}>
{x.label}
</span>
))}
</div>
</div>
<Tab.Group> <Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-listening/20 p-1"> <Tab.List className="flex space-x-1 rounded-xl bg-ielts-listening/20 p-1">
<Tab <Tab
@@ -297,12 +377,36 @@ const ListeningGeneration = () => {
</Tab.List> </Tab.List>
<Tab.Panels> <Tab.Panels>
{[ {[
{part: part1, setPart: setPart1}, {
{part: part2, setPart: setPart2}, part: part1,
{part: part3, setPart: setPart3}, setPart: setPart1,
{part: part4, setPart: setPart4}, types: [MULTIPLE_CHOICE, WRITE_BLANKS_QUESTIONS, WRITE_BLANKS_FILL, WRITE_BLANKS_FORM],
].map(({part, setPart}, index) => ( },
<PartTab part={part} difficulty={difficulty} types={types} index={index + 1} key={index} setPart={setPart} /> {
part: part2,
setPart: setPart2,
types: [MULTIPLE_CHOICE, WRITE_BLANKS_QUESTIONS],
},
{
part: part3,
setPart: setPart3,
types: [MULTIPLE_CHOICE_3, WRITE_BLANKS_QUESTIONS],
},
{
part: part4,
setPart: setPart4,
types: [MULTIPLE_CHOICE, WRITE_BLANKS_QUESTIONS, WRITE_BLANKS_FILL, WRITE_BLANKS_FORM],
},
].map(({part, setPart, types}, index) => (
<PartTab
part={part}
difficulty={difficulty}
availableTypes={types}
index={index + 1}
key={index}
setPart={setPart}
updatePart={setPart}
/>
))} ))}
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>

View File

@@ -1,37 +1,53 @@
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import {Difficulty, ReadingExam, ReadingPart} from "@/interfaces/exam"; import {Difficulty, Exercise, ReadingExam, ReadingPart} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams"; import {getExamById} from "@/utils/exams";
import {playSound} from "@/utils/sound"; import {playSound} from "@/utils/sound";
import {convertCamelCaseToReadable} from "@/utils/string"; import {convertCamelCaseToReadable} from "@/utils/string";
import {generate} from "random-words";
import {Tab} from "@headlessui/react"; import {Tab} from "@headlessui/react";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize, sample} from "lodash"; import {capitalize, sample} from "lodash";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {useEffect, useState} from "react"; import {useEffect, useState, Dispatch, SetStateAction} from "react";
import {BsArrowRepeat, BsCheck} from "react-icons/bs"; import {BsArrowRepeat, BsCheck} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import {v4} from "uuid"; import {v4} from "uuid";
import FillBlanksEdit from "@/components/Generation/fill.blanks.edit";
import TrueFalseEdit from "@/components/Generation/true.false.edit";
import WriteBlanksEdit from "@/components/Generation/write.blanks.edit";
import MatchSentencesEdit from "@/components/Generation/match.sentences.edit";
import MultipleChoiceEdit from "@/components/Generation/multiple.choice.edit";
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"]; const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
const availableTypes = [
{type: "fillBlanks", label: "Fill the Blanks"},
{type: "trueFalse", label: "True or False"},
{type: "writeBlanks", label: "Write the Blanks"},
{type: "paragraphMatch", label: "Match Sentences"},
];
const PartTab = ({ const PartTab = ({
part, part,
types,
difficulty, difficulty,
index, index,
setPart, setPart,
updatePart,
}: { }: {
part?: ReadingPart; part?: ReadingPart;
types: string[];
index: number; index: number;
difficulty: Difficulty; difficulty: Difficulty;
setPart: (part?: ReadingPart) => void; setPart: (part?: ReadingPart) => void;
updatePart: Dispatch<SetStateAction<ReadingPart | undefined>>;
}) => { }) => {
const [topic, setTopic] = useState(""); const [topic, setTopic] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [types, setTypes] = useState<string[]>([]);
const toggleType = (type: string) => setTypes((prev) => (prev.includes(type) ? [...prev.filter((x) => x !== type)] : [...prev, type]));
const generate = () => { const generate = () => {
const url = new URLSearchParams(); const url = new URLSearchParams();
@@ -45,6 +61,8 @@ const PartTab = ({
axios axios
.get(`/api/exam/reading/generate/reading_passage_${index}${topic || types ? `?${url.toString()}` : ""}`) .get(`/api/exam/reading/generate/reading_passage_${index}${topic || types ? `?${url.toString()}` : ""}`)
.then((result) => { .then((result) => {
console.log(result.data);
playSound(typeof result.data === "string" ? "error" : "check"); playSound(typeof result.data === "string" ? "error" : "check");
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again."); if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
setPart(result.data); setPart(result.data);
@@ -56,8 +74,143 @@ const PartTab = ({
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
const renderExercises = () => {
return part?.exercises.map((exercise) => {
switch (exercise.type) {
case "fillBlanks":
return (
<>
<h1>Exercise: Fill Blanks</h1>
<FillBlanksEdit
exercise={exercise}
key={exercise.id}
updateExercise={(data: any) =>
updatePart((part?: ReadingPart) => {
if (part) {
const exercises = part.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x)) as Exercise[];
const updatedPart = {...part, exercises} as ReadingPart;
return updatedPart;
}
return part;
})
}
/>
</>
);
case "trueFalse":
return (
<>
<h1>Exercise: True or False</h1>
<TrueFalseEdit
exercise={exercise}
key={exercise.id}
updateExercise={(data: any) => {
updatePart((part?: ReadingPart) => {
if (part) {
return {
...part,
exercises: part.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x)),
} as ReadingPart;
}
return part;
});
}}
/>
</>
);
case "multipleChoice":
return (
<>
<h1>Exercise: True or False</h1>
<MultipleChoiceEdit
exercise={exercise}
key={exercise.id}
updateExercise={(data: any) => {
updatePart((part?: ReadingPart) => {
if (part) {
return {
...part,
exercises: part.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x)),
} as ReadingPart;
}
return part;
});
}}
/>
</>
);
case "writeBlanks":
return (
<>
<h1>Exercise: Write Blanks</h1>
<WriteBlanksEdit
exercise={exercise}
key={exercise.id}
updateExercise={(data: any) => {
updatePart((part?: ReadingPart) => {
if (part) {
return {
...part,
exercises: part.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x)),
} as ReadingPart;
}
return part;
});
}}
/>
</>
);
case "matchSentences":
return (
<>
<h1>Exercise: Match Sentences</h1>
<MatchSentencesEdit
exercise={exercise}
key={exercise.id}
updateExercise={(data: any) => {
updatePart((part?: ReadingPart) => {
if (part) {
return {
...part,
exercises: part.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x)),
} as ReadingPart;
}
return part;
});
}}
/>
</>
);
default:
return null;
}
});
};
return ( return (
<Tab.Panel className="w-full bg-ielts-reading/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4"> <Tab.Panel className="w-full bg-ielts-reading/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Exercises</label>
<div className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
{[...availableTypes, ...(index === 3 ? [{type: "ideaMatch", label: "Idea Match"}] : [])].map((x) => (
<span
onClick={() => toggleType(x.type)}
key={x.type}
className={clsx(
"px-6 py-4 w-64 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!types.includes(x.type) ? "bg-white border-mti-gray-platinum" : "bg-ielts-reading/70 border-ielts-reading text-white",
)}>
{x.label}
</span>
))}
</div>
</div>
<div className="flex gap-4 items-end"> <div className="flex gap-4 items-end">
<Input type="text" placeholder="Grand Canyon..." name="topic" label="Topic" onChange={setTopic} roundness="xl" defaultValue={topic} /> <Input type="text" placeholder="Grand Canyon..." name="topic" label="Topic" onChange={setTopic} roundness="xl" defaultValue={topic} />
<button <button
@@ -86,17 +239,20 @@ const PartTab = ({
</div> </div>
)} )}
{part && ( {part && (
<div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide"> <>
<div className="flex gap-4"> <div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide">
{part.exercises.map((x) => ( <div className="flex gap-4">
<span className="rounded-xl bg-white border border-ielts-reading p-1 px-4" key={x.id}> {part.exercises.map((x) => (
{x.type && convertCamelCaseToReadable(x.type)} <span className="rounded-xl bg-white border border-ielts-reading p-1 px-4" key={x.id}>
</span> {x.type && convertCamelCaseToReadable(x.type)}
))} </span>
))}
</div>
<h3 className="text-xl font-semibold">{part.text.title}</h3>
<span className="w-full h-96">{part.text.content}</span>
</div> </div>
<h3 className="text-xl font-semibold">{part.text.title}</h3> {renderExercises()}
<span className="w-full h-96">{part.text.content}</span> </>
</div>
)} )}
</Tab.Panel> </Tab.Panel>
); );
@@ -107,7 +263,6 @@ const ReadingGeneration = () => {
const [part2, setPart2] = useState<ReadingPart>(); const [part2, setPart2] = useState<ReadingPart>();
const [part3, setPart3] = useState<ReadingPart>(); const [part3, setPart3] = useState<ReadingPart>();
const [minTimer, setMinTimer] = useState(60); const [minTimer, setMinTimer] = useState(60);
const [types, setTypes] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<ReadingExam>(); const [resultingExam, setResultingExam] = useState<ReadingExam>();
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!); const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
@@ -122,15 +277,6 @@ const ReadingGeneration = () => {
const setExams = useExamStore((state) => state.setExams); const setExams = useExamStore((state) => state.setExams);
const setSelectedModules = useExamStore((state) => state.setSelectedModules); const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const availableTypes = [
{type: "fillBlanks", label: "Fill the Blanks"},
{type: "trueFalse", label: "True or False"},
{type: "writeBlanks", label: "Write the Blanks"},
{type: "matchSentences", label: "Match Sentences"},
];
const toggleType = (type: string) => setTypes((prev) => (prev.includes(type) ? [...prev.filter((x) => x !== type)] : [...prev, type]));
const loadExam = async (examId: string) => { const loadExam = async (examId: string) => {
const exam = await getExamById("reading", examId.trim()); const exam = await getExamById("reading", examId.trim());
if (!exam) { if (!exam) {
@@ -160,7 +306,7 @@ const ReadingGeneration = () => {
isDiagnostic: false, isDiagnostic: false,
minTimer, minTimer,
module: "reading", module: "reading",
id: v4(), id: generate({minLength: 4, maxLength: 8, min: 3, max: 5, join: " ", formatter: capitalize}),
type: "academic", type: "academic",
variant: parts.length === 3 ? "full" : "partial", variant: parts.length === 3 ? "full" : "partial",
difficulty, difficulty,
@@ -171,7 +317,7 @@ const ReadingGeneration = () => {
.then((result) => { .then((result) => {
playSound("sent"); playSound("sent");
console.log(`Generated Exam ID: ${result.data.id}`); console.log(`Generated Exam ID: ${result.data.id}`);
toast.success("This new exam has been generated successfully! Check the ID in our browser's console."); toast.success(`Generated Exam ID: ${result.data.id}`);
setResultingExam(result.data); setResultingExam(result.data);
setPart1(undefined); setPart1(undefined);
@@ -179,7 +325,6 @@ const ReadingGeneration = () => {
setPart3(undefined); setPart3(undefined);
setDifficulty(sample(DIFFICULTIES)!); setDifficulty(sample(DIFFICULTIES)!);
setMinTimer(60); setMinTimer(60);
setTypes([]);
}) })
.catch((error) => { .catch((error) => {
console.log(error); console.log(error);
@@ -204,32 +349,16 @@ const ReadingGeneration = () => {
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label> <label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
<Select <Select
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))} options={DIFFICULTIES.map((x) => ({
value: x,
label: capitalize(x),
}))}
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)} onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
value={{value: difficulty, label: capitalize(difficulty)}} value={{value: difficulty, label: capitalize(difficulty)}}
disabled={!!part1 || !!part2 || !!part3} disabled={!!part1 || !!part2 || !!part3}
/> />
</div> </div>
</div> </div>
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Exercises</label>
<div className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
{availableTypes.map((x) => (
<span
onClick={() => toggleType(x.type)}
key={x.type}
className={clsx(
"px-6 py-4 w-64 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!types.includes(x.type) ? "bg-white border-mti-gray-platinum" : "bg-ielts-reading/70 border-ielts-reading text-white",
)}>
{x.label}
</span>
))}
</div>
</div>
<Tab.Group> <Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-reading/20 p-1"> <Tab.List className="flex space-x-1 rounded-xl bg-ielts-reading/20 p-1">
<Tab <Tab
@@ -272,7 +401,7 @@ const ReadingGeneration = () => {
{part: part2, setPart: setPart2}, {part: part2, setPart: setPart2},
{part: part3, setPart: setPart3}, {part: part3, setPart: setPart3},
].map(({part, setPart}, index) => ( ].map(({part, setPart}, index) => (
<PartTab part={part} types={types} difficulty={difficulty} index={index + 1} key={index} setPart={setPart} /> <PartTab part={part} difficulty={difficulty} index={index + 1} key={index} setPart={setPart} updatePart={setPart} />
))} ))}
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>

View File

@@ -12,7 +12,8 @@ import clsx from "clsx";
import {capitalize, sample, uniq} from "lodash"; import {capitalize, sample, uniq} from "lodash";
import moment from "moment"; import moment from "moment";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {useEffect, useState} from "react"; import {generate} from "random-words";
import {useEffect, useState, Dispatch, SetStateAction} from "react";
import {BsArrowRepeat, BsCheck} from "react-icons/bs"; import {BsArrowRepeat, BsCheck} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import {v4} from "uuid"; import {v4} from "uuid";
@@ -24,11 +25,13 @@ const PartTab = ({
index, index,
difficulty, difficulty,
setPart, setPart,
updatePart,
}: { }: {
part?: SpeakingPart; part?: SpeakingPart;
difficulty: Difficulty; difficulty: Difficulty;
index: number; index: number;
setPart: (part?: SpeakingPart) => void; setPart: (part?: SpeakingPart) => void;
updatePart: Dispatch<SetStateAction<SpeakingPart | undefined>>;
}) => { }) => {
const [gender, setGender] = useState<"male" | "female">("male"); const [gender, setGender] = useState<"male" | "female">("male");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -45,6 +48,7 @@ const PartTab = ({
.then((result) => { .then((result) => {
playSound(typeof result.data === "string" ? "error" : "check"); playSound(typeof result.data === "string" ? "error" : "check");
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again."); if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
console.log(result.data);
setPart(result.data); setPart(result.data);
}) })
.catch((error) => { .catch((error) => {
@@ -54,7 +58,7 @@ const PartTab = ({
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
const generateVideo = () => { const generateVideo = async () => {
if (!part) return toast.error("Please generate the first part before generating the video!"); if (!part) return toast.error("Please generate the first part before generating the video!");
toast.info("This will take quite a while, please do not leave this page or close the tab/window."); toast.info("This will take quite a while, please do not leave this page or close the tab/window.");
@@ -64,13 +68,22 @@ const PartTab = ({
const initialTime = moment(); const initialTime = moment();
axios axios
.post(`/api/exam/speaking/generate/speaking/generate_${index === 3 ? "interactive" : "speaking"}_video`, {...part, avatar: avatar?.id}) .post(`/api/exam/speaking/generate/speaking/generate_video_${index}`, {
...part,
avatar: avatar?.id,
})
.then((result) => { .then((result) => {
const isError = typeof result.data === "string" || moment().diff(initialTime, "seconds") < 60; const isError = typeof result.data === "string" || moment().diff(initialTime, "seconds") < 60;
playSound(isError ? "error" : "check"); playSound(isError ? "error" : "check");
console.log(result.data);
if (isError) return toast.error("Something went wrong, please try to generate the video again."); if (isError) return toast.error("Something went wrong, please try to generate the video again.");
setPart({...part, result: {...result.data, topic: part?.topic}, gender, avatar}); setPart({
...part,
result: {...result.data, topic: part?.topic},
gender,
avatar,
});
}) })
.catch((e) => { .catch((e) => {
toast.error("Something went wrong!"); toast.error("Something went wrong!");
@@ -139,7 +152,9 @@ const PartTab = ({
)} )}
{part && !isLoading && ( {part && !isLoading && (
<div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide h-96"> <div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide h-96">
<h3 className="text-xl font-semibold">{part.topic}</h3> <h3 className="text-xl font-semibold">
{!!part.first_topic && !!part.second_topic ? `${part.first_topic} & ${part.second_topic}` : part.topic}
</h3>
{part.question && <span className="w-full">{part.question}</span>} {part.question && <span className="w-full">{part.question}</span>}
{part.questions && ( {part.questions && (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -166,6 +181,28 @@ const PartTab = ({
<b>Instructor:</b> {part.avatar.name} - {capitalize(part.avatar.gender)} <b>Instructor:</b> {part.avatar.name} - {capitalize(part.avatar.gender)}
</span> </span>
)} )}
{part.questions?.map((question, index) => (
<Input
key={index}
type="text"
label="Question"
name="question"
required
value={question}
onChange={(value) =>
updatePart((part?: SpeakingPart) => {
if (part) {
return {
...part,
questions: part.questions?.map((x, xIndex) => (xIndex === index ? value : x)),
} as SpeakingPart;
}
return part;
})
}
/>
))}
</div> </div>
)} )}
</Tab.Panel> </Tab.Panel>
@@ -177,6 +214,8 @@ interface SpeakingPart {
question?: string; question?: string;
questions?: string[]; questions?: string[];
topic: string; topic: string;
first_topic?: string;
second_topic?: string;
result?: SpeakingExercise | InteractiveSpeakingExercise; result?: SpeakingExercise | InteractiveSpeakingExercise;
gender?: "male" | "female"; gender?: "male" | "female";
avatar?: (typeof AVATARS)[number]; avatar?: (typeof AVATARS)[number];
@@ -208,10 +247,18 @@ const SpeakingGeneration = () => {
const genders = [part1?.gender, part2?.gender, part3?.gender].filter((x) => !!x); const genders = [part1?.gender, part2?.gender, part3?.gender].filter((x) => !!x);
const exercises = [part1?.result, part2?.result, part3?.result]
.filter((x) => !!x)
.map((x) => ({
...x,
first_title: x?.type === "interactiveSpeaking" ? x.first_topic : undefined,
second_title: x?.type === "interactiveSpeaking" ? x.second_topic : undefined,
}));
const exam: SpeakingExam = { const exam: SpeakingExam = {
id: v4(), id: generate({minLength: 4, maxLength: 8, min: 3, max: 5, join: " ", formatter: capitalize}),
isDiagnostic: false, isDiagnostic: false,
exercises: [part1?.result, part2?.result, part3?.result].filter((x) => !!x) as (SpeakingExercise | InteractiveSpeakingExercise)[], exercises: exercises as (SpeakingExercise | InteractiveSpeakingExercise)[],
minTimer, minTimer,
variant: minTimer >= 14 ? "full" : "partial", variant: minTimer >= 14 ? "full" : "partial",
module: "speaking", module: "speaking",
@@ -223,7 +270,7 @@ const SpeakingGeneration = () => {
.then((result) => { .then((result) => {
playSound("sent"); playSound("sent");
console.log(`Generated Exam ID: ${result.data.id}`); console.log(`Generated Exam ID: ${result.data.id}`);
toast.success("This new exam has been generated successfully! Check the ID in our browser's console."); toast.success(`Generated Exam ID: ${result.data.id}`);
setResultingExam(result.data); setResultingExam(result.data);
setPart1(undefined); setPart1(undefined);
@@ -271,7 +318,10 @@ const SpeakingGeneration = () => {
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label> <label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
<Select <Select
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))} options={DIFFICULTIES.map((x) => ({
value: x,
label: capitalize(x),
}))}
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)} onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
value={{value: difficulty, label: capitalize(difficulty)}} value={{value: difficulty, label: capitalize(difficulty)}}
disabled={!!part1 || !!part2 || !!part3} disabled={!!part1 || !!part2 || !!part3}
@@ -321,7 +371,7 @@ const SpeakingGeneration = () => {
{part: part2, setPart: setPart2}, {part: part2, setPart: setPart2},
{part: part3, setPart: setPart3}, {part: part3, setPart: setPart3},
].map(({part, setPart}, index) => ( ].map(({part, setPart}, index) => (
<PartTab difficulty={difficulty} part={part} index={index + 1} key={index} setPart={setPart} /> <PartTab difficulty={difficulty} part={part} index={index + 1} key={index} setPart={setPart} updatePart={setPart} />
))} ))}
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>

View File

@@ -9,6 +9,7 @@ import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize, sample} from "lodash"; import {capitalize, sample} from "lodash";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {generate} from "random-words";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {BsArrowRepeat, BsCheck} from "react-icons/bs"; import {BsArrowRepeat, BsCheck} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
@@ -151,7 +152,7 @@ const WritingGeneration = () => {
minTimer, minTimer,
module: "writing", module: "writing",
exercises: [...(exercise1 ? [exercise1] : []), ...(exercise2 ? [exercise2] : [])], exercises: [...(exercise1 ? [exercise1] : []), ...(exercise2 ? [exercise2] : [])],
id: v4(), id: generate({minLength: 4, maxLength: 8, min: 3, max: 5, join: " ", formatter: capitalize}),
variant: exercise1 && exercise2 ? "full" : "partial", variant: exercise1 && exercise2 ? "full" : "partial",
difficulty, difficulty,
}; };
@@ -160,6 +161,7 @@ const WritingGeneration = () => {
.post(`/api/exam/writing`, exam) .post(`/api/exam/writing`, exam)
.then((result) => { .then((result) => {
console.log(`Generated Exam ID: ${result.data.id}`); console.log(`Generated Exam ID: ${result.data.id}`);
toast.success(`Generated Exam ID: ${result.data.id}`);
playSound("sent"); playSound("sent");
toast.success("This new exam has been generated successfully! Check the ID in our browser's console."); toast.success("This new exam has been generated successfully! Check the ID in our browser's console.");
setResultingExam(result.data); setResultingExam(result.data);

View File

@@ -7,7 +7,6 @@ import {User} from "@/interfaces/user";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize} from "lodash"; import {capitalize} from "lodash";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import getSymbolFromCurrency from "currency-symbol-map";
import useInvites from "@/hooks/useInvites"; import useInvites from "@/hooks/useInvites";
import {BsArrowRepeat} from "react-icons/bs"; import {BsArrowRepeat} from "react-icons/bs";
import InviteCard from "@/components/Medium/InviteCard"; import InviteCard from "@/components/Medium/InviteCard";
@@ -15,15 +14,15 @@ import {useRouter} from "next/router";
import {ToastContainer} from "react-toastify"; import {ToastContainer} from "react-toastify";
import useDiscounts from "@/hooks/useDiscounts"; import useDiscounts from "@/hooks/useDiscounts";
import PaymobPayment from "@/components/PaymobPayment"; import PaymobPayment from "@/components/PaymobPayment";
import moment from "moment";
interface Props { interface Props {
user: User; user: User;
hasExpired?: boolean; hasExpired?: boolean;
clientID: string;
reload: () => void; reload: () => void;
} }
export default function PaymentDue({user, hasExpired = false, clientID, reload}: Props) { export default function PaymentDue({user, hasExpired = false, reload}: Props) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [appliedDiscount, setAppliedDiscount] = useState(0); const [appliedDiscount, setAppliedDiscount] = useState(0);
@@ -40,7 +39,7 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
if (userDiscounts.length === 0) return; if (userDiscounts.length === 0) return;
const biggestDiscount = [...userDiscounts].sort((a, b) => b.percentage - a.percentage).shift(); const biggestDiscount = [...userDiscounts].sort((a, b) => b.percentage - a.percentage).shift();
if (!biggestDiscount) return; if (!biggestDiscount || (biggestDiscount.validUntil && moment(biggestDiscount.validUntil).isBefore(moment()))) return;
setAppliedDiscount(biggestDiscount.percentage); setAppliedDiscount(biggestDiscount.percentage);
}, [discounts, user]); }, [discounts, user]);
@@ -121,26 +120,22 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
</span> </span>
</div> </div>
<div className="flex w-full flex-col items-start gap-2"> <div className="flex w-full flex-col items-start gap-2">
{!appliedDiscount && ( {appliedDiscount === 0 && (
<span className="text-2xl"> <span className="text-2xl">
{p.price} {p.price} {p.currency}
{getSymbolFromCurrency(p.currency)}
</span> </span>
)} )}
{appliedDiscount && ( {appliedDiscount > 0 && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-2xl line-through"> <span className="text-2xl line-through">
{p.price} {p.price} {p.currency}
{getSymbolFromCurrency(p.currency)}
</span> </span>
<span className="text-2xl text-mti-red-light"> <span className="text-2xl text-mti-red-light">
{(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} {(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} {p.currency}
{getSymbolFromCurrency(p.currency)}
</span> </span>
</div> </div>
)} )}
<PaymobPayment <PaymobPayment
key={clientID}
user={user} user={user}
setIsPaymentLoading={setIsLoading} setIsPaymentLoading={setIsLoading}
onSuccess={() => { onSuccess={() => {
@@ -177,11 +172,9 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
</div> </div>
<div className="flex w-full flex-col items-start gap-2"> <div className="flex w-full flex-col items-start gap-2">
<span className="text-2xl"> <span className="text-2xl">
{user.corporateInformation.payment.value} {user.corporateInformation.payment.value} {user.corporateInformation.payment.currency}
{getSymbolFromCurrency(user.corporateInformation.payment.currency)}
</span> </span>
<PaymobPayment <PaymobPayment
key={clientID}
user={user} user={user}
setIsPaymentLoading={setIsLoading} setIsPaymentLoading={setIsLoading}
currency={user.corporateInformation.payment.currency} currency={user.corporateInformation.payment.currency}

View File

@@ -4,7 +4,7 @@ import {app} from "@/firebase";
import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc, deleteDoc} from "firebase/firestore"; import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc, deleteDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {uuidv4} from "@firebase/util";
const db = getFirestore(app); const db = getFirestore(app);

View File

@@ -102,6 +102,7 @@ const generateExams = async (
async function POST(req: NextApiRequest, res: NextApiResponse) { async function POST(req: NextApiRequest, res: NextApiResponse) {
const { const {
examIDs,
selectedModules, selectedModules,
assignees, assignees,
// Generate multiple true would generate an unique exam for each user // Generate multiple true would generate an unique exam for each user
@@ -111,6 +112,7 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
instructorGender, instructorGender,
...body ...body
} = req.body as { } = req.body as {
examIDs?: {id: string; module: Module}[];
selectedModules: Module[]; selectedModules: Module[];
assignees: string[]; assignees: string[];
generateMultiple: Boolean; generateMultiple: Boolean;
@@ -121,7 +123,9 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
instructorGender?: InstructorGender; instructorGender?: InstructorGender;
}; };
const exams: ExamWithUser[] = await generateExams(generateMultiple, selectedModules, assignees, variant, instructorGender); const exams: ExamWithUser[] = !!examIDs
? examIDs.flatMap((e) => assignees.map((a) => ({...e, assignee: a})))
: await generateExams(generateMultiple, selectedModules, assignees, variant, instructorGender);
if (exams.length === 0) { if (exams.length === 0) {
res.status(400).json({ok: false, error: "No exams found for the selected modules"}); res.status(400).json({ok: false, error: "No exams found for the selected modules"});

View File

@@ -47,7 +47,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
res.status(200).json(null); res.status(200).json(null);
console.log("🌱 - Still processing"); console.log("🌱 - Still processing");
const backendRequest = await evaluate({answers: uploadingAudios}); const backendRequest = await evaluate({answers: uploadingAudios}, fields.variant);
console.log("🌱 - Process complete"); console.log("🌱 - Process complete");
const correspondingStat = await getCorrespondingStat(fields.id, 1); const correspondingStat = await getCorrespondingStat(fields.id, 1);
@@ -79,8 +79,8 @@ async function getCorrespondingStat(id: string, index: number): Promise<Stat> {
return getCorrespondingStat(id, index + 1); return getCorrespondingStat(id, index + 1);
} }
async function evaluate(body: {answers: object[]}): Promise<AxiosResponse> { async function evaluate(body: {answers: object[]}, variant?: "initial" | "final"): Promise<AxiosResponse> {
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/speaking_task_3`, body, { const backendRequest = await axios.post(`${process.env.BACKEND_URL}/speaking_task_${variant === "initial" ? "1" : "3"}`, body, {
headers: { headers: {
Authorization: `Bearer ${process.env.BACKEND_JWT}`, Authorization: `Bearer ${process.env.BACKEND_JWT}`,
}, },

View File

@@ -30,6 +30,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const audioFile = files.audio; const audioFile = files.audio;
const audioFileRef = ref(storage, `speaking_recordings/${fields.id}.wav`); const audioFileRef = ref(storage, `speaking_recordings/${fields.id}.wav`);
const task = parseInt(fields.task.toString());
const binary = fs.readFileSync((audioFile as any).path).buffer; const binary = fs.readFileSync((audioFile as any).path).buffer;
const snapshot = await uploadBytes(audioFileRef, binary); const snapshot = await uploadBytes(audioFileRef, binary);
@@ -39,7 +40,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
res.status(200).json(null); res.status(200).json(null);
console.log("🌱 - Still processing"); console.log("🌱 - Still processing");
const backendRequest = await evaluate({answers: [{question: fields.question, answer: path}]}); const backendRequest = await evaluate({answer: path, question: fields.question}, task);
console.log("🌱 - Process complete"); console.log("🌱 - Process complete");
const correspondingStat = await getCorrespondingStat(fields.id, 1); const correspondingStat = await getCorrespondingStat(fields.id, 1);
@@ -76,14 +77,14 @@ async function getCorrespondingStat(id: string, index: number): Promise<Stat> {
return getCorrespondingStat(id, index + 1); return getCorrespondingStat(id, index + 1);
} }
async function evaluate(body: {answers: object[]}): Promise<AxiosResponse> { async function evaluate(body: {answer: string; question: string}, task: number): Promise<AxiosResponse> {
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/speaking_task_3`, body, { const backendRequest = await axios.post(`${process.env.BACKEND_URL}/speaking_task_2`, body, {
headers: { headers: {
Authorization: `Bearer ${process.env.BACKEND_JWT}`, Authorization: `Bearer ${process.env.BACKEND_JWT}`,
}, },
}); });
if (typeof backendRequest.data === "string") return evaluate(body); if (typeof backendRequest.data === "string") return evaluate(body, task);
return backendRequest; return backendRequest;
} }

View File

@@ -23,7 +23,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) return res.status(401).json({ok: false}); if (!req.session.user) return res.status(401).json({ok: false});
if (req.session.user.type !== "developer") return res.status(403).json({ok: false});
const {endpoint, topic, exercises, difficulty} = req.query as { const {endpoint, topic, exercises, difficulty} = req.query as {
module: Module; module: Module;
@@ -48,7 +47,6 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
async function post(req: NextApiRequest, res: NextApiResponse) { async function post(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) return res.status(401).json({ok: false}); if (!req.session.user) return res.status(401).json({ok: false});
if (req.session.user.type !== "developer") return res.status(403).json({ok: false});
const {endpoint, topic, exercises} = req.query as {module: Module; endpoint: string[]; topic?: string; exercises?: string[]}; const {endpoint, topic, exercises} = req.query as {module: Module; endpoint: string[]; topic?: string; exercises?: string[]};
const url = `${process.env.BACKEND_URL}/${endpoint.join("/")}`; const url = `${process.env.BACKEND_URL}/${endpoint.join("/")}`;

View File

@@ -0,0 +1,35 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, collection, getDocs, query, where} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {shuffle} from "lodash";
import {Difficulty, Exam} from "@/interfaces/exam";
import {Stat} from "@/interfaces/user";
import {Module} from "@/interfaces";
import axios from "axios";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") return post(req, res);
return res.status(404).json({ok: false});
}
async function post(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) return res.status(401).json({ok: false});
const body = req.body;
const params = new URLSearchParams();
Object.keys(body).forEach((key) => params.append(key, body[key]));
const result = await axios.get(`${process.env.BACKEND_URL}/custom_level?${params.toString()}`, {
headers: {Authorization: `Bearer ${process.env.BACKEND_JWT}`},
});
res.status(200).json(result.data);
}

View File

@@ -30,29 +30,74 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") await post(req, res); if (req.method === "POST") await post(req, res);
} }
const getGroupsForUser = async (admin: string, participant: string) => {
try {
const queryConstraints = [
...(admin ? [where("admin", "==", admin)] : []),
...(participant
? [where("participants", "array-contains", participant)]
: []),
];
const snapshot = await getDocs(
queryConstraints.length > 0
? query(collection(db, "groups"), ...queryConstraints)
: collection(db, "groups")
);
const groups = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as Group[];
return groups;
} catch (e) {
console.error(e);
return [];
}
};
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
const { admin, participant } = req.query as { const { admin, participant } = req.query as {
admin: string; admin: string;
participant: string; participant: string;
}; };
const queryConstraints = [ if (req.session?.user?.type === "mastercorporate") {
...(admin ? [where("admin", "==", admin)] : []), try {
...(participant const masterCorporateGroups = await getGroupsForUser(admin, participant);
? [where("participants", "array-contains", participant)] const corporatesFromMaster = masterCorporateGroups
: []), .filter((g) => g.name === "Corporate")
]; .flatMap((g) => g.participants);
const snapshot = await getDocs(
queryConstraints.length > 0
? query(collection(db, "groups"), ...queryConstraints)
: collection(db, "groups"),
);
const groups = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as Group[];
res.status(200).json(groups); if (corporatesFromMaster.length === 0) {
res.status(200).json([]);
return;
}
Promise.all(
corporatesFromMaster.map((c) => getGroupsForUser(c, participant))
)
.then((groups) => {
res.status(200).json([...masterCorporateGroups, ...groups.flat()]);
return;
})
.catch((e) => {
console.error(e);
res.status(500).json({ ok: false });
return;
});
} catch (e) {
console.error(e);
res.status(500).json({ ok: false });
return;
}
return;
}
try {
const groups = await getGroupsForUser(admin, participant);
res.status(200).json(groups);
} catch (e) {
console.error(e);
res.status(500).json({ ok: false });
}
} }
async function post(req: NextApiRequest, res: NextApiResponse) { async function post(req: NextApiRequest, res: NextApiResponse) {
@@ -60,8 +105,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
await Promise.all( await Promise.all(
body.participants.map( body.participants.map(
async (p) => await updateExpiryDateOnGroup(p, body.admin), async (p) => await updateExpiryDateOnGroup(p, body.admin)
), )
); );
await setDoc(doc(db, "groups", v4()), { await setDoc(doc(db, "groups", v4()), {

View File

@@ -41,7 +41,12 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const response = await axios.post<IntentionResult>( const response = await axios.post<IntentionResult>(
"https://oman.paymob.com/v1/intention/", "https://oman.paymob.com/v1/intention/",
{...intention, payment_methods: [parseInt(process.env.PAYMOB_INTEGRATION_ID || "0")], items: []}, {
...intention,
payment_methods: [parseInt(process.env.PAYMOB_INTEGRATION_ID || "0")],
items: [],
extras: {...intention.extras, userID: req.session.user!.id},
} as PaymentIntention,
{headers: {Authorization: `Token ${process.env.PAYMOB_SECRET_KEY}`}}, {headers: {Authorization: `Token ${process.env.PAYMOB_SECRET_KEY}`}},
); );
const intentionResult = response.data; const intentionResult = response.data;

View File

@@ -22,8 +22,9 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const transactionResult = req.body as TransactionResult; const transactionResult = req.body as TransactionResult;
const authToken = await authenticatePaymob(); const authToken = await authenticatePaymob();
console.log("WEBHOOK: ", transactionResult);
if (!checkTransaction(authToken, transactionResult.transaction.order.id)) return res.status(404).json({ok: false}); if (!checkTransaction(authToken, transactionResult.transaction.order.id)) return res.status(404).json({ok: false});
if (!transactionResult.transaction.success) return res.status(200).json({ok: false}); if (!transactionResult.transaction.success) return res.status(400).json({ok: false});
const {userID, duration, duration_unit} = transactionResult.intention.extras.creation_extras as { const {userID, duration, duration_unit} = transactionResult.intention.extras.creation_extras as {
userID: string; userID: string;
@@ -42,9 +43,9 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const initialDate = moment(subscriptionExpirationDate).isAfter(moment()) ? moment(subscriptionExpirationDate) : moment(); const initialDate = moment(subscriptionExpirationDate).isAfter(moment()) ? moment(subscriptionExpirationDate) : moment();
const updatedSubscriptionExpirationDate = moment(initialDate).add(duration, duration_unit).toISOString(); const updatedSubscriptionExpirationDate = moment(initialDate).add(duration, duration_unit).endOf("day").subtract(2, "hours").toISOString();
await setDoc(userSnapshot.ref, {subscriptionExpirationDate: updatedSubscriptionExpirationDate}, {merge: true}); await setDoc(userSnapshot.ref, {subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active"}, {merge: true});
await setDoc(doc(db, "paypalpayments", v4()), { await setDoc(doc(db, "paypalpayments", v4()), {
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
currency: transactionResult.transaction.currency, currency: transactionResult.transaction.currency,
@@ -64,10 +65,16 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const participants = (await Promise.all( const participants = (await Promise.all(
groups.flatMap((x) => x.participants).map(async (x) => ({...(await getDoc(doc(db, "users", x))).data(), id: x})), groups.flatMap((x) => x.participants).map(async (x) => ({...(await getDoc(doc(db, "users", x))).data(), id: x})),
)) as User[]; )) as User[];
const sameExpiryDateParticipants = participants.filter((x) => x.subscriptionExpirationDate === subscriptionExpirationDate); const sameExpiryDateParticipants = participants.filter(
(x) => x.subscriptionExpirationDate === subscriptionExpirationDate && x.status !== "disabled",
);
for (const participant of sameExpiryDateParticipants) { for (const participant of sameExpiryDateParticipants) {
await setDoc(doc(db, "users", participant.id), {subscriptionExpirationDate: updatedSubscriptionExpirationDate}, {merge: true}); await setDoc(
doc(db, "users", participant.id),
{subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active"},
{merge: true},
);
} }
} }

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, doc, 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 handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "PATCH") return patch(req, res);
}
async function patch(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
const { id } = req.query as { id: string };
const { users } = req.body;
try {
await setDoc(doc(db, "permissions", id), { users }, { merge: true });
return res.status(200).json({ ok: true });
} catch (err) {
console.error(err);
return res.status(500).json({ ok: false });
}
}

View File

@@ -0,0 +1,43 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
import {
getFirestore,
collection,
getDocs,
query,
where,
doc,
setDoc,
addDoc,
getDoc,
deleteDoc,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Permission } from "@/interfaces/permissions";
import { bootstrap } from "@/utils/permissions.be";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return get(req, res);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
console.log("Boostrap");
try {
await bootstrap();
return res.status(200).json({ ok: true });
} catch (err) {
console.error("Failed to update permissions", err);
return res.status(500).json({ ok: false });
}
}

View File

@@ -0,0 +1,36 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
import {
getFirestore,
collection,
getDocs,
query,
where,
doc,
setDoc,
addDoc,
getDoc,
deleteDoc,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Permission } from "@/interfaces/permissions";
import { getPermissions, getPermissionDocs } from "@/utils/permissions.be";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return get(req, res);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
const docs = await getPermissionDocs();
res.status(200).json(docs);
}

View File

@@ -143,9 +143,18 @@ async function registerCorporate(req: NextApiRequest, res: NextApiResponse) {
disableEditing: true, disableEditing: true,
}; };
const defaultCorporateGroup: Group = {
admin: userId,
id: v4(),
name: "Corporate",
participants: [],
disableEditing: true,
};
await setDoc(doc(db, "users", userId), user); await setDoc(doc(db, "users", userId), user);
await setDoc(doc(db, "groups", defaultTeachersGroup.id), defaultTeachersGroup); await setDoc(doc(db, "groups", defaultTeachersGroup.id), defaultTeachersGroup);
await setDoc(doc(db, "groups", defaultStudentsGroup.id), defaultStudentsGroup); await setDoc(doc(db, "groups", defaultStudentsGroup.id), defaultStudentsGroup);
await setDoc(doc(db, "groups", defaultCorporateGroup.id), defaultCorporateGroup);
req.session.user = {...user, id: userId}; req.session.user = {...user, id: userId};
await req.session.save(); await req.session.save();

View File

@@ -1,11 +1,22 @@
import {PERMISSIONS} from "@/constants/userPermissions"; import { PERMISSIONS } from "@/constants/userPermissions";
import {app, adminApp} from "@/firebase"; import { app, adminApp } from "@/firebase";
import {Group, User} from "@/interfaces/user"; import { Group, User } from "@/interfaces/user";
import {sessionOptions} from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import {collection, deleteDoc, doc, getDoc, getDocs, getFirestore, query, setDoc, where} from "firebase/firestore"; import {
import {getAuth} from "firebase-admin/auth"; collection,
import {withIronSessionApiRoute} from "iron-session/next"; deleteDoc,
import {NextApiRequest, NextApiResponse} from "next"; doc,
getDoc,
getDocs,
getFirestore,
query,
setDoc,
where,
} from "firebase/firestore";
import { getAuth } from "firebase-admin/auth";
import { withIronSessionApiRoute } from "iron-session/next";
import { NextApiRequest, NextApiResponse } from "next";
import { getPermissions, getPermissionDocs } from "@/utils/permissions.be";
const db = getFirestore(app); const db = getFirestore(app);
const auth = getAuth(adminApp); const auth = getAuth(adminApp);
@@ -13,89 +24,132 @@ const auth = getAuth(adminApp);
export default withIronSessionApiRoute(user, sessionOptions); export default withIronSessionApiRoute(user, sessionOptions);
async function user(req: NextApiRequest, res: NextApiResponse) { async function user(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return get(req, res); if (req.method === "GET") return get(req, res);
if (req.method === "DELETE") return del(req, res); if (req.method === "DELETE") return del(req, res);
res.status(404).json(undefined); res.status(404).json(undefined);
} }
async function del(req: NextApiRequest, res: NextApiResponse) { async function del(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ok: false}); res.status(401).json({ ok: false });
return; return;
} }
const {id} = req.query as {id: string}; const { id } = req.query as { id: string };
const docUser = await getDoc(doc(db, "users", req.session.user.id)); const docUser = await getDoc(doc(db, "users", req.session.user.id));
if (!docUser.exists()) { if (!docUser.exists()) {
res.status(401).json({ok: false}); res.status(401).json({ ok: false });
return; return;
} }
const user = docUser.data() as User; const user = docUser.data() as User;
const docTargetUser = await getDoc(doc(db, "users", id)); const docTargetUser = await getDoc(doc(db, "users", id));
if (!docTargetUser.exists()) { if (!docTargetUser.exists()) {
res.status(404).json({ok: false}); res.status(404).json({ ok: false });
return; return;
} }
const targetUser = {...docTargetUser.data(), id: docTargetUser.id} as User; const targetUser = { ...docTargetUser.data(), id: docTargetUser.id } as User;
if (user.type === "corporate" && (targetUser.type === "student" || targetUser.type === "teacher")) { if (
res.json({ok: true}); user.type === "corporate" &&
(targetUser.type === "student" || targetUser.type === "teacher")
) {
res.json({ ok: true });
const userParticipantGroup = await getDocs(query(collection(db, "groups"), where("participants", "array-contains", id))); const userParticipantGroup = await getDocs(
await Promise.all([ query(
...userParticipantGroup.docs collection(db, "groups"),
.filter((x) => (x.data() as Group).admin === user.id) where("participants", "array-contains", id)
.map(async (x) => await setDoc(x.ref, {participants: x.data().participants.filter((y: string) => y !== id)}, {merge: true})), )
]); );
await Promise.all([
...userParticipantGroup.docs
.filter((x) => (x.data() as Group).admin === user.id)
.map(
async (x) =>
await setDoc(
x.ref,
{
participants: x
.data()
.participants.filter((y: string) => y !== id),
},
{ merge: true }
)
),
]);
return; return;
} }
const permission = PERMISSIONS.deleteUser[targetUser.type]; const permission = PERMISSIONS.deleteUser[targetUser.type];
if (!permission.includes(user.type)) { if (!permission.list.includes(user.type)) {
res.status(403).json({ok: false}); res.status(403).json({ ok: false });
return; return;
} }
res.json({ok: true}); res.json({ ok: true });
await auth.deleteUser(id); await auth.deleteUser(id);
await deleteDoc(doc(db, "users", id)); await deleteDoc(doc(db, "users", id));
const userCodeDocs = await getDocs(query(collection(db, "codes"), where("userId", "==", id))); const userCodeDocs = await getDocs(
const userParticipantGroup = await getDocs(query(collection(db, "groups"), where("participants", "array-contains", id))); query(collection(db, "codes"), where("userId", "==", id))
const userGroupAdminDocs = await getDocs(query(collection(db, "groups"), where("admin", "==", id))); );
const userStatsDocs = await getDocs(query(collection(db, "stats"), where("user", "==", id))); const userParticipantGroup = await getDocs(
query(collection(db, "groups"), where("participants", "array-contains", id))
);
const userGroupAdminDocs = await getDocs(
query(collection(db, "groups"), where("admin", "==", id))
);
const userStatsDocs = await getDocs(
query(collection(db, "stats"), where("user", "==", id))
);
await Promise.all([ await Promise.all([
...userCodeDocs.docs.map(async (x) => await deleteDoc(x.ref)), ...userCodeDocs.docs.map(async (x) => await deleteDoc(x.ref)),
...userGroupAdminDocs.docs.map(async (x) => await deleteDoc(x.ref)), ...userGroupAdminDocs.docs.map(async (x) => await deleteDoc(x.ref)),
...userStatsDocs.docs.map(async (x) => await deleteDoc(x.ref)), ...userStatsDocs.docs.map(async (x) => await deleteDoc(x.ref)),
...userParticipantGroup.docs.map( ...userParticipantGroup.docs.map(
async (x) => await setDoc(x.ref, {participants: x.data().participants.filter((y: string) => y !== id)}, {merge: true}), async (x) =>
), await setDoc(
]); x.ref,
{
participants: x.data().participants.filter((y: string) => y !== id),
},
{ merge: true }
)
),
]);
} }
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
if (req.session.user) { if (req.session.user) {
const docUser = await getDoc(doc(db, "users", req.session.user.id)); const docUser = await getDoc(doc(db, "users", req.session.user.id));
if (!docUser.exists()) { if (!docUser.exists()) {
res.status(401).json(undefined); res.status(401).json(undefined);
return; return;
} }
const user = docUser.data() as User; const user = docUser.data() as User;
const permissionDocs = await getPermissionDocs();
req.session.user = {...user, id: req.session.user.id}; const userWithPermissions = {
await req.session.save(); ...user,
permissions: getPermissions(req.session.user.id, permissionDocs),
};
req.session.user = {
...userWithPermissions,
id: req.session.user.id,
};
await req.session.save();
res.json({...user, id: req.session.user.id}); res.json({ ...userWithPermissions, id: req.session.user.id });
} else { } else {
res.status(401).json(undefined); res.status(401).json(undefined);
} }
} }

View File

@@ -90,7 +90,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
if (updatedUser.status || updatedUser.type === "corporate") { if (updatedUser.status || updatedUser.type === "corporate") {
// there's no await as this does not affect the user // there's no await as this does not affect the user
propagateStatusChange(queryId, updatedUser.status); propagateStatusChange(queryId, updatedUser.status);
propagateExpiryDateChanges(queryId, user.subscriptionExpirationDate || null, updatedUser.subscriptionExpirationDate || null); propagateExpiryDateChanges(queryId, user.subscriptionExpirationDate, updatedUser.subscriptionExpirationDate || null);
} }
res.status(200).json({ok: true}); res.status(200).json({ok: true});

View File

@@ -1,19 +1,19 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Head from "next/head"; import Head from "next/head";
import {withIronSessionSsr} from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
import {toast, ToastContainer} from "react-toastify"; import { toast, ToastContainer } from "react-toastify";
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import {shouldRedirectHome} from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import {useState} from "react"; import { useState } from "react";
import {Module} from "@/interfaces"; import { Module } from "@/interfaces";
import {RadioGroup, Tab} from "@headlessui/react"; import { RadioGroup, Tab } from "@headlessui/react";
import clsx from "clsx"; import clsx from "clsx";
import {MODULE_ARRAY} from "@/utils/moduleUtils"; import { MODULE_ARRAY } from "@/utils/moduleUtils";
import {capitalize} from "lodash"; import { capitalize } from "lodash";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import {Exercise, ReadingPart} from "@/interfaces/exam"; import { Exercise, ReadingPart } from "@/interfaces/exam";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import axios from "axios"; import axios from "axios";
import ReadingGeneration from "./(generation)/ReadingGeneration"; import ReadingGeneration from "./(generation)/ReadingGeneration";
@@ -21,101 +21,109 @@ import ListeningGeneration from "./(generation)/ListeningGeneration";
import WritingGeneration from "./(generation)/WritingGeneration"; import WritingGeneration from "./(generation)/WritingGeneration";
import LevelGeneration from "./(generation)/LevelGeneration"; import LevelGeneration from "./(generation)/LevelGeneration";
import SpeakingGeneration from "./(generation)/SpeakingGeneration"; import SpeakingGeneration from "./(generation)/SpeakingGeneration";
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
export const getServerSideProps = withIronSessionSsr(({req, res}) => { export const getServerSideProps = withIronSessionSsr(({ req, res }) => {
const user = req.session.user; const user = req.session.user;
if (!user || !user.isVerified) { if (!user || !user.isVerified) {
return { return {
redirect: { redirect: {
destination: "/login", destination: "/login",
permanent: false, permanent: false,
} },
}; };
} }
if (shouldRedirectHome(user) || user.type !== "developer") { if (
return { shouldRedirectHome(user) ||
redirect: { checkAccess(user, getTypesOfUser(["developer"]))
destination: "/", ) {
permanent: false, return {
} redirect: {
}; destination: "/",
} permanent: false,
},
};
}
return { return {
props: {user: req.session.user}, props: { user: req.session.user },
}; };
}, sessionOptions); }, sessionOptions);
export default function Generation() { export default function Generation() {
const [module, setModule] = useState<Module>("reading"); const [module, setModule] = useState<Module>("reading");
const {user} = useUser({redirectTo: "/login"}); const { user } = useUser({ redirectTo: "/login" });
return ( return (
<> <>
<Head> <Head>
<title>Exam Generation | EnCoach</title> <title>Exam Generation | EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/> />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
{user && ( {user && (
<Layout user={user} className="gap-6"> <Layout user={user} className="gap-6">
<h1 className="text-2xl font-semibold">Exam Generation</h1> <h1 className="text-2xl font-semibold">Exam Generation</h1>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Module</label> <label className="font-normal text-base text-mti-gray-dim">
<RadioGroup Module
value={module} </label>
onChange={setModule} <RadioGroup
className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between"> value={module}
{[...MODULE_ARRAY].map((x) => ( onChange={setModule}
<RadioGroup.Option value={x} key={x}> className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between"
{({checked}) => ( >
<span {[...MODULE_ARRAY].map((x) => (
className={clsx( <RadioGroup.Option value={x} key={x}>
"px-6 py-4 w-64 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", {({ checked }) => (
"transition duration-300 ease-in-out", <span
x === "reading" && className={clsx(
(!checked "px-6 py-4 w-64 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
? "bg-white border-mti-gray-platinum" "transition duration-300 ease-in-out",
: "bg-ielts-reading/70 border-ielts-reading text-white"), x === "reading" &&
x === "listening" && (!checked
(!checked ? "bg-white border-mti-gray-platinum"
? "bg-white border-mti-gray-platinum" : "bg-ielts-reading/70 border-ielts-reading text-white"),
: "bg-ielts-listening/70 border-ielts-listening text-white"), x === "listening" &&
x === "writing" && (!checked
(!checked ? "bg-white border-mti-gray-platinum"
? "bg-white border-mti-gray-platinum" : "bg-ielts-listening/70 border-ielts-listening text-white"),
: "bg-ielts-writing/70 border-ielts-writing text-white"), x === "writing" &&
x === "speaking" && (!checked
(!checked ? "bg-white border-mti-gray-platinum"
? "bg-white border-mti-gray-platinum" : "bg-ielts-writing/70 border-ielts-writing text-white"),
: "bg-ielts-speaking/70 border-ielts-speaking text-white"), x === "speaking" &&
x === "level" && (!checked
(!checked ? "bg-white border-mti-gray-platinum"
? "bg-white border-mti-gray-platinum" : "bg-ielts-speaking/70 border-ielts-speaking text-white"),
: "bg-ielts-level/70 border-ielts-level text-white"), x === "level" &&
)}> (!checked
{capitalize(x)} ? "bg-white border-mti-gray-platinum"
</span> : "bg-ielts-level/70 border-ielts-level text-white")
)} )}
</RadioGroup.Option> >
))} {capitalize(x)}
</RadioGroup> </span>
</div> )}
{module === "reading" && <ReadingGeneration />} </RadioGroup.Option>
{module === "listening" && <ListeningGeneration />} ))}
{module === "writing" && <WritingGeneration />} </RadioGroup>
{module === "speaking" && <SpeakingGeneration />} </div>
{module === "level" && <LevelGeneration />} {module === "reading" && <ReadingGeneration />}
</Layout> {module === "listening" && <ListeningGeneration />}
)} {module === "writing" && <WritingGeneration />}
</> {module === "speaking" && <SpeakingGeneration />}
); {module === "level" && <LevelGeneration />}
</Layout>
)}
</>
);
} }

View File

@@ -1,204 +1,244 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Head from "next/head"; import Head from "next/head";
import Navbar from "@/components/Navbar"; import Navbar from "@/components/Navbar";
import {BsFileEarmarkText, BsPencil, BsStar, BsBook, BsHeadphones, BsPen, BsMegaphone} from "react-icons/bs"; import {
import {withIronSessionSsr} from "iron-session/next"; BsFileEarmarkText,
import {sessionOptions} from "@/lib/session"; BsPencil,
import {useEffect, useState} from "react"; BsStar,
BsBook,
BsHeadphones,
BsPen,
BsMegaphone,
} from "react-icons/bs";
import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { useEffect, useState } from "react";
import useStats from "@/hooks/useStats"; import useStats from "@/hooks/useStats";
import {averageScore, groupBySession, totalExams} from "@/utils/stats"; import { averageScore, groupBySession, totalExams } from "@/utils/stats";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
import Sidebar from "@/components/Sidebar";
import Diagnostic from "@/components/Diagnostic"; import Diagnostic from "@/components/Diagnostic";
import {ToastContainer} from "react-toastify"; import { ToastContainer } from "react-toastify";
import {capitalize} from "lodash"; import { capitalize } from "lodash";
import {Module} from "@/interfaces"; import { Module } from "@/interfaces";
import ProgressBar from "@/components/Low/ProgressBar"; import ProgressBar from "@/components/Low/ProgressBar";
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import {calculateAverageLevel} from "@/utils/score"; import { calculateAverageLevel } from "@/utils/score";
import axios from "axios"; import axios from "axios";
import DemographicInformationInput from "@/components/DemographicInformationInput"; import DemographicInformationInput from "@/components/DemographicInformationInput";
import moment from "moment"; import moment from "moment";
import Link from "next/link"; import Link from "next/link";
import {MODULE_ARRAY} from "@/utils/moduleUtils"; import { MODULE_ARRAY } from "@/utils/moduleUtils";
import ProfileSummary from "@/components/ProfileSummary"; import ProfileSummary from "@/components/ProfileSummary";
import StudentDashboard from "@/dashboards/Student"; import StudentDashboard from "@/dashboards/Student";
import AdminDashboard from "@/dashboards/Admin"; import AdminDashboard from "@/dashboards/Admin";
import CorporateDashboard from "@/dashboards/Corporate"; import CorporateDashboard from "@/dashboards/Corporate";
import TeacherDashboard from "@/dashboards/Teacher"; import TeacherDashboard from "@/dashboards/Teacher";
import AgentDashboard from "@/dashboards/Agent"; import AgentDashboard from "@/dashboards/Agent";
import MasterCorporateDashboard from "@/dashboards/MasterCorporate";
import PaymentDue from "./(status)/PaymentDue"; import PaymentDue from "./(status)/PaymentDue";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import {PayPalScriptProvider} from "@paypal/react-paypal-js"; import { PayPalScriptProvider } from "@paypal/react-paypal-js";
import {CorporateUser, Type, userTypes} from "@/interfaces/user"; import {
CorporateUser,
MasterCorporateUser,
Type,
userTypes,
} from "@/interfaces/user";
import Select from "react-select"; import Select from "react-select";
import {USER_TYPE_LABELS} from "@/resources/user"; import { USER_TYPE_LABELS } from "@/resources/user";
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
export const getServerSideProps = withIronSessionSsr(({req, res}) => { export const getServerSideProps = withIronSessionSsr(({ req, res }) => {
const user = req.session.user; const user = req.session.user;
const envVariables: {[key: string]: string} = {}; const envVariables: { [key: string]: string } = {};
Object.keys(process.env) Object.keys(process.env)
.filter((x) => x.startsWith("NEXT_PUBLIC")) .filter((x) => x.startsWith("NEXT_PUBLIC"))
.forEach((x: string) => { .forEach((x: string) => {
envVariables[x] = process.env[x]!; envVariables[x] = process.env[x]!;
}); });
if (!user || !user.isVerified) { if (!user || !user.isVerified) {
return { return {
redirect: { redirect: {
destination: "/login", destination: "/login",
permanent: false, permanent: false,
} },
}; };
} }
return { return {
props: {user: req.session.user, envVariables}, props: { user: req.session.user, envVariables },
}; };
}, sessionOptions); }, sessionOptions);
interface Props { interface Props {
user: any; user: any;
envVariables: {[key: string]: string}; envVariables: { [key: string]: string };
} }
export default function Home(props: Props) { export default function Home(props: Props) {
const { envVariables} = props; const { envVariables } = props;
const [showDiagnostics, setShowDiagnostics] = useState(false); const [showDiagnostics, setShowDiagnostics] = useState(false);
const [showDemographicInput, setShowDemographicInput] = useState(false); const [showDemographicInput, setShowDemographicInput] = useState(false);
const [selectedScreen, setSelectedScreen] = useState<Type>("admin"); const [selectedScreen, setSelectedScreen] = useState<Type>("admin");
const {user, mutateUser} = useUser({redirectTo: "/login"}); const { user, mutateUser } = useUser({ redirectTo: "/login" });
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
if (user) { if (user) {
setShowDemographicInput( setShowDemographicInput(
!user.demographicInformation || !user.demographicInformation ||
!user.demographicInformation.country || !user.demographicInformation.country ||
!user.demographicInformation.gender || !user.demographicInformation.gender ||
!user.demographicInformation.phone, !user.demographicInformation.phone
); );
setShowDiagnostics(user.isFirstLogin && user.type === "student"); setShowDiagnostics(user.isFirstLogin && user.type === "student");
} }
}, [user]); }, [user]);
const checkIfUserExpired = () => { const checkIfUserExpired = () => {
const expirationDate = user!.subscriptionExpirationDate; const expirationDate = user!.subscriptionExpirationDate;
if (expirationDate === null || expirationDate === undefined) return false; if (expirationDate === null || expirationDate === undefined) return false;
if (moment(expirationDate).isAfter(moment(new Date()))) return false; if (moment(expirationDate).isAfter(moment(new Date()))) return false;
return true; return true;
}; };
if (user && (user.status === "paymentDue" || user.status === "disabled" || checkIfUserExpired())) { if (
return ( user &&
<> (user.status === "paymentDue" ||
<Head> user.status === "disabled" ||
<title>EnCoach</title> checkIfUserExpired())
<meta ) {
name="description" return (
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." <>
/> <Head>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <title>EnCoach</title>
<link rel="icon" href="/favicon.ico" /> <meta
</Head> name="description"
{user.status === "disabled" && ( content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
<Layout user={user} navDisabled> />
<div className="flex flex-col items-center justify-center text-center w-full gap-4"> <meta name="viewport" content="width=device-width, initial-scale=1" />
<span className="font-bold text-lg">Your account has been disabled!</span> <link rel="icon" href="/favicon.ico" />
<span>Please contact an administrator if you believe this to be a mistake.</span> </Head>
</div> {user.status === "disabled" && (
</Layout> <Layout user={user} navDisabled>
)} <div className="flex flex-col items-center justify-center text-center w-full gap-4">
{(user.status === "paymentDue" || checkIfUserExpired()) && ( <span className="font-bold text-lg">
<PaymentDue Your account has been disabled!
key={envVariables["NEXT_PUBLIC_PAYPAL_CLIENT_ID"]} </span>
hasExpired <span>
user={user} Please contact an administrator if you believe this to be a
reload={router.reload} mistake.
clientID={envVariables["NEXT_PUBLIC_PAYPAL_CLIENT_ID"] || ""} </span>
/> </div>
)} </Layout>
</> )}
); {(user.status === "paymentDue" || checkIfUserExpired()) && (
} <PaymentDue hasExpired user={user} reload={router.reload} />
)}
</>
);
}
if (user && showDemographicInput) { if (user && showDemographicInput) {
return ( return (
<> <>
<Head> <Head>
<title>EnCoach</title> <title>EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/> />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<Layout user={user} navDisabled> <Layout user={user} navDisabled>
<DemographicInformationInput mutateUser={mutateUser} user={user} /> <DemographicInformationInput mutateUser={mutateUser} user={user} />
</Layout> </Layout>
</> </>
); );
} }
if (user && showDiagnostics) { if (user && showDiagnostics) {
return ( return (
<> <>
<Head> <Head>
<title>EnCoach</title> <title>EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/> />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<Layout user={user} navDisabled> <Layout user={user} navDisabled>
<Diagnostic user={user} onFinish={() => setShowDiagnostics(false)} /> <Diagnostic user={user} onFinish={() => setShowDiagnostics(false)} />
</Layout> </Layout>
</> </>
); );
} }
return ( return (
<> <>
<Head> <Head>
<title>EnCoach</title> <title>EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/> />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
{user && ( {user && (
<Layout user={user}> <Layout user={user}>
{user.type === "student" && <StudentDashboard user={user} />} {checkAccess(user, ["student"]) && <StudentDashboard user={user} />}
{user.type === "teacher" && <TeacherDashboard user={user} />} {checkAccess(user, ["teacher"]) && <TeacherDashboard user={user} />}
{user.type === "corporate" && <CorporateDashboard user={user} />} {checkAccess(user, ["corporate"]) && (
{user.type === "agent" && <AgentDashboard user={user} />} <CorporateDashboard user={user as CorporateUser} />
{user.type === "admin" && <AdminDashboard user={user} />} )}
{user.type === "developer" && ( {checkAccess(user, ["mastercorporate"]) && (
<> <MasterCorporateDashboard user={user as MasterCorporateUser} />
<Select )}
options={userTypes.map((u) => ({value: u, label: USER_TYPE_LABELS[u]}))} {checkAccess(user, ["agent"]) && <AgentDashboard user={user} />}
value={{value: selectedScreen, label: USER_TYPE_LABELS[selectedScreen]}} {checkAccess(user, ["admin"]) && <AdminDashboard user={user} />}
onChange={(value) => (value ? setSelectedScreen(value.value) : setSelectedScreen("admin"))} {checkAccess(user, ["developer"]) && (
/> <>
<Select
options={userTypes.map((u) => ({
value: u,
label: USER_TYPE_LABELS[u],
}))}
value={{
value: selectedScreen,
label: USER_TYPE_LABELS[selectedScreen],
}}
onChange={(value) =>
value
? setSelectedScreen(value.value)
: setSelectedScreen("admin")
}
/>
{selectedScreen === "student" && <StudentDashboard user={user} />} {selectedScreen === "student" && <StudentDashboard user={user} />}
{selectedScreen === "teacher" && <TeacherDashboard user={user} />} {selectedScreen === "teacher" && <TeacherDashboard user={user} />}
{selectedScreen === "corporate" && <CorporateDashboard user={user as unknown as CorporateUser} />} {selectedScreen === "corporate" && (
{selectedScreen === "agent" && <AgentDashboard user={user} />} <CorporateDashboard user={user as unknown as CorporateUser} />
{selectedScreen === "admin" && <AdminDashboard user={user} />} )}
</> {selectedScreen === "mastercorporate" && (
)} <MasterCorporateDashboard
</Layout> user={user as unknown as MasterCorporateUser}
)} />
</> )}
); {selectedScreen === "agent" && <AgentDashboard user={user} />}
{selectedScreen === "admin" && <AdminDashboard user={user} />}
</>
)}
</Layout>
)}
</>
);
} }

View File

@@ -1,77 +1,85 @@
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import {sessionOptions} from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import useFilterStore from "@/stores/listFilterStore"; import useFilterStore from "@/stores/listFilterStore";
import {withIronSessionSsr} from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import Head from "next/head"; import Head from "next/head";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import {useEffect} from "react"; import { useEffect } from "react";
import {BsArrowLeft} from "react-icons/bs"; import { BsArrowLeft } from "react-icons/bs";
import {ToastContainer} from "react-toastify"; import { ToastContainer } from "react-toastify";
import UserList from "../(admin)/Lists/UserList"; import UserList from "../(admin)/Lists/UserList";
export const getServerSideProps = withIronSessionSsr(({req, res}) => { export const getServerSideProps = withIronSessionSsr(({ req, res }) => {
const user = req.session.user; const user = req.session.user;
const envVariables: {[key: string]: string} = {}; const envVariables: { [key: string]: string } = {};
Object.keys(process.env) Object.keys(process.env)
.filter((x) => x.startsWith("NEXT_PUBLIC")) .filter((x) => x.startsWith("NEXT_PUBLIC"))
.forEach((x: string) => { .forEach((x: string) => {
envVariables[x] = process.env[x]!; envVariables[x] = process.env[x]!;
}); });
if (!user || !user.isVerified) { if (!user || !user.isVerified) {
return { return {
redirect: { redirect: {
destination: "/login", destination: "/login",
permanent: false, permanent: false,
} },
}; };
} }
return { return {
props: {user: req.session.user, envVariables}, props: { user: req.session.user, envVariables },
}; };
}, sessionOptions); }, sessionOptions);
export default function UsersListPage() { export default function UsersListPage() {
const {user} = useUser(); const { user } = useUser();
const {users} = useUsers(); const { users } = useUsers();
const [filters, clearFilters] = useFilterStore((state) => [state.userFilters, state.clearUserFilters]); const [filters, clearFilters] = useFilterStore((state) => [
const router = useRouter(); state.userFilters,
state.clearUserFilters,
]);
const router = useRouter();
return ( return (
<> <>
<Head> <Head>
<title>EnCoach</title> <title>EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/> />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
{user && ( {user && (
<Layout user={user}> <Layout user={user}>
<div className="flex flex-col gap-4"> <UserList
<div user={user}
onClick={() => { filters={filters.map((f) => f.filter)}
clearFilters(); renderHeader={(total) => (
router.back(); <div className="flex flex-col gap-4">
}} <div
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> onClick={() => {
<BsArrowLeft className="text-xl" /> clearFilters();
<span>Back</span> router.back();
</div> }}
<h2 className="text-2xl font-semibold">Users ({filters.map((f) => f.filter).reduce((d, f) => d.filter(f), users).length})</h2> className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
</div> >
<BsArrowLeft className="text-xl" />
<UserList user={user} filters={filters.map((f) => f.filter)} /> <span>Back</span>
</Layout> </div>
)} <h2 className="text-2xl font-semibold">Users ({total})</h2>
</> </div>
); )}
/>
</Layout>
)}
</>
);
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,62 +1,51 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Head from "next/head"; import Head from "next/head";
import { withIronSessionSsr } from "iron-session/next"; import {withIronSessionSsr} from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
import PaymentDue from "./(status)/PaymentDue"; import PaymentDue from "./(status)/PaymentDue";
import { useRouter } from "next/router"; import {useRouter} from "next/router";
export const getServerSideProps = withIronSessionSsr(({ req, res }) => { export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user; const user = req.session.user;
const envVariables: { [key: string]: string } = {}; const envVariables: {[key: string]: string} = {};
Object.keys(process.env) Object.keys(process.env)
.filter((x) => x.startsWith("NEXT_PUBLIC")) .filter((x) => x.startsWith("NEXT_PUBLIC"))
.forEach((x: string) => { .forEach((x: string) => {
envVariables[x] = process.env[x]!; envVariables[x] = process.env[x]!;
}); });
if (!user || !user.isVerified) { if (!user || !user.isVerified) {
return { return {
redirect: { redirect: {
destination: "/login", destination: "/login",
permanent: false, permanent: false,
} },
}; };
} }
return { return {
props: { user: req.session.user, envVariables }, props: {user: req.session.user, envVariables},
}; };
}, sessionOptions); }, sessionOptions);
export default function Home({ export default function Home({envVariables}: {envVariables: {[key: string]: string}}) {
envVariables, const {user} = useUser({redirectTo: "/login"});
}: { const router = useRouter();
envVariables: { [key: string]: string };
}) {
const { user } = useUser({ redirectTo: "/login" });
const router = useRouter();
return ( return (
<> <>
<Head> <Head>
<title>EnCoach</title> <title>EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/> />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
{user && ( {user && <PaymentDue user={user} reload={router.reload} />}
<PaymentDue </>
key={envVariables["NEXT_PUBLIC_PAYPAL_CLIENT_ID"]} );
clientID={envVariables["NEXT_PUBLIC_PAYPAL_CLIENT_ID"] || ""}
user={user}
reload={router.reload}
/>
)}
</>
);
} }

View File

@@ -0,0 +1,209 @@
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import { useState } from "react";
import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { Permission, PermissionType } from "@/interfaces/permissions";
import { getPermissionDoc } from "@/utils/permissions.be";
import { User } from "@/interfaces/user";
import Layout from "@/components/High/Layout";
import { getUsers } from "@/utils/users.be";
import { BsTrash } from "react-icons/bs";
import Select from "@/components/Low/Select";
import Button from "@/components/Low/Button";
import axios from "axios";
import { toast, ToastContainer } from "react-toastify";
import {Type as UserType} from '@/interfaces/user'
interface BasicUser {
id: string;
name: string;
type: UserType
}
interface PermissionWithBasicUsers {
id: string;
type: PermissionType;
users: BasicUser[];
}
export const getServerSideProps = withIronSessionSsr(async (context) => {
const { req, params } = context;
const user = req.session.user;
if (!user || !user.isVerified) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (shouldRedirectHome(user)) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
if (!params?.id) {
return {
redirect: {
destination: "/permissions",
permanent: false,
},
};
}
// Fetch data from external API
const permission: Permission = await getPermissionDoc(params.id as string);
const allUserData: User[] = await getUsers();
const users = allUserData.map((u) => ({
id: u.id,
name: u.name,
type: u.type
})) as BasicUser[];
// const res = await fetch("api/permissions");
// const permissions: Permission[] = await res.json();
// Pass data to the page via props
const usersData: BasicUser[] = permission.users.reduce(
(acc: BasicUser[], userId) => {
const user = users.find((u) => u.id === userId) as BasicUser;
if (user) {
acc.push(user);
}
return acc;
},
[]
);
return {
props: {
// permissions: permissions.map((p) => ({ id: p.id, type: p.type })),
permission: {
...permission,
id: params.id,
users: usersData,
},
user: req.session.user,
users,
},
};
}, sessionOptions);
interface Props {
permission: PermissionWithBasicUsers;
user: User;
users: BasicUser[];
}
export default function Page(props: Props) {
const { permission, user, users } = props;
const [selectedUsers, setSelectedUsers] = useState<string[]>(() =>
permission.users.map((u) => u.id)
);
const onChange = (value: any) => {
setSelectedUsers((prev) => {
if (value?.value) {
return [...prev, value?.value];
}
return prev;
});
};
const removeUser = (id: string) => {
setSelectedUsers((prev) => prev.filter((u) => u !== id));
};
const update = async () => {
try {
await axios.patch(`/api/permissions/${permission.id}`, {
users: selectedUsers,
});
toast.success("Permission updated");
} catch (err) {
toast.error("Failed to update permission");
}
};
return (
<>
<Head>
<title>EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<Layout user={user} className="gap-6">
<h1 className="text-2xl font-semibold">
Permission: {permission.type as string}
</h1>
<div className="flex gap-3">
<Select
value={null}
options={users
.filter((u) => !selectedUsers.includes(u.id))
.map((u) => ({
label: `${u?.type}-${u?.name}`,
value: u.id,
}))}
onChange={onChange}
/>
<Button onClick={update}>Update</Button>
</div>
<div className="flex flex-row justify-between">
<div className="flex flex-col gap-3">
<h2>Blacklisted Users</h2>
<div className="flex gap-3 flex-wrap">
{selectedUsers.map((userId) => {
const user = users.find((u) => u.id === userId);
return (
<div
className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4"
key={userId}
>
<span className="text-base first-letter:uppercase">{user?.type}-{user?.name}</span>
<BsTrash
style={{ cursor: "pointer" }}
onClick={() => removeUser(userId)}
size={20}
/>
</div>
);
})}
</div>
</div>
<div className="flex flex-col gap-3">
<h2>Whitelisted Users</h2>
<div className="flex flex-col gap-3 flex-wrap">
{users.filter(user => !selectedUsers.includes(user.id)).map((user) => {
return (
<div
className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4"
key={user.id}
>
<span className="text-base first-letter:uppercase">{user?.type}-{user?.name}</span>
</div>
);
})}
</div>
</div>
</div>
</Layout>
</>
);
}

View File

@@ -0,0 +1,80 @@
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { Permission } from "@/interfaces/permissions";
import { getPermissionDocs } from "@/utils/permissions.be";
import { User } from "@/interfaces/user";
import Layout from "@/components/High/Layout";
import PermissionList from '@/components/PermissionList'
export const getServerSideProps = withIronSessionSsr(async ({ req }) => {
const user = req.session.user;
if (!user || !user.isVerified) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (shouldRedirectHome(user)) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
// Fetch data from external API
const permissions: Permission[] = await getPermissionDocs();
// const res = await fetch("api/permissions");
// const permissions: Permission[] = await res.json();
// Pass data to the page via props
return {
props: {
// permissions: permissions.map((p) => ({ id: p.id, type: p.type })),
permissions: permissions.map((p) => {
const { users, ...rest } = p;
return rest;
}),
user: req.session.user,
},
};
}, sessionOptions);
interface Props {
permissions: Permission[];
user: User;
}
export default function Page(props: Props) {
const { permissions, user } = props;
return (
<>
<Head>
<title>EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<Layout user={user} className="gap-6">
<h1 className="text-2xl font-semibold">Permissions</h1>
<div className="flex gap-3 flex-wrap">
<PermissionList permissions={permissions} />
</div>
</Layout>
</>
);
}

View File

@@ -26,6 +26,7 @@ import {
EMPLOYMENT_STATUS, EMPLOYMENT_STATUS,
Gender, Gender,
User, User,
DemographicInformation,
} from "@/interfaces/user"; } from "@/interfaces/user";
import CountrySelect from "@/components/Low/CountrySelect"; import CountrySelect from "@/components/Low/CountrySelect";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
@@ -47,6 +48,8 @@ import { InstructorGender } from "@/interfaces/exam";
import { capitalize } from "lodash"; import { capitalize } from "lodash";
import TopicModal from "@/components/Medium/TopicModal"; import TopicModal from "@/components/Medium/TopicModal";
import { v4 } from "uuid"; import { v4 } from "uuid";
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
export const getServerSideProps = withIronSessionSsr(({ req, res }) => { export const getServerSideProps = withIronSessionSsr(({ req, res }) => {
const user = req.session.user; const user = req.session.user;
@@ -94,29 +97,28 @@ function UserProfile({ user, mutateUser }: Props) {
const [desiredLevels, setDesiredLevels] = useState< const [desiredLevels, setDesiredLevels] = useState<
{ [key in Module]: number } | undefined { [key in Module]: number } | undefined
>( >(
["developer", "student"].includes(user.type) checkAccess(user, ["developer", "student"]) ? user.desiredLevels : undefined
? user.desiredLevels
: undefined,
); );
const [focus, setFocus] = useState<"academic" | "general">(user.focus);
const [country, setCountry] = useState<string>( const [country, setCountry] = useState<string>(
user.demographicInformation?.country || "", user.demographicInformation?.country || ""
); );
const [phone, setPhone] = useState<string>( const [phone, setPhone] = useState<string>(
user.demographicInformation?.phone || "", user.demographicInformation?.phone || ""
); );
const [gender, setGender] = useState<Gender | undefined>( const [gender, setGender] = useState<Gender | undefined>(
user.demographicInformation?.gender || undefined, user.demographicInformation?.gender || undefined
); );
const [employment, setEmployment] = useState<EmploymentStatus | undefined>( const [employment, setEmployment] = useState<EmploymentStatus | undefined>(
user.type === "corporate" checkAccess(user, ["corporate", "mastercorporate"])
? undefined ? undefined
: user.demographicInformation?.employment, : (user.demographicInformation as DemographicInformation)?.employment
); );
const [passport_id, setPassportID] = useState<string | undefined>( const [passport_id, setPassportID] = useState<string | undefined>(
user.type === "student" checkAccess(user, ["student"])
? user.demographicInformation?.passport_id ? (user.demographicInformation as DemographicInformation)?.passport_id
: undefined, : undefined
); );
const [preferredGender, setPreferredGender] = useState< const [preferredGender, setPreferredGender] = useState<
@@ -124,38 +126,38 @@ function UserProfile({ user, mutateUser }: Props) {
>( >(
user.type === "student" || user.type === "developer" user.type === "student" || user.type === "developer"
? user.preferredGender || "varied" ? user.preferredGender || "varied"
: undefined, : undefined
); );
const [preferredTopics, setPreferredTopics] = useState<string[] | undefined>( const [preferredTopics, setPreferredTopics] = useState<string[] | undefined>(
user.type === "student" || user.type === "developer" user.type === "student" || user.type === "developer"
? user.preferredTopics ? user.preferredTopics
: undefined, : undefined
); );
const [position, setPosition] = useState<string | undefined>( const [position, setPosition] = useState<string | undefined>(
user.type === "corporate" user.type === "corporate"
? user.demographicInformation?.position ? user.demographicInformation?.position
: undefined, : undefined
); );
const [corporateInformation, setCorporateInformation] = useState( const [corporateInformation, setCorporateInformation] = useState(
user.type === "corporate" ? user.corporateInformation : undefined, user.type === "corporate" ? user.corporateInformation : undefined
); );
const [companyName, setCompanyName] = useState<string | undefined>( const [companyName, setCompanyName] = useState<string | undefined>(
user.type === "agent" ? user.agentInformation?.companyName : undefined, user.type === "agent" ? user.agentInformation?.companyName : undefined
); );
const [commercialRegistration, setCommercialRegistration] = useState< const [commercialRegistration, setCommercialRegistration] = useState<
string | undefined string | undefined
>( >(
user.type === "agent" user.type === "agent"
? user.agentInformation?.commercialRegistration ? user.agentInformation?.commercialRegistration
: undefined, : undefined
); );
const [arabName, setArabName] = useState<string | undefined>( const [arabName, setArabName] = useState<string | undefined>(
user.type === "agent" ? user.agentInformation?.companyArabName : undefined, user.type === "agent" ? user.agentInformation?.companyArabName : undefined
); );
const [timezone, setTimezone] = useState<string>( const [timezone, setTimezone] = useState<string>(
user.demographicInformation?.timezone || moment.tz.guess(), user.demographicInformation?.timezone || moment.tz.guess()
); );
const [isPreferredTopicsOpen, setIsPreferredTopicsOpen] = useState(false); const [isPreferredTopicsOpen, setIsPreferredTopicsOpen] = useState(false);
@@ -194,7 +196,7 @@ function UserProfile({ user, mutateUser }: Props) {
if (newPassword && !password) { if (newPassword && !password) {
toast.error( toast.error(
"To update your password you need to input your current one!", "To update your password you need to input your current one!"
); );
setIsLoading(false); setIsLoading(false);
return; return;
@@ -227,6 +229,7 @@ function UserProfile({ user, mutateUser }: Props) {
desiredLevels, desiredLevels,
preferredGender, preferredGender,
preferredTopics, preferredTopics,
focus,
demographicInformation: { demographicInformation: {
phone, phone,
country, country,
@@ -277,7 +280,7 @@ function UserProfile({ user, mutateUser }: Props) {
!user.subscriptionExpirationDate !user.subscriptionExpirationDate
? "!bg-mti-green-ultralight !border-mti-green-light" ? "!bg-mti-green-ultralight !border-mti-green-light"
: expirationDateColor(user.subscriptionExpirationDate), : expirationDateColor(user.subscriptionExpirationDate),
"bg-white border-mti-gray-platinum", "bg-white border-mti-gray-platinum"
)} )}
> >
{!user.subscriptionExpirationDate && "Unlimited"} {!user.subscriptionExpirationDate && "Unlimited"}
@@ -297,7 +300,7 @@ function UserProfile({ user, mutateUser }: Props) {
); );
const manualDownloadLink = ["student", "teacher", "corporate"].includes( const manualDownloadLink = ["student", "teacher", "corporate"].includes(
user.type, user.type
) )
? `/manuals/${user.type}.pdf` ? `/manuals/${user.type}.pdf`
: ""; : "";
@@ -445,19 +448,52 @@ function UserProfile({ user, mutateUser }: Props) {
{desiredLevels && {desiredLevels &&
["developer", "student"].includes(user.type) && ( ["developer", "student"].includes(user.type) && (
<div className="flex flex-col gap-3 w-full"> <>
<label className="font-normal text-base text-mti-gray-dim"> <div className="flex flex-col gap-3 w-full">
Desired Levels <label className="font-normal text-base text-mti-gray-dim">
</label> Desired Levels
<ModuleLevelSelector </label>
levels={desiredLevels} <ModuleLevelSelector
setLevels={ levels={desiredLevels}
setDesiredLevels as Dispatch< setLevels={
SetStateAction<{ [key in Module]: number }> setDesiredLevels as Dispatch<
SetStateAction<{ [key in Module]: number }>
>
}
/>
</div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">
Focus
</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-16 w-full">
<button
onClick={() => setFocus("academic")}
className={clsx(
"w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-center items-center gap-12 bg-white",
"hover:bg-mti-purple-light hover:text-white",
focus === "academic" &&
"!bg-mti-purple-light !text-white",
"transition duration-300 ease-in-out"
)}
> >
} Academic
/> </button>
</div> <button
onClick={() => setFocus("general")}
className={clsx(
"w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-center items-center gap-12 bg-white",
"hover:bg-mti-purple-light hover:text-white",
focus === "general" &&
"!bg-mti-purple-light !text-white",
"transition duration-300 ease-in-out"
)}
>
General
</button>
</div>
</div>
</>
)} )}
{preferredGender && {preferredGender &&
@@ -477,7 +513,7 @@ function UserProfile({ user, mutateUser }: Props) {
onChange={(value) => onChange={(value) =>
value value
? setPreferredGender( ? setPreferredGender(
value.value as InstructorGender, value.value as InstructorGender
) )
: null : null
} }
@@ -586,7 +622,7 @@ function UserProfile({ user, mutateUser }: Props) {
defaultValue={ defaultValue={
users.find( users.find(
(x) => (x) =>
x.id === user.corporateInformation.referralAgent, x.id === user.corporateInformation.referralAgent
)?.name )?.name
} }
type="text" type="text"
@@ -601,7 +637,7 @@ function UserProfile({ user, mutateUser }: Props) {
defaultValue={ defaultValue={
users.find( users.find(
(x) => (x) =>
x.id === user.corporateInformation.referralAgent, x.id === user.corporateInformation.referralAgent
)?.email )?.email
} }
type="text" type="text"
@@ -620,8 +656,7 @@ function UserProfile({ user, mutateUser }: Props) {
value={ value={
users.find( users.find(
(x) => (x) =>
x.id === x.id === user.corporateInformation.referralAgent
user.corporateInformation.referralAgent,
)?.demographicInformation?.country )?.demographicInformation?.country
} }
onChange={() => null} onChange={() => null}
@@ -638,7 +673,7 @@ function UserProfile({ user, mutateUser }: Props) {
defaultValue={ defaultValue={
users.find( users.find(
(x) => (x) =>
x.id === user.corporateInformation.referralAgent, x.id === user.corporateInformation.referralAgent
)?.demographicInformation?.phone )?.demographicInformation?.phone
} }
disabled disabled
@@ -672,7 +707,7 @@ function UserProfile({ user, mutateUser }: Props) {
<div <div
className={clsx( className={clsx(
"absolute top-0 left-0 bg-mti-purple-light/60 w-full h-full z-20 flex items-center justify-center opacity-0 group-hover:opacity-100", "absolute top-0 left-0 bg-mti-purple-light/60 w-full h-full z-20 flex items-center justify-center opacity-0 group-hover:opacity-100",
"transition ease-in-out duration-300", "transition ease-in-out duration-300"
)} )}
> >
<BsCamera className="text-6xl text-mti-purple-ultralight/80" /> <BsCamera className="text-6xl text-mti-purple-ultralight/80" />

View File

@@ -18,13 +18,15 @@ import {sortByModule} from "@/utils/moduleUtils";
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import clsx from "clsx"; import clsx from "clsx";
import {calculateBandScore} from "@/utils/score"; import {calculateBandScore} from "@/utils/score";
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs"; import {BsBook, BsClipboard, BsClock, BsHeadphones, BsMegaphone, BsPen, BsPersonDash, BsPersonFillX, BsXCircle} from "react-icons/bs";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import {shouldRedirectHome} from "@/utils/navigation.disabled"; import {shouldRedirectHome} from "@/utils/navigation.disabled";
import useAssignments from "@/hooks/useAssignments"; import useAssignments from "@/hooks/useAssignments";
import {uuidv4} from "@firebase/util"; import {uuidv4} from "@firebase/util";
import {usePDFDownload} from "@/hooks/usePDFDownload"; import {usePDFDownload} from "@/hooks/usePDFDownload";
import useRecordStore from "@/stores/recordStore";
import ai_usage from "@/utils/ai.detection";
export const getServerSideProps = withIronSessionSsr(({req, res}) => { export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user; const user = req.session.user;
@@ -52,20 +54,30 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => {
}; };
}, sessionOptions); }, sessionOptions);
const defaultSelectableCorporate = {
value: "",
label: "All",
};
export default function History({user}: {user: User}) { export default function History({user}: {user: User}) {
const [statsUserId, setStatsUserId] = useState<string | undefined>(user.id); const [statsUserId, setStatsUserId] = useRecordStore((state) => [state.selectedUser, state.setSelectedUser]);
// const [statsUserId, setStatsUserId] = useState<string | undefined>(user.id);
const [groupedStats, setGroupedStats] = useState<{[key: string]: Stat[]}>(); const [groupedStats, setGroupedStats] = useState<{[key: string]: Stat[]}>();
const [filter, setFilter] = useState<"months" | "weeks" | "days" | "assignments">(); const [filter, setFilter] = useState<"months" | "weeks" | "days" | "assignments">();
const {assignments} = useAssignments({}); const {assignments} = useAssignments({});
const {users} = useUsers(); const {users} = useUsers();
const {stats, isLoading: isStatsLoading} = useStats(statsUserId); const {stats, isLoading: isStatsLoading} = useStats(statsUserId);
const {groups} = useGroups(user.id); const {groups: allGroups} = useGroups();
const groups = allGroups.filter((x) => x.admin === user.id);
const setExams = useExamStore((state) => state.setExams); const setExams = useExamStore((state) => state.setExams);
const setShowSolutions = useExamStore((state) => state.setShowSolutions); const setShowSolutions = useExamStore((state) => state.setShowSolutions);
const setUserSolutions = useExamStore((state) => state.setUserSolutions); const setUserSolutions = useExamStore((state) => state.setUserSolutions);
const setSelectedModules = useExamStore((state) => state.setSelectedModules); const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const setInactivity = useExamStore((state) => state.setInactivity);
const setTimeSpent = useExamStore((state) => state.setTimeSpent);
const router = useRouter(); const router = useRouter();
const renderPdfIcon = usePDFDownload("stats"); const renderPdfIcon = usePDFDownload("stats");
@@ -87,6 +99,11 @@ export default function History({user}: {user: User}) {
} }
}, [stats, isStatsLoading]); }, [stats, isStatsLoading]);
// useEffect(() => {
// // just set this initially
// if (!statsUserId) setStatsUserId(user.id);
// }, []);
const toggleFilter = (value: "months" | "weeks" | "days" | "assignments") => { const toggleFilter = (value: "months" | "weeks" | "days" | "assignments") => {
setFilter((prev) => (prev === value ? undefined : value)); setFilter((prev) => (prev === value ? undefined : value));
}; };
@@ -127,7 +144,9 @@ export default function History({user}: {user: User}) {
}; };
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}} = { const scores: {
[key in Module]: {total: number; missing: number; correct: number};
} = {
reading: { reading: {
total: 0, total: 0,
correct: 0, correct: 0,
@@ -179,12 +198,14 @@ export default function History({user}: {user: User}) {
const assignment = assignments.find((a) => a.id === assignmentID); const assignment = assignments.find((a) => a.id === assignmentID);
const isDisabled = dateStats.some((x) => x.isDisabled); const isDisabled = dateStats.some((x) => x.isDisabled);
const aiUsage = Math.round(ai_usage(dateStats) * 100);
const aggregatedLevels = aggregatedScores.map((x) => ({ const aggregatedLevels = aggregatedScores.map((x) => ({
module: x.module, module: x.module,
level: calculateBandScore(x.correct, x.total, x.module, user.focus), level: calculateBandScore(x.correct, x.total, x.module, user.focus),
})); }));
const {timeSpent, session} = dateStats[0]; const {timeSpent, inactivity, session} = dateStats[0];
const selectExam = () => { const selectExam = () => {
const examPromises = uniqBy(dateStats, "exam").map((stat) => { const examPromises = uniqBy(dateStats, "exam").map((stat) => {
@@ -192,8 +213,13 @@ export default function History({user}: {user: User}) {
return getExamById(stat.module, stat.exam); return getExamById(stat.module, stat.exam);
}); });
if (isDisabled) return;
Promise.all(examPromises).then((exams) => { Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) { if (exams.every((x) => !!x)) {
if (!!timeSpent) setTimeSpent(timeSpent);
if (!!inactivity) setInactivity(inactivity);
setUserSolutions(convertToUserSolutions(dateStats)); setUserSolutions(convertToUserSolutions(dateStats));
setShowSolutions(true); setShowSolutions(true);
setExams(exams.map((x) => x!).sort(sortByModule)); setExams(exams.map((x) => x!).sort(sortByModule));
@@ -217,21 +243,40 @@ export default function History({user}: {user: User}) {
const content = ( const content = (
<> <>
<div className="w-full flex justify-between -md:items-center 2xl:items-center"> <div className="w-full flex justify-between -md:items-center 2xl:items-center">
<div className="flex md:flex-col 2xl:flex-row md:gap-1 -md:gap-2 2xl:gap-2 -md:items-center 2xl:items-center"> <div className="flex flex-col md:gap-1 -md:gap-2 2xl:gap-2">
<span className="font-medium">{formatTimestamp(timestamp)}</span> <span className="font-medium">{formatTimestamp(timestamp)}</span>
{timeSpent && ( <div className="flex items-center gap-2">
<> {!!timeSpent && (
<span className="md:hidden 2xl:flex"> </span> <span className="text-sm flex gap-2 items-center tooltip" data-tip="Time Spent">
<span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span> <BsClock /> {Math.floor(timeSpent / 60)} minutes
</> </span>
)} )}
{!!inactivity && (
<span className="text-sm flex gap-2 items-center tooltip" data-tip="Inactivity">
<BsXCircle /> {Math.floor(inactivity / 60)} minutes
</span>
)}
</div>
</div> </div>
<div className="flex flex-row gap-2"> <div className="flex flex-col gap-2">
<span className={textColor}> <div className="flex flex-row gap-2">
Level{" "} <span className={textColor}>
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)} Level{" "}
</span> {(
{renderPdfIcon(session, textColor, textColor)} aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length
).toFixed(1)}
</span>
{renderPdfIcon(session, textColor, textColor)}
</div>
{aiUsage >= 50 && user.type !== "student" && (
<div
className={clsx("ml-auto border px-1 rounded w-fit mr-1", {
"bg-orange-100 border-orange-400 text-orange-700": aiUsage < 80,
"bg-red-100 border-red-400 text-red-700": aiUsage >= 80,
})}>
<span className="text-xs">AI Usage</span>
</div>
)}
</div> </div>
</div> </div>
@@ -272,13 +317,13 @@ export default function History({user}: {user: User}) {
<div <div
key={uuidv4()} key={uuidv4()}
className={clsx( className={clsx(
"flex flex-col gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:hidden", "flex flex-col justify-between gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:hidden",
isDisabled && "grayscale tooltip", isDisabled && "grayscale tooltip",
correct / total >= 0.7 && "hover:border-mti-purple", 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", correct / total < 0.3 && "hover:border-mti-rose",
)} )}
onClick={isDisabled ? () => null : selectExam} onClick={selectExam}
data-tip="This exam is still being evaluated..." data-tip="This exam is still being evaluated..."
role="button"> role="button">
{content} {content}
@@ -299,6 +344,63 @@ export default function History({user}: {user: User}) {
); );
}; };
const selectableCorporates = [
defaultSelectableCorporate,
...users
.filter((x) => x.type === "corporate")
.map((x) => ({
value: x.id,
label: `${x.name} - ${x.email}`,
})),
];
const [selectedCorporate, setSelectedCorporate] = useState<string>(defaultSelectableCorporate.value);
const getUsersList = (): User[] => {
if (selectedCorporate) {
// get groups for that corporate
const selectedCorporateGroups = allGroups.filter((x) => x.admin === selectedCorporate);
// get the teacher ids for that group
const selectedCorporateGroupsParticipants = selectedCorporateGroups.flatMap((x) => x.participants);
// // search for groups for these teachers
// const teacherGroups = allGroups.filter((x) => {
// return selectedCorporateGroupsParticipants.includes(x.admin);
// });
// const usersList = [
// ...selectedCorporateGroupsParticipants,
// ...teacherGroups.flatMap((x) => x.participants),
// ];
const userListWithUsers = selectedCorporateGroupsParticipants.map((x) => users.find((y) => y.id === x)) as User[];
return userListWithUsers.filter((x) => x);
}
return users || [];
};
const corporateFilteredUserList = getUsersList();
const getSelectedUser = () => {
if (selectedCorporate) {
const userInCorporate = corporateFilteredUserList.find((x) => x.id === statsUserId);
return userInCorporate || corporateFilteredUserList[0];
}
return users.find((x) => x.id === statsUserId) || user;
};
const selectedUser = getSelectedUser();
const selectedUserSelectValue = selectedUser
? {
value: selectedUser.id,
label: `${selectedUser.name} - ${selectedUser.email}`,
}
: {
value: "",
label: "",
};
return ( return (
<> <>
<Head> <Head>
@@ -316,36 +418,64 @@ export default function History({user}: {user: User}) {
<div className="w-full flex -xl:flex-col -xl:gap-4 justify-between items-center"> <div className="w-full flex -xl:flex-col -xl:gap-4 justify-between items-center">
<div className="xl:w-3/4"> <div className="xl:w-3/4">
{(user.type === "developer" || user.type === "admin") && ( {(user.type === "developer" || user.type === "admin") && (
<Select <>
options={users.map((x) => ({value: x.id, label: `${x.name} - ${x.email}`}))} <label className="font-normal text-base text-mti-gray-dim">Corporate</label>
defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}}
onChange={(value) => setStatsUserId(value?.value)} <Select
styles={{ options={selectableCorporates}
menuPortal: (base) => ({...base, zIndex: 9999}), value={selectableCorporates.find((x) => x.value === selectedCorporate)}
option: (styles, state) => ({ onChange={(value) => setSelectedCorporate(value?.value || "")}
...styles, styles={{
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", menuPortal: (base) => ({...base, zIndex: 9999}),
color: state.isFocused ? "black" : styles.color, option: (styles, state) => ({
}), ...styles,
}} backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
/> color: state.isFocused ? "black" : styles.color,
}),
}}></Select>
<label className="font-normal text-base text-mti-gray-dim">User</label>
<Select
options={corporateFilteredUserList.map((x) => ({
value: x.id,
label: `${x.name} - ${x.email}`,
}))}
value={selectedUserSelectValue}
onChange={(value) => setStatsUserId(value?.value!)}
styles={{
menuPortal: (base) => ({...base, zIndex: 9999}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
</>
)} )}
{(user.type === "corporate" || user.type === "teacher") && groups.length > 0 && ( {(user.type === "corporate" || user.type === "teacher") && groups.length > 0 && (
<Select <>
options={users <label className="font-normal text-base text-mti-gray-dim">User</label>
.filter((x) => groups.flatMap((y) => y.participants).includes(x.id))
.map((x) => ({value: x.id, label: `${x.name} - ${x.email}`}))} <Select
defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}} options={users
onChange={(value) => setStatsUserId(value?.value)} .filter((x) => groups.flatMap((y) => y.participants).includes(x.id))
styles={{ .map((x) => ({
menuPortal: (base) => ({...base, zIndex: 9999}), value: x.id,
option: (styles, state) => ({ label: `${x.name} - ${x.email}`,
...styles, }))}
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", value={selectedUserSelectValue}
color: state.isFocused ? "black" : styles.color, onChange={(value) => setStatsUserId(value?.value!)}
}), styles={{
}} menuPortal: (base) => ({...base, zIndex: 9999}),
/> option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
</>
)} )}
</div> </div>
<div className="flex gap-4 w-full justify-center xl:justify-end"> <div className="flex gap-4 w-full justify-center xl:justify-end">

View File

@@ -26,7 +26,7 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => {
}; };
} }
if (shouldRedirectHome(user) || !["developer", "admin", "corporate", "agent"].includes(user.type)) { if (shouldRedirectHome(user) || !["developer", "admin", "corporate", "agent", "mastercorporate"].includes(user.type)) {
return { return {
redirect: { redirect: {
destination: "/", destination: "/",

Some files were not shown because too many files have changed in this diff Show More