Compare commits
1 Commits
feature/up
...
feature-ge
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7962857a95 |
5
.gitignore
vendored
@@ -1,5 +1,3 @@
|
||||
src/constants/test_firebase.json
|
||||
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
@@ -39,5 +37,4 @@ next-env.d.ts
|
||||
|
||||
.env
|
||||
.yarn/*
|
||||
.history*
|
||||
__ENV.js
|
||||
.history*
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
yarn build
|
||||
28
.vscode/launch.json
vendored
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Next.js: debug server-side",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "npm run dev"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug client-side",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:3000"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug full stack",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "npm run dev",
|
||||
"serverReadyAction": {
|
||||
"pattern": "- Local:.+(https?://.+)",
|
||||
"uriFormat": "%s",
|
||||
"action": "debugWithChrome"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,57 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const websiteUrl = process.env.NODE_ENV === 'production' ? "https://encoach.com" : "http://localhost:3000";
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
output: "standalone",
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/api/packages",
|
||||
headers: [
|
||||
{key: "Access-Control-Allow-Credentials", value: "false"},
|
||||
{key: "Access-Control-Allow-Origin", value: websiteUrl},
|
||||
{
|
||||
key: "Access-Control-Allow-Methods",
|
||||
value: "GET",
|
||||
},
|
||||
{
|
||||
key: "Access-Control-Allow-Headers",
|
||||
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/api/tickets",
|
||||
headers: [
|
||||
{key: "Access-Control-Allow-Credentials", value: "false"},
|
||||
{key: "Access-Control-Allow-Origin", value: websiteUrl},
|
||||
{
|
||||
key: "Access-Control-Allow-Methods",
|
||||
value: "POST,OPTIONS",
|
||||
},
|
||||
{
|
||||
key: "Access-Control-Allow-Headers",
|
||||
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/api/users/agents",
|
||||
headers: [
|
||||
{key: "Access-Control-Allow-Credentials", value: "false"},
|
||||
{key: "Access-Control-Allow-Origin", value: websiteUrl},
|
||||
{
|
||||
key: "Access-Control-Allow-Methods",
|
||||
value: "POST,OPTIONS",
|
||||
},
|
||||
{
|
||||
key: "Access-Control-Allow-Headers",
|
||||
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
7590
package-lock.json
generated
42
package.json
@@ -6,96 +6,56 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"prepare": "husky install"
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@beam-australia/react-env": "^3.1.1",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@firebase/util": "^1.9.7",
|
||||
"@headlessui/react": "^1.7.13",
|
||||
"@mdi/js": "^7.1.96",
|
||||
"@mdi/react": "^1.6.1",
|
||||
"@next/font": "13.1.6",
|
||||
"@paypal/paypal-js": "^7.1.0",
|
||||
"@paypal/react-paypal-js": "^8.1.3",
|
||||
"@react-pdf/renderer": "^3.1.14",
|
||||
"@tanstack/react-table": "^8.10.1",
|
||||
"@types/node": "18.13.0",
|
||||
"@types/react": "18.0.27",
|
||||
"@types/react-dom": "18.0.10",
|
||||
"axios": "^1.3.5",
|
||||
"bcrypt": "^5.1.1",
|
||||
"chart.js": "^4.2.1",
|
||||
"clsx": "^1.2.1",
|
||||
"countries-list": "^3.0.1",
|
||||
"country-codes-list": "^1.6.11",
|
||||
"currency-symbol-map": "^5.1.0",
|
||||
"daisyui": "^3.1.5",
|
||||
"eslint": "8.33.0",
|
||||
"eslint-config-next": "13.1.6",
|
||||
"express-handlebars": "^7.1.2",
|
||||
"firebase": "9.19.1",
|
||||
"firebase-admin": "^11.10.1",
|
||||
"formidable": "^3.5.0",
|
||||
"formidable-serverless": "^1.1.1",
|
||||
"framer-motion": "^9.0.2",
|
||||
"howler": "^2.2.4",
|
||||
"iron-session": "^6.3.1",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.4",
|
||||
"moment-timezone": "^0.5.44",
|
||||
"next": "13.1.6",
|
||||
"nodemailer": "^6.9.5",
|
||||
"nodemailer-express-handlebars": "^6.1.0",
|
||||
"primeicons": "^6.0.1",
|
||||
"primereact": "^9.2.3",
|
||||
"qrcode": "^1.5.3",
|
||||
"random-words": "^2.0.0",
|
||||
"react": "18.2.0",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-csv": "^2.2.2",
|
||||
"react-currency-input-field": "^3.6.12",
|
||||
"react-datepicker": "^4.18.0",
|
||||
"react-diff-viewer": "^3.1.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-firebase-hooks": "^5.1.1",
|
||||
"react-icons": "^4.8.0",
|
||||
"react-lineto": "^3.3.0",
|
||||
"react-media-recorder": "1.6.5",
|
||||
"react-phone-number-input": "^3.3.6",
|
||||
"react-player": "^2.12.0",
|
||||
"react-select": "^5.7.5",
|
||||
"react-string-replace": "^1.1.0",
|
||||
"react-toastify": "^9.1.2",
|
||||
"react-tooltip": "^5.27.1",
|
||||
"react-xarrows": "^2.0.2",
|
||||
"read-excel-file": "^5.7.1",
|
||||
"short-unique-id": "5.0.2",
|
||||
"stripe": "^13.10.0",
|
||||
"swr": "^2.1.3",
|
||||
"tailwind-scrollbar-hide": "^1.1.7",
|
||||
"typescript": "4.9.5",
|
||||
"use-file-picker": "^2.1.0",
|
||||
"uuid": "^9.0.0",
|
||||
"wavesurfer.js": "^6.6.4",
|
||||
"zustand": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/blob-stream": "^0.1.33",
|
||||
"@types/formidable": "^3.4.0",
|
||||
"@types/howler": "^2.2.11",
|
||||
"@types/lodash": "^4.14.191",
|
||||
"@types/nodemailer": "^6.4.11",
|
||||
"@types/nodemailer-express-handlebars": "^4.0.3",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react-csv": "^1.1.10",
|
||||
"@types/react-datepicker": "^4.15.1",
|
||||
"@types/uuid": "^9.0.1",
|
||||
"@types/wavesurfer.js": "^6.0.6",
|
||||
"@wixc3/react-board": "^2.2.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"husky": "^8.0.3",
|
||||
"postcss": "^8.4.21",
|
||||
"tailwindcss": "^3.2.4"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 13 KiB |
BIN
public/logo.png
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 48 KiB |
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 535 B |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
@@ -1,193 +0,0 @@
|
||||
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;
|
||||
@@ -3,53 +3,60 @@ import {Fragment} from "react";
|
||||
import Button from "./Low/Button";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
isOpen: boolean;
|
||||
abandonPopupTitle: string;
|
||||
abandonPopupDescription: string;
|
||||
abandonConfirmButtonText: string;
|
||||
onAbandon: () => void;
|
||||
onCancel: () => void;
|
||||
onAbandon: Function;
|
||||
onCancel: Function;
|
||||
}
|
||||
|
||||
export default function AbandonPopup({isOpen, abandonPopupTitle, abandonPopupDescription, abandonConfirmButtonText, onAbandon, onCancel}: Props) {
|
||||
return (
|
||||
<Transition show={isOpen} as={Fragment}>
|
||||
<Dialog onClose={onCancel} className="relative z-50">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0">
|
||||
<div className="fixed inset-0 bg-black/30" />
|
||||
</Transition.Child>
|
||||
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95">
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<Dialog.Panel className="w-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
|
||||
<Dialog.Title className="font-bold text-xl">{abandonPopupTitle}</Dialog.Title>
|
||||
<span>{abandonPopupDescription}</span>
|
||||
<div className="w-full flex justify-between mt-8">
|
||||
<Button color="purple" onClick={onCancel} variant="outline" className="max-w-[200px] self-end w-full">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="purple" onClick={onAbandon} className="max-w-[200px] self-end w-full">
|
||||
{abandonConfirmButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
export default function AbandonPopup({
|
||||
isOpen,
|
||||
abandonPopupTitle,
|
||||
abandonPopupDescription,
|
||||
abandonConfirmButtonText,
|
||||
onAbandon,
|
||||
onCancel,
|
||||
}: Props) {
|
||||
return (
|
||||
<Transition show={isOpen} as={Fragment}>
|
||||
<Dialog onClose={onCancel} className="relative z-50">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0">
|
||||
<div className="fixed inset-0 bg-black/30" />
|
||||
</Transition.Child>
|
||||
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95">
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<Dialog.Panel className="w-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
|
||||
<Dialog.Title className="font-bold text-xl">{abandonPopupTitle}</Dialog.Title>
|
||||
<span>{abandonPopupDescription}</span>
|
||||
<div className="w-full flex justify-between mt-8">
|
||||
<Button color="purple" onClick={onCancel} variant="outline" className="max-w-[200px] self-end w-full">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="purple" onClick={onAbandon} className="max-w-[200px] self-end w-full">
|
||||
{abandonConfirmButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import clsx from "clsx";
|
||||
import {IconType} from "react-icons";
|
||||
import {MdSpaceDashboard} from "react-icons/md";
|
||||
import {BsFileEarmarkText, BsClockHistory, BsPencil, BsGraphUp} from "react-icons/bs";
|
||||
import {RiLogoutBoxFill} from "react-icons/ri";
|
||||
import {SlPencil} from "react-icons/sl";
|
||||
import {FaAward} from "react-icons/fa";
|
||||
import Link from "next/link";
|
||||
import {useRouter} from "next/router";
|
||||
import axios from "axios";
|
||||
import FocusLayer from "@/components/FocusLayer";
|
||||
import {preventNavigation} from "@/utils/navigation.disabled";
|
||||
interface Props {
|
||||
path: string;
|
||||
navDisabled?: boolean;
|
||||
focusMode?: boolean;
|
||||
onFocusLayerMouseEnter?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface NavProps {
|
||||
Icon: IconType;
|
||||
label: string;
|
||||
path: string;
|
||||
keyPath: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const Nav = ({Icon, label, path, keyPath, disabled = false}: NavProps) => (
|
||||
<Link
|
||||
href={!disabled ? keyPath : ""}
|
||||
className={clsx(
|
||||
"p-4 rounded-full flex gap-4 items-center cursor-pointer text-gray-500 hover:bg-mti-purple-light hover:text-white transition duration-300 ease-in-out",
|
||||
path === keyPath && "bg-mti-purple-light text-white",
|
||||
)}>
|
||||
<Icon size={20} />
|
||||
</Link>
|
||||
);
|
||||
|
||||
export default function BottomBar({path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter, className}: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const logout = async () => {
|
||||
axios.post("/api/logout").finally(() => {
|
||||
setTimeout(() => router.reload(), 500);
|
||||
});
|
||||
};
|
||||
|
||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||
|
||||
return (
|
||||
<section className={clsx("w-full bg-white py-2 drop-shadow-2xl shadow-2xl rounded-t-2xl", className)}>
|
||||
<div className="flex justify-around gap-3">
|
||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" />
|
||||
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" />
|
||||
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" />
|
||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" />
|
||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" />
|
||||
</div>
|
||||
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import {EmploymentStatus, EMPLOYMENT_STATUS, Gender, User} from "@/interfaces/user";
|
||||
import {FormEvent, useEffect, useState} from "react";
|
||||
import countryCodes from "country-codes-list";
|
||||
import {RadioGroup} from "@headlessui/react";
|
||||
import Input from "./Low/Input";
|
||||
import clsx from "clsx";
|
||||
import Button from "./Low/Button";
|
||||
import {BsArrowRepeat} from "react-icons/bs";
|
||||
import axios from "axios";
|
||||
import {toast} from "react-toastify";
|
||||
import {KeyedMutator} from "swr";
|
||||
import CountrySelect from "./Low/CountrySelect";
|
||||
import GenderInput from "@/components/High/GenderInput";
|
||||
import EmploymentStatusInput from "@/components/High/EmploymentStatusInput";
|
||||
import TimezoneSelect from "./Low/TImezoneSelect";
|
||||
import moment from "moment";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
mutateUser: KeyedMutator<User>;
|
||||
}
|
||||
|
||||
export default function DemographicInformationInput({user, mutateUser}: Props) {
|
||||
const [country, setCountry] = useState<string>();
|
||||
const [phone, setPhone] = useState<string>();
|
||||
const [passport_id, setPassportID] = useState<string | undefined>(user.type === "student" ? user.demographicInformation?.passport_id : undefined);
|
||||
const [gender, setGender] = useState<Gender>();
|
||||
const [employment, setEmployment] = useState<EmploymentStatus>();
|
||||
const [position, setPosition] = useState<string>();
|
||||
const [timezone, setTimezone] = useState<string>(moment.tz.guess());
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [companyName, setCompanyName] = useState<string>();
|
||||
const [commercialRegistration, setCommercialRegistration] = useState<string>();
|
||||
|
||||
const save = (e?: FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
axios
|
||||
.patch("/api/users/update", {
|
||||
demographicInformation: {
|
||||
country,
|
||||
phone: `+${countryCodes.findOne("countryCode" as any, country!).countryCallingCode}${phone}`,
|
||||
gender,
|
||||
employment: user.type === "corporate" ? undefined : employment,
|
||||
position: user.type === "corporate" ? position : undefined,
|
||||
passport_id,
|
||||
timezone,
|
||||
},
|
||||
agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined,
|
||||
})
|
||||
.then((response) => mutateUser((response.data as {user: User}).user))
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"});
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-12 w-full">
|
||||
<h2 className="font-semibold text-center text-xl max-w-[800px]">
|
||||
Welcome to EnCoach, the ultimate platform dedicated to helping you master the IELTS ! We are thrilled that you have chosen us as your
|
||||
learning companion on this journey towards achieving your desired IELTS score.
|
||||
<br />
|
||||
<br />
|
||||
To make the most of your learning experience, we kindly request you to complete your profile. By providing some essential information
|
||||
about yourself.
|
||||
</h2>
|
||||
<form className="flex flex-col items-center justify-items-center gap-6 w-full h-full -md:px-4 lg:w-1/2 mb-32" onSubmit={save}>
|
||||
{user.type === "agent" && (
|
||||
<div className="w-full flex gap-8">
|
||||
<Input type="text" onChange={setCompanyName} name="companyName" label="Corporate Name" required />
|
||||
<Input
|
||||
type="text"
|
||||
onChange={setCommercialRegistration}
|
||||
name="commercialRegistration"
|
||||
label="Commercial Registration"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full grid grid-cols-2 gap-6">
|
||||
<div className="relative flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
||||
<CountrySelect value={country} onChange={setCountry} />
|
||||
</div>
|
||||
<Input type="tel" name="phone" label="Phone number" onChange={(e) => setPhone(e)} placeholder="Enter phone number" required />
|
||||
</div>
|
||||
{user.type === "student" && (
|
||||
<Input
|
||||
type="text"
|
||||
name="passport_id"
|
||||
label="Passport/National ID"
|
||||
onChange={(e) => setPassportID(e)}
|
||||
value={passport_id}
|
||||
placeholder="Enter National ID or Passport number"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Timezone</label>
|
||||
<TimezoneSelect value={timezone} onChange={setTimezone} />
|
||||
</div>
|
||||
|
||||
<GenderInput value={gender} onChange={setGender} />
|
||||
{user.type === "corporate" && (
|
||||
<Input name="position" onChange={setPosition} type="text" label="Position" placeholder="CEO, Head of Marketing..." required />
|
||||
)}
|
||||
{user.type !== "corporate" && <EmploymentStatusInput value={employment} onChange={setEmployment} />}
|
||||
</form>
|
||||
|
||||
<div className="self-end flex justify-end w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||
<Button
|
||||
className="lg:mt-8 max-w-[400px] w-full self-end"
|
||||
color="purple"
|
||||
onClick={save}
|
||||
disabled={
|
||||
isLoading ||
|
||||
!country ||
|
||||
!phone ||
|
||||
!gender ||
|
||||
(user.type === "corporate" ? !position : !employment) ||
|
||||
(user.type === "agent" ? !companyName || !commercialRegistration : false)
|
||||
}>
|
||||
{!isLoading && "Save information"}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,36 @@
|
||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import {BAND_SCORES} from "@/constants/ielts";
|
||||
import {Module} from "@/interfaces";
|
||||
import {User} from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {getExam, getExamById} from "@/utils/exams";
|
||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {writingMarking} from "@/utils/score";
|
||||
import {Menu} from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize} from "lodash";
|
||||
import {useRouter} from "next/router";
|
||||
import {useEffect, useState} from "react";
|
||||
import {useState} from "react";
|
||||
import {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen, BsQuestionSquare} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import Button from "./Low/Button";
|
||||
import ModuleLevelSelector from "./Medium/ModuleLevelSelector";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
onFinish: () => void;
|
||||
}
|
||||
|
||||
const DIAGNOSTIC_EXAMS = [
|
||||
["reading", "CurQtQoxWmHaJHeN0JW2"],
|
||||
["listening", "Y6cMao8kUcVnPQOo6teV"],
|
||||
["writing", "hbueuDaEZXV37EW7I12A"],
|
||||
["speaking", "QVFm4pdcziJQZN2iUTDo"],
|
||||
];
|
||||
|
||||
export default function Diagnostic({onFinish}: Props) {
|
||||
const [focus, setFocus] = useState<"academic" | "general">();
|
||||
const [levels, setLevels] = useState({reading: -1, listening: -1, writing: -1, speaking: -1, level: 0});
|
||||
const [desiredLevels, setDesiredLevels] = useState({reading: 9, listening: 9, writing: 9, speaking: 9, level: 9});
|
||||
const [levels, setLevels] = useState({reading: -1, listening: -1, writing: -1, speaking: -1});
|
||||
const [desiredLevels, setDesiredLevels] = useState({reading: 9, listening: 9, writing: 9, speaking: 9});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -37,13 +43,13 @@ export default function Diagnostic({onFinish}: Props) {
|
||||
};
|
||||
|
||||
const selectExam = () => {
|
||||
const examPromises = MODULE_ARRAY.map((module) => getExam(module, true, "partial"));
|
||||
const examPromises = DIAGNOSTIC_EXAMS.map((exam) => getExamById(exam[0] as Module, exam[1]));
|
||||
|
||||
Promise.all(examPromises).then((exams) => {
|
||||
if (exams.every((x) => !!x)) {
|
||||
setExams(exams.map((x) => x!));
|
||||
setSelectedModules(exams.map((x) => x!.module));
|
||||
router.push("/exercises");
|
||||
router.push("/exam");
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -52,7 +58,7 @@ export default function Diagnostic({onFinish}: Props) {
|
||||
axios
|
||||
.patch("/api/users/update", {
|
||||
focus,
|
||||
levels: Object.values(levels).includes(-1) ? {reading: 0, listening: 0, writing: 0, speaking: 0, level: 0} : levels,
|
||||
levels: Object.values(levels).includes(-1) ? {reading: -1, listening: -1, writing: -1, speaking: -1} : levels,
|
||||
desiredLevels,
|
||||
isFirstLogin: false,
|
||||
})
|
||||
@@ -67,7 +73,7 @@ export default function Diagnostic({onFinish}: Props) {
|
||||
<div className="flex flex-col items-center justify-center gap-8 w-full">
|
||||
<h2 className="font-semibold text-xl">What is your current focus?</h2>
|
||||
<div className="flex flex-col gap-16 w-full">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-16">
|
||||
<div className="grid grid-cols-2 gap-y-4 gap-x-16">
|
||||
<button
|
||||
onClick={() => setFocus("academic")}
|
||||
className={clsx(
|
||||
@@ -91,44 +97,131 @@ export default function Diagnostic({onFinish}: Props) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center gap-8 w-full">
|
||||
<h2 className="font-semibold text-xl">What is your current IELTS level?</h2>
|
||||
<ModuleLevelSelector levels={levels} setLevels={setLevels} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-16 w-full">
|
||||
<div className="grid grid-cols-2 gap-y-4 gap-x-16">
|
||||
<div className="w-full flex flex-col gap-3.5 relative">
|
||||
<span className="text-sm text-mti-gray-dim">
|
||||
<span className="font-bold">Reading</span> level
|
||||
</span>
|
||||
<Menu>
|
||||
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
||||
<BsBook className="text-ielts-reading" size={34} />
|
||||
<span className="text-mti-gray-cool text-sm">
|
||||
{levels.reading === -1 ? "Select your reading level" : `Level ${levels.reading}`}
|
||||
</span>
|
||||
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
||||
</Menu.Button>
|
||||
<Menu.Items className="absolute origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl overflow-hidden">
|
||||
{Object.values(writingMarking).map((x) => (
|
||||
<Menu.Item key={x}>
|
||||
<span
|
||||
onClick={() => setLevels((prev) => ({...prev, reading: x}))}
|
||||
className="w-full py-4 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
||||
Level {x}
|
||||
</span>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-3.5 relative">
|
||||
<span className="text-sm text-mti-gray-dim">
|
||||
<span className="font-bold">Listening</span> level
|
||||
</span>
|
||||
<Menu>
|
||||
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
||||
<BsHeadphones className="text-ielts-listening" size={34} />
|
||||
<span className="text-mti-gray-cool text-sm">
|
||||
{levels.listening === -1 ? "Select your listening level" : `Level ${levels.listening}`}
|
||||
</span>
|
||||
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
||||
</Menu.Button>
|
||||
<Menu.Items className="absolute origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl overflow-hidden">
|
||||
{Object.values(writingMarking).map((x) => (
|
||||
<Menu.Item key={x}>
|
||||
<span
|
||||
onClick={() => setLevels((prev) => ({...prev, listening: x}))}
|
||||
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
||||
Level {x}
|
||||
</span>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-3.5 relative">
|
||||
<span className="text-sm text-mti-gray-dim">
|
||||
<span className="font-bold">Writing</span> level
|
||||
</span>
|
||||
<Menu>
|
||||
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
||||
<BsPen className="text-ielts-writing" size={34} />
|
||||
<span className="text-mti-gray-cool text-sm">
|
||||
{levels.writing === -1 ? "Select your writing level" : `Level ${levels.writing}`}
|
||||
</span>
|
||||
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
||||
</Menu.Button>
|
||||
<Menu.Items className="absolute origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl overflow-hidden">
|
||||
{Object.values(writingMarking).map((x) => (
|
||||
<Menu.Item key={x}>
|
||||
<span
|
||||
onClick={() => setLevels((prev) => ({...prev, writing: x}))}
|
||||
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
||||
Level {x}
|
||||
</span>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-3.5 relative">
|
||||
<span className="text-sm text-mti-gray-dim">
|
||||
<span className="font-bold">Speaking</span> level
|
||||
</span>
|
||||
<Menu>
|
||||
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
||||
<BsMegaphone className="text-ielts-speaking" size={34} />
|
||||
<span className="text-mti-gray-cool text-sm">
|
||||
{levels.speaking === -1 ? "Select your speaking level" : `Level ${levels.speaking}`}
|
||||
</span>
|
||||
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
||||
</Menu.Button>
|
||||
<Menu.Items className="absolute origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
|
||||
{Object.values(writingMarking).map((x) => (
|
||||
<Menu.Item key={x}>
|
||||
<span
|
||||
onClick={() => setLevels((prev) => ({...prev, speaking: x}))}
|
||||
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
||||
Level {x}
|
||||
</span>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center gap-8 w-full mb-44">
|
||||
<h2 className="font-semibold text-xl">What is your desired IELTS level?</h2>
|
||||
<ModuleLevelSelector levels={desiredLevels} setLevels={setDesiredLevels} />
|
||||
</div>
|
||||
|
||||
<div className="md:self-end flex -md:flex-col justify-between w-full gap-8 absolute bottom-8 left-0 px-4 md:px-8">
|
||||
<div className="w-full tooltip" data-tip="Your screen size is too small to perform a diagnostic test">
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
className="group flex items-center justify-center gap-6 relative md:max-w-[400px] w-full md:hidden"
|
||||
disabled>
|
||||
<BsQuestionSquare className="text-mti-purple-light transition duration-300 ease-in-out" size={20} />
|
||||
<span>Perform diagnostic test instead</span>
|
||||
</Button>
|
||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||
<Button
|
||||
onClick={() => updateUser(selectExam)}
|
||||
color="purple"
|
||||
variant="outline"
|
||||
className="group flex items-center justify-center gap-6 relative max-w-[400px] w-full"
|
||||
disabled={!focus}>
|
||||
<BsQuestionSquare
|
||||
className="text-mti-purple-light group-hover:text-white transition duration-300 ease-in-out"
|
||||
size={20}
|
||||
onClick={() => updateUser(selectExam)}
|
||||
/>
|
||||
<span onClick={() => updateUser(selectExam)}>Perform diagnostic test instead</span>
|
||||
</Button>
|
||||
<Button color="purple" className="max-w-[400px] w-full" onClick={() => updateUser(onFinish)} disabled={isNextDisabled()}>
|
||||
Next Step
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => updateUser(selectExam)}
|
||||
color="purple"
|
||||
variant="outline"
|
||||
className="group flex items-center justify-center gap-6 relative md:max-w-[400px] w-full -md:hidden"
|
||||
disabled={!focus}>
|
||||
<BsQuestionSquare
|
||||
className="text-mti-purple-light group-hover:text-white transition duration-300 ease-in-out"
|
||||
size={20}
|
||||
onClick={() => updateUser(selectExam)}
|
||||
/>
|
||||
<span onClick={() => updateUser(selectExam)}>Perform diagnostic test instead</span>
|
||||
</Button>
|
||||
<Button color="purple" className="md:max-w-[400px] w-full" onClick={() => updateUser(onFinish)} disabled={isNextDisabled()}>
|
||||
Next Step
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -35,10 +35,10 @@ function WordsDrawer({words, isOpen, blankId, previouslySelectedWord, onCancel,
|
||||
<div className="rounded-full w-6 h-6 flex items-center justify-center text-white bg-mti-purple-light">{blankId}</div>
|
||||
<span> Choose the correct word:</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-6 gap-6" key="word-array">
|
||||
<div className="grid grid-cols-6 gap-6">
|
||||
{words.map(({word, isDisabled}) => (
|
||||
<button
|
||||
key={`${word}_${blankId}`}
|
||||
key={word}
|
||||
onClick={() => setSelectedWord((prev) => (prev === word ? undefined : word))}
|
||||
className={clsx(
|
||||
"rounded-full py-3 text-center transition duration-300 ease-in-out",
|
||||
@@ -53,7 +53,7 @@ function WordsDrawer({words, isOpen, blankId, previouslySelectedWord, onCancel,
|
||||
</div>
|
||||
<div className="flex justify-between w-full">
|
||||
<Button color="purple" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
|
||||
Cancel
|
||||
Back
|
||||
</Button>
|
||||
<Button color="purple" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
|
||||
Confirm
|
||||
@@ -77,9 +77,15 @@ export default function FillBlanks({
|
||||
onBack,
|
||||
}: FillBlanksExercise & CommonProps) {
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => setIsDrawerShowing(!!currentBlankId), 100);
|
||||
}, [currentBlankId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -87,47 +93,52 @@ export default function FillBlanks({
|
||||
|
||||
const calculateScore = () => {
|
||||
const total = text.match(/({{\d+}})/g)?.length || 0;
|
||||
const correct = answers.filter((x) => {
|
||||
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution.toLowerCase();
|
||||
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 correct = answers.filter((x) => solutions.find((y) => x.id === y.id)?.solution === x.solution.toLowerCase() || false).length;
|
||||
const missing = total - answers.filter((x) => solutions.find((y) => x.id === y.id)).length;
|
||||
|
||||
return {total, correct, missing};
|
||||
};
|
||||
|
||||
const renderLines = (line: string) => {
|
||||
return (
|
||||
<div className="text-base leading-5">
|
||||
<span className="text-base leading-5">
|
||||
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
||||
const id = match.replaceAll(/[\{\}]/g, "");
|
||||
const userSolution = answers.find((x) => x.id === id);
|
||||
|
||||
return (
|
||||
<input
|
||||
<button
|
||||
className={clsx(
|
||||
"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 && "text-center text-mti-purple-light bg-mti-purple-ultralight",
|
||||
userSolution && "px-5 py-2 text-center text-mti-purple-dark bg-mti-purple-ultralight",
|
||||
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
|
||||
!userSolution && "w-6 h-6 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-white bg-mti-purple-light",
|
||||
)}
|
||||
onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), {id, solution: e.target.value}])}
|
||||
value={userSolution?.solution}></input>
|
||||
onClick={() => setCurrentBlankId(id)}>
|
||||
{userSolution ? userSolution.solution : id}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
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 mb-20">
|
||||
{(!!currentBlankId || isDrawerShowing) && (
|
||||
<WordsDrawer
|
||||
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}]);
|
||||
setCurrentBlankId(undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm w-full leading-6">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
@@ -144,26 +155,6 @@ export default function FillBlanks({
|
||||
</p>
|
||||
))}
|
||||
</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 className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
import {InteractiveSpeakingExercise} from "@/interfaces/exam";
|
||||
import {CommonProps} from ".";
|
||||
import {useEffect, useState} from "react";
|
||||
import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill} from "react-icons/bs";
|
||||
import dynamic from "next/dynamic";
|
||||
import Button from "../Low/Button";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {downloadBlob} from "@/utils/evaluation";
|
||||
import axios from "axios";
|
||||
|
||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function InteractiveSpeaking({
|
||||
id,
|
||||
title,
|
||||
first_title,
|
||||
second_title,
|
||||
examID,
|
||||
type,
|
||||
prompts,
|
||||
userSolutions,
|
||||
onNext,
|
||||
onBack,
|
||||
}: InteractiveSpeakingExercise & CommonProps) {
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||
const [answers, setAnswers] = useState<{prompt: string; blob: string; questionIndex: number}[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
|
||||
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
|
||||
const back = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const answer = await saveAnswer(questionIndex);
|
||||
if (questionIndex - 1 >= 0) {
|
||||
setQuestionIndex(questionIndex - 1);
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
setIsLoading(false);
|
||||
|
||||
onBack({
|
||||
exercise: id,
|
||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||
score: {correct: 100, total: 100, missing: 0},
|
||||
type,
|
||||
});
|
||||
};
|
||||
|
||||
const next = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const answer = await saveAnswer(questionIndex);
|
||||
if (questionIndex + 1 < prompts.length) {
|
||||
setQuestionIndex(questionIndex + 1);
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
setQuestionIndex(0);
|
||||
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||
score: {correct: 100, total: 100, missing: 0},
|
||||
type,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (userSolutions.length > 0 && answers.length === 0) {
|
||||
const solutions = userSolutions as unknown as typeof answers;
|
||||
setAnswers(solutions);
|
||||
|
||||
if (!mediaBlob) setMediaBlob(solutions.find((x) => x.questionIndex === questionIndex)?.blob);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userSolutions, mediaBlob, answers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded) {
|
||||
const answer = {
|
||||
questionIndex,
|
||||
prompt: prompts[questionIndex].text,
|
||||
blob: mediaBlob!,
|
||||
};
|
||||
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||
score: {correct: 100, total: 100, missing: 0},
|
||||
type,
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasExamEnded]);
|
||||
|
||||
useEffect(() => {
|
||||
let recordingInterval: NodeJS.Timer | undefined = undefined;
|
||||
if (isRecording) {
|
||||
recordingInterval = setInterval(() => setRecordingDuration((prev) => prev + 1), 1000);
|
||||
} else if (recordingInterval) {
|
||||
clearInterval(recordingInterval);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (recordingInterval) clearInterval(recordingInterval);
|
||||
};
|
||||
}, [isRecording]);
|
||||
|
||||
useEffect(() => {
|
||||
if (questionIndex <= answers.length - 1) {
|
||||
const blob = answers.find((x) => x.questionIndex === questionIndex)?.blob;
|
||||
setMediaBlob(blob);
|
||||
}
|
||||
}, [answers, questionIndex]);
|
||||
|
||||
const saveAnswer = async (index: number) => {
|
||||
const answer = {
|
||||
questionIndex,
|
||||
prompt: prompts[questionIndex].text,
|
||||
blob: mediaBlob!,
|
||||
};
|
||||
|
||||
setAnswers((prev) => [...prev.filter((x) => x.questionIndex !== index), answer]);
|
||||
setMediaBlob(undefined);
|
||||
|
||||
setUserSolutions([
|
||||
...storeUserSolutions.filter((x) => x.exercise !== id),
|
||||
{
|
||||
exercise: id,
|
||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||
score: {correct: 100, total: 100, missing: 0},
|
||||
module: "speaking",
|
||||
exam: examID,
|
||||
type,
|
||||
},
|
||||
]);
|
||||
|
||||
return answer;
|
||||
};
|
||||
|
||||
return (
|
||||
<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 gap-3">
|
||||
<span className="font-semibold">{!!first_title && !!second_title ? `${first_title} & ${second_title}` : title}</span>
|
||||
</div>
|
||||
{prompts && prompts.length > 0 && (
|
||||
<div className="flex flex-col gap-4 w-full items-center">
|
||||
<video key={questionIndex} autoPlay controls className="max-w-3xl rounded-xl">
|
||||
<source src={prompts[questionIndex].video_url} />
|
||||
</video>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ReactMediaRecorder
|
||||
audio
|
||||
key={questionIndex}
|
||||
onStop={(blob) => setMediaBlob(blob)}
|
||||
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => (
|
||||
<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>
|
||||
<div className="flex gap-8 items-center justify-center py-8">
|
||||
{status === "idle" && (
|
||||
<>
|
||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||
{status === "idle" && (
|
||||
<BsMicFill
|
||||
onClick={() => {
|
||||
setRecordingDuration(0);
|
||||
startRecording();
|
||||
setIsRecording(true);
|
||||
}}
|
||||
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{status === "recording" && (
|
||||
<>
|
||||
<div className="flex gap-4 items-center">
|
||||
<span className="text-xs w-9">
|
||||
{Math.floor(recordingDuration / 60)
|
||||
.toString(10)
|
||||
.padStart(2, "0")}
|
||||
:
|
||||
{Math.floor(recordingDuration % 60)
|
||||
.toString(10)
|
||||
.padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||
<div className="flex gap-4 items-center">
|
||||
<BsPauseCircle
|
||||
onClick={() => {
|
||||
setIsRecording(false);
|
||||
pauseRecording();
|
||||
}}
|
||||
className="text-red-500 w-8 h-8 cursor-pointer"
|
||||
/>
|
||||
<BsCheckCircleFill
|
||||
onClick={() => {
|
||||
setIsRecording(false);
|
||||
stopRecording();
|
||||
}}
|
||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{status === "paused" && (
|
||||
<>
|
||||
<div className="flex gap-4 items-center">
|
||||
<span className="text-xs w-9">
|
||||
{Math.floor(recordingDuration / 60)
|
||||
.toString(10)
|
||||
.padStart(2, "0")}
|
||||
:
|
||||
{Math.floor(recordingDuration % 60)
|
||||
.toString(10)
|
||||
.padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||
<div className="flex gap-4 items-center">
|
||||
<BsPlayCircle
|
||||
onClick={() => {
|
||||
setIsRecording(true);
|
||||
resumeRecording();
|
||||
}}
|
||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||
/>
|
||||
<BsCheckCircleFill
|
||||
onClick={() => {
|
||||
setIsRecording(false);
|
||||
stopRecording();
|
||||
}}
|
||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{status === "stopped" && mediaBlobUrl && (
|
||||
<>
|
||||
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||
<div className="flex gap-4 items-center">
|
||||
<BsTrashFill
|
||||
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||
onClick={() => {
|
||||
setRecordingDuration(0);
|
||||
clearBlobUrl();
|
||||
setMediaBlob(undefined);
|
||||
}}
|
||||
/>
|
||||
|
||||
<BsMicFill
|
||||
onClick={() => {
|
||||
clearBlobUrl();
|
||||
setRecordingDuration(0);
|
||||
startRecording();
|
||||
setIsRecording(true);
|
||||
setMediaBlob(undefined);
|
||||
}}
|
||||
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="self-end flex justify-between w-full gap-8">
|
||||
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
||||
Back
|
||||
</Button>
|
||||
<Button color="purple" disabled={!mediaBlob} isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
|
||||
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import {MatchSentenceExerciseOption, MatchSentenceExerciseSentence, MatchSentencesExercise} from "@/interfaces/exam";
|
||||
import {MatchSentencesExercise} from "@/interfaces/exam";
|
||||
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import clsx from "clsx";
|
||||
@@ -9,84 +9,27 @@ import {CommonProps} from ".";
|
||||
import Button from "../Low/Button";
|
||||
import Xarrow from "react-xarrows";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {DndContext, DragEndEvent, useDraggable, useDroppable} from "@dnd-kit/core";
|
||||
|
||||
function DroppableQuestionArea({question, answer}: {question: MatchSentenceExerciseSentence; answer?: string}) {
|
||||
const {isOver, setNodeRef} = useDroppable({id: `droppable_sentence_${question.id}`});
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-4" ref={setNodeRef}>
|
||||
<div className="flex items-center gap-3 cursor-pointer col-span-2">
|
||||
<button
|
||||
className={clsx(
|
||||
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
||||
"transition duration-300 ease-in-out",
|
||||
)}>
|
||||
{question.id}
|
||||
</button>
|
||||
<span>{question.sentence}</span>
|
||||
</div>
|
||||
<div
|
||||
key={`answer_${question.id}_${answer}`}
|
||||
className={clsx("w-48 h-10 border rounded-xl flex items-center justify-center", isOver && "border-mti-purple-light")}>
|
||||
{answer && `Paragraph ${answer}`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DraggableOptionArea({option}: {option: MatchSentenceExerciseOption}) {
|
||||
const {attributes, listeners, setNodeRef, transform} = useDraggable({
|
||||
id: `draggable_option_${option.id}`,
|
||||
});
|
||||
|
||||
const style = transform
|
||||
? {
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
zIndex: 99,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className={clsx("flex items-center justify-start gap-6 cursor-pointer")} ref={setNodeRef} style={style} {...listeners} {...attributes}>
|
||||
<button
|
||||
id={`option_${option.id}`}
|
||||
// onClick={() => selectOption(id)}
|
||||
className={clsx(
|
||||
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple px-3 py-2 rounded-full z-10",
|
||||
"transition duration-300 ease-in-out",
|
||||
option.id,
|
||||
)}>
|
||||
Paragraph {option.id}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MatchSentences({id, options, type, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) {
|
||||
const [selectedQuestion, setSelectedQuestion] = useState<string>();
|
||||
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
||||
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
if (event.over && event.over.id.toString().startsWith("droppable")) {
|
||||
const optionID = event.active.id.toString().replace("draggable_option_", "");
|
||||
const sentenceID = event.over.id.toString().replace("droppable_sentence_", "");
|
||||
|
||||
setAnswers((prev) => [...prev.filter((x) => x.question.toString() !== sentenceID), {question: sentenceID, option: optionID}]);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateScore = () => {
|
||||
const total = sentences.length;
|
||||
const correct = answers.filter(
|
||||
(x) => sentences.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false,
|
||||
).length;
|
||||
const missing = total - answers.filter((x) => sentences.find((y) => y.id.toString() === x.question.toString())).length;
|
||||
const correct = answers.filter((x) => sentences.find((y) => y.id === x.question)?.solution === x.option || false).length;
|
||||
const missing = total - answers.filter((x) => sentences.find((y) => y.id === x.question)).length;
|
||||
|
||||
return {total, correct, missing};
|
||||
};
|
||||
|
||||
const selectOption = (option: string) => {
|
||||
if (!selectedQuestion) return;
|
||||
setAnswers((prev) => [...prev.filter((x) => x.question !== selectedQuestion), {question: selectedQuestion, option}]);
|
||||
setSelectedQuestion(undefined);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -94,7 +37,7 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
||||
|
||||
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 mb-20">
|
||||
<span className="text-sm w-full leading-6">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
@@ -103,28 +46,46 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
|
||||
<DndContext onDragEnd={handleDragEnd}>
|
||||
<div className="flex flex-col gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
{sentences.map((question) => (
|
||||
<DroppableQuestionArea
|
||||
key={`question_${question.id}`}
|
||||
question={question}
|
||||
answer={answers.find((x) => x.question.toString() === question.id.toString())?.option}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<span>Drag one of these paragraphs into the slots above:</span>
|
||||
<div className="flex gap-4 flex-wrap justify-center items-center max-w-lg">
|
||||
{options.map((option) => (
|
||||
<DraggableOptionArea key={`answer_${option.id}`} option={option} />
|
||||
))}
|
||||
<div className="flex gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
{sentences.map(({sentence, id}) => (
|
||||
<div key={`question_${id}`} className="flex items-center justify-end gap-2 cursor-pointer">
|
||||
<span>{sentence} </span>
|
||||
<button
|
||||
id={id}
|
||||
onClick={() => setSelectedQuestion((prev) => (prev === id ? undefined : id))}
|
||||
className={clsx(
|
||||
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
||||
"transition duration-300 ease-in-out",
|
||||
selectedQuestion === id && "!text-white !bg-mti-purple",
|
||||
id,
|
||||
)}>
|
||||
{id}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DndContext>
|
||||
<div className="flex flex-col gap-4">
|
||||
{options.map(({sentence, id}) => (
|
||||
<div key={`answer_${id}`} className={clsx("flex items-center justify-start gap-2 cursor-pointer")}>
|
||||
<button
|
||||
id={id}
|
||||
onClick={() => selectOption(id)}
|
||||
className={clsx(
|
||||
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
||||
"transition duration-300 ease-in-out",
|
||||
id,
|
||||
)}>
|
||||
{id}
|
||||
</button>
|
||||
<span>{sentence}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{answers.map((solution, index) => (
|
||||
<Xarrow key={index} start={solution.question} end={solution.option} lineColor="#7872BF" showHead={false} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||
|
||||
@@ -3,60 +3,43 @@ import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam"
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import clsx from "clsx";
|
||||
import {useEffect, useState} from "react";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import {CommonProps} from ".";
|
||||
import Button from "../Low/Button";
|
||||
|
||||
function Question({
|
||||
id,
|
||||
variant,
|
||||
prompt,
|
||||
options,
|
||||
userSolution,
|
||||
onSelectOption,
|
||||
}: 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 (
|
||||
<div className="flex flex-col gap-10">
|
||||
{isNaN(Number(id)) ? (
|
||||
<span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
||||
) : (
|
||||
<span className="">
|
||||
<>
|
||||
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
||||
</>
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-4 justify-between">
|
||||
<span className="">{prompt}</span>
|
||||
<div className="flex justify-between">
|
||||
{variant === "image" &&
|
||||
options.map((option) => (
|
||||
<div
|
||||
key={option.id.toString()}
|
||||
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
|
||||
key={option.id}
|
||||
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
|
||||
className={clsx(
|
||||
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative",
|
||||
userSolution === option.id.toString() && "border-mti-purple-light",
|
||||
userSolution === option.id && "border-mti-purple-light",
|
||||
)}>
|
||||
<span className={clsx("text-sm", userSolution !== option.id.toString() && "opacity-50")}>{option.id.toString()}</span>
|
||||
<img src={option.src!} alt={`Option ${option.id.toString()}`} />
|
||||
<span className={clsx("text-sm", userSolution !== option.id && "opacity-50")}>{option.id}</span>
|
||||
<img src={option.src!} alt={`Option ${option.id}`} />
|
||||
</div>
|
||||
))}
|
||||
{variant === "text" &&
|
||||
options.map((option) => (
|
||||
<div
|
||||
key={option.id.toString()}
|
||||
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
|
||||
key={option.id}
|
||||
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
|
||||
className={clsx(
|
||||
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm",
|
||||
userSolution === option.id.toString() && "border-mti-purple-light",
|
||||
userSolution === option.id && "border-mti-purple-light",
|
||||
)}>
|
||||
<span className="font-semibold">{option.id.toString()}.</span>
|
||||
<span className="font-semibold">{option.id}.</span>
|
||||
<span>{option.text}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -67,18 +50,10 @@ function Question({
|
||||
|
||||
export default function MultipleChoice({id, prompt, type, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
|
||||
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
||||
const [questionIndex, setQuestionIndex] = useState(0);
|
||||
|
||||
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
|
||||
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
|
||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||
|
||||
useEffect(() => {
|
||||
setUserSolutions([...storeUserSolutions.filter((x) => x.exercise !== id), {exercise: id, solutions: answers, score: calculateScore(), type}]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [answers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -91,10 +66,8 @@ export default function MultipleChoice({id, prompt, type, questions, userSolutio
|
||||
|
||||
const calculateScore = () => {
|
||||
const total = questions.length;
|
||||
const correct = answers.filter(
|
||||
(x) => questions.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false,
|
||||
).length;
|
||||
const missing = total - answers.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length;
|
||||
const correct = answers.filter((x) => questions.find((y) => y.id === x.question)?.solution === x.option || false).length;
|
||||
const missing = total - answers.filter((x) => questions.find((y) => y.id === x.question)).length;
|
||||
|
||||
return {total, correct, missing};
|
||||
};
|
||||
@@ -103,25 +76,21 @@ export default function MultipleChoice({id, prompt, type, questions, userSolutio
|
||||
if (questionIndex === questions.length - 1) {
|
||||
onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||
} else {
|
||||
setQuestionIndex(questionIndex + 1);
|
||||
setQuestionIndex((prev) => prev + 1);
|
||||
}
|
||||
|
||||
scrollToTop();
|
||||
};
|
||||
|
||||
const back = () => {
|
||||
if (questionIndex === 0) {
|
||||
onBack({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||
} else {
|
||||
setQuestionIndex(questionIndex - 1);
|
||||
setQuestionIndex((prev) => prev - 1);
|
||||
}
|
||||
|
||||
scrollToTop();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 mt-4 h-fit w-full mb-20 bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||
<div className="flex flex-col gap-2 mt-4 h-full mb-20 bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||
<span className="text-xl font-semibold">{prompt}</span>
|
||||
{questionIndex < questions.length && (
|
||||
<Question
|
||||
|
||||
@@ -5,60 +5,28 @@ import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill}
|
||||
import dynamic from "next/dynamic";
|
||||
import Button from "../Low/Button";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {downloadBlob} from "@/utils/evaluation";
|
||||
import axios from "axios";
|
||||
import Modal from "../Modal";
|
||||
|
||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function Speaking({id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
|
||||
export default function Speaking({id, title, text, type, prompts, onNext, onBack}: SpeakingExercise & CommonProps) {
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||
const [audioURL, setAudioURL] = useState<string>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isPromptsModalOpen, setIsPromptsModalOpen] = useState(false);
|
||||
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
|
||||
const saveToStorage = async () => {
|
||||
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 (audioURL) await axios.post("/api/storage/delete", {path: audioURL});
|
||||
return response.data.path;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (userSolutions.length > 0) {
|
||||
const {solution} = userSolutions[0] as {solution?: string};
|
||||
if (solution && !mediaBlob) setMediaBlob(solution);
|
||||
if (solution && !solution.startsWith("blob")) setAudioURL(solution);
|
||||
if (hasExamEnded) {
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
type,
|
||||
});
|
||||
}
|
||||
}, [userSolutions, mediaBlob]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded) next();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasExamEnded]);
|
||||
|
||||
@@ -75,67 +43,32 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
|
||||
};
|
||||
}, [isRecording]);
|
||||
|
||||
const next = async () => {
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||
score: {correct: 0, total: 100, missing: 0},
|
||||
type,
|
||||
});
|
||||
};
|
||||
|
||||
const back = async () => {
|
||||
onBack({
|
||||
exercise: id,
|
||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||
score: {correct: 0, total: 100, missing: 0},
|
||||
type,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<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-14 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-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 && (
|
||||
<span className="font-regular">
|
||||
{text.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
<span>{line}</span>
|
||||
<br />
|
||||
</Fragment>
|
||||
<span className="font-semibold">{title}</span>
|
||||
<span className="font-regular">
|
||||
{text.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
<span>{line}</span>
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
{prompts && prompts.length > 0 && (
|
||||
<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>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 items-center">
|
||||
{video_url && (
|
||||
<div className="flex flex-col gap-4 w-full items-center">
|
||||
<video key={id} autoPlay controls className="max-w-3xl rounded-xl">
|
||||
<source src={video_url} />
|
||||
</video>
|
||||
</div>
|
||||
)}
|
||||
{prompts && prompts.length > 0 && <Button onClick={() => setIsPromptsModalOpen(true)}>View Prompts</Button>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ReactMediaRecorder
|
||||
@@ -145,7 +78,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
|
||||
<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>
|
||||
<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" />
|
||||
{status === "idle" && (
|
||||
@@ -224,9 +157,9 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
|
||||
</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">
|
||||
<BsTrashFill
|
||||
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||
@@ -256,10 +189,32 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
|
||||
/>
|
||||
|
||||
<div className="self-end flex justify-between w-full gap-8">
|
||||
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onBack({
|
||||
exercise: id,
|
||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
type,
|
||||
})
|
||||
}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
Back
|
||||
</Button>
|
||||
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
|
||||
<Button
|
||||
color="purple"
|
||||
disabled={!mediaBlob}
|
||||
onClick={() =>
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
type,
|
||||
})
|
||||
}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -16,14 +16,8 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
||||
|
||||
const calculateScore = () => {
|
||||
const total = questions.length || 0;
|
||||
const correct = answers.filter(
|
||||
(x) =>
|
||||
questions
|
||||
.find((y) => x.id.toString() === y.id.toString())
|
||||
?.solution?.toString()
|
||||
.toLowerCase() === x.solution.toLowerCase() || false,
|
||||
).length;
|
||||
const missing = total - answers.filter((x) => questions.find((y) => x.id.toString() === y.id.toString())).length;
|
||||
const correct = answers.filter((x) => questions.find((y) => x.id === y.id)?.solution === x.solution.toLowerCase() || false).length;
|
||||
const missing = total - answers.filter((x) => questions.find((y) => x.id === y.id)).length;
|
||||
|
||||
return {total, correct, missing};
|
||||
};
|
||||
@@ -40,7 +34,7 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
||||
|
||||
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 mb-20">
|
||||
<span className="text-sm w-full leading-6">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
@@ -66,37 +60,33 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
||||
</div>
|
||||
<span className="text-sm w-full leading-6">You can click a selected option again to deselect it.</span>
|
||||
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-8">
|
||||
{questions.map((question, index) => {
|
||||
const id = question.id.toString();
|
||||
|
||||
return (
|
||||
<div key={question.id.toString()} className="flex flex-col gap-4">
|
||||
<span>
|
||||
{index + 1}. {question.prompt}
|
||||
</span>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
variant={answers.find((x) => x.id.toString() === id)?.solution === "true" ? "solid" : "outline"}
|
||||
onClick={() => toggleAnswer("true", id)}
|
||||
className="!py-2">
|
||||
True
|
||||
</Button>
|
||||
<Button
|
||||
variant={answers.find((x) => x.id.toString() === id)?.solution === "false" ? "solid" : "outline"}
|
||||
onClick={() => toggleAnswer("false", id)}
|
||||
className="!py-2">
|
||||
False
|
||||
</Button>
|
||||
<Button
|
||||
variant={answers.find((x) => x.id.toString() === id)?.solution === "not_given" ? "solid" : "outline"}
|
||||
onClick={() => toggleAnswer("not_given", id)}
|
||||
className="!py-2">
|
||||
Not Given
|
||||
</Button>
|
||||
</div>
|
||||
{questions.map((question, index) => (
|
||||
<div key={question.id} className="flex flex-col gap-4">
|
||||
<span>
|
||||
{index + 1}. {question.prompt}
|
||||
</span>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
variant={answers.find((x) => x.id === question.id)?.solution === "true" ? "solid" : "outline"}
|
||||
onClick={() => toggleAnswer("true", question.id)}
|
||||
className="!py-2">
|
||||
True
|
||||
</Button>
|
||||
<Button
|
||||
variant={answers.find((x) => x.id === question.id)?.solution === "false" ? "solid" : "outline"}
|
||||
onClick={() => toggleAnswer("false", question.id)}
|
||||
className="!py-2">
|
||||
False
|
||||
</Button>
|
||||
<Button
|
||||
variant={answers.find((x) => x.id === question.id)?.solution === "not_given" ? "solid" : "outline"}
|
||||
onClick={() => toggleAnswer("not_given", question.id)}
|
||||
className="!py-2">
|
||||
Not Given
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@ function Blank({
|
||||
const [userInput, setUserInput] = useState(userSolution || "");
|
||||
|
||||
useEffect(() => {
|
||||
const words = userInput.split(" ");
|
||||
if (words.length > maxWords) {
|
||||
const words = userInput.split(" ").filter((x) => x !== "");
|
||||
if (words.length >= maxWords) {
|
||||
toast.warning(`You have reached your word limit of ${maxWords} words!`, {toastId: "word-limit"});
|
||||
setUserInput(words.join(" ").trim());
|
||||
}
|
||||
@@ -36,7 +36,7 @@ function Blank({
|
||||
|
||||
return (
|
||||
<input
|
||||
className="py-2 px-3 mx-2 rounded-2xl w-48 bg-white focus:outline-none my-2"
|
||||
className="py-2 px-3 rounded-2xl w-48 bg-white focus:outline-none my-2"
|
||||
placeholder={id}
|
||||
onChange={(e) => setUserInput(e.target.value)}
|
||||
onBlur={() => setUserSolution(userInput)}
|
||||
@@ -61,9 +61,9 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
|
||||
const correct = answers.filter(
|
||||
(x) =>
|
||||
solutions
|
||||
.find((y) => x.id.toString() === y.id.toString())
|
||||
?.solution.map((y) => y.toLowerCase().trim())
|
||||
.includes(x.solution.toLowerCase().trim()) || false,
|
||||
.find((y) => x.id === y.id)
|
||||
?.solution.map((y) => y.toLowerCase())
|
||||
.includes(x.solution.toLowerCase()) || false,
|
||||
).length;
|
||||
const missing = total - answers.filter((x) => solutions.find((y) => x.id === y.id)).length;
|
||||
|
||||
@@ -88,13 +88,13 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
|
||||
|
||||
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 mb-20">
|
||||
<span className="text-sm w-full leading-6">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<span key={index}>
|
||||
<Fragment key={index}>
|
||||
{line}
|
||||
<br />
|
||||
</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {WritingExercise} from "@/interfaces/exam";
|
||||
import {CommonProps} from ".";
|
||||
import React, {Fragment, useEffect, useRef, useState} from "react";
|
||||
import {Fragment, useEffect, useState} from "react";
|
||||
import {toast} from "react-toastify";
|
||||
import Button from "../Low/Button";
|
||||
import {Dialog, Transition} from "@headlessui/react";
|
||||
@@ -22,50 +22,11 @@ export default function Writing({
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : "");
|
||||
const [isSubmitEnabled, setIsSubmitEnabled] = useState(false);
|
||||
const [saveTimer, setSaveTimer] = useState(0);
|
||||
|
||||
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
|
||||
useEffect(() => {
|
||||
const saveTimerInterval = setInterval(() => {
|
||||
setSaveTimer((prev) => prev + 1);
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(saveTimerInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputText.length > 0 && saveTimer % 10 === 0) {
|
||||
setUserSolutions([
|
||||
...storeUserSolutions.filter((x) => x.exercise !== id),
|
||||
{exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type, module: "writing"},
|
||||
]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [saveTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem("enable_paste")) return;
|
||||
|
||||
const listener = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "v") {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", listener);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", listener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded)
|
||||
onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type, module: "writing"});
|
||||
if (hasExamEnded) onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasExamEnded]);
|
||||
|
||||
@@ -118,8 +79,22 @@ export default function Writing({
|
||||
)}
|
||||
<div className="flex flex-col h-full w-full gap-9 mb-20">
|
||||
<div className="flex flex-col w-full gap-7 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
||||
<span className="whitespace-pre-wrap">{prefix.replaceAll("\\n", "\n")}</span>
|
||||
<span className="font-semibold whitespace-pre-wrap">{prompt.replaceAll("\\n", "\n")}</span>
|
||||
<span>
|
||||
{prefix.split("\\n").map((line) => (
|
||||
<>
|
||||
{line}
|
||||
<br />
|
||||
</>
|
||||
))}
|
||||
</span>
|
||||
<span className="font-semibold">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
<p>{line}</p>
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
{attachment && (
|
||||
<img
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
@@ -131,16 +106,20 @@ export default function Writing({
|
||||
</div>
|
||||
|
||||
<div className="w-full h-full flex flex-col gap-4">
|
||||
<span className="whitespace-pre-wrap">{suffix}</span>
|
||||
<span>
|
||||
{suffix.split("\\n").map((line) => (
|
||||
<>
|
||||
{line}
|
||||
<br />
|
||||
</>
|
||||
))}
|
||||
</span>
|
||||
<textarea
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
className="w-full h-full min-h-[300px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
|
||||
className="w-full h-full min-h-[148px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
value={inputText}
|
||||
placeholder="Write your text here..."
|
||||
spellCheck={false}
|
||||
/>
|
||||
<span className="text-base self-end text-mti-gray-cool">Word Count: {inputText.split(" ").filter((x) => x !== "").length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -148,24 +127,14 @@ export default function Writing({
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onBack({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type})
|
||||
}
|
||||
onClick={() => onBack({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type})}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
color="purple"
|
||||
disabled={!isSubmitEnabled}
|
||||
onClick={() =>
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: [{id, solution: inputText.replaceAll(/\s{2,}/g, " ")}],
|
||||
score: {correct: 100, total: 100, missing: 0},
|
||||
type,
|
||||
module: "writing",
|
||||
})
|
||||
}
|
||||
onClick={() => onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type})}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
Exercise,
|
||||
FillBlanksExercise,
|
||||
InteractiveSpeakingExercise,
|
||||
MatchSentencesExercise,
|
||||
MultipleChoiceExercise,
|
||||
SpeakingExercise,
|
||||
@@ -17,46 +16,29 @@ import WriteBlanks from "./WriteBlanks";
|
||||
import Writing from "./Writing";
|
||||
import Speaking from "./Speaking";
|
||||
import TrueFalse from "./TrueFalse";
|
||||
import InteractiveSpeaking from "./InteractiveSpeaking";
|
||||
|
||||
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false});
|
||||
|
||||
export interface CommonProps {
|
||||
examID?: string;
|
||||
onNext: (userSolutions: UserSolution) => void;
|
||||
onBack: (userSolutions: UserSolution) => void;
|
||||
}
|
||||
|
||||
export const renderExercise = (
|
||||
exercise: Exercise,
|
||||
examID: string,
|
||||
onNext: (userSolutions: UserSolution) => void,
|
||||
onBack: (userSolutions: UserSolution) => void,
|
||||
) => {
|
||||
export const renderExercise = (exercise: Exercise, onNext: (userSolutions: UserSolution) => void, onBack: (userSolutions: UserSolution) => void) => {
|
||||
switch (exercise.type) {
|
||||
case "fillBlanks":
|
||||
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||
return <FillBlanks {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "trueFalse":
|
||||
return <TrueFalse key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||
return <TrueFalse {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "matchSentences":
|
||||
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||
return <MatchSentences {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "multipleChoice":
|
||||
return <MultipleChoice key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "writeBlanks":
|
||||
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||
return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "writing":
|
||||
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||
return <Writing {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "speaking":
|
||||
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||
case "interactiveSpeaking":
|
||||
return (
|
||||
<InteractiveSpeaking
|
||||
key={exercise.id}
|
||||
{...(exercise as InteractiveSpeakingExercise)}
|
||||
examID={examID}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
return <Speaking {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
interface Props {
|
||||
onFocusLayerMouseEnter?: () => void;
|
||||
onFocusLayerMouseEnter: Function,
|
||||
}
|
||||
|
||||
export default function FocusLayer({onFocusLayerMouseEnter}: Props) {
|
||||
return <div className="absolute top-0 left-0 bottom-0 right-0" onMouseDown={onFocusLayerMouseEnter} />;
|
||||
export default function FocusLayer({
|
||||
onFocusLayerMouseEnter,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="bg-gray-700 bg-opacity-30 absolute top-0 left-0 bottom-0 right-0" onMouseEnter={onFocusLayerMouseEnter}/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
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;
|
||||
@@ -1,7 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
const InteractiveSpeakingEdit = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export default InteractiveSpeakingEdit;
|
||||
@@ -1,130 +0,0 @@
|
||||
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;
|
||||
@@ -1,137 +0,0 @@
|
||||
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;
|
||||
@@ -1,7 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const SpeakingEdit = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
export default SpeakingEdit;
|
||||
@@ -1,71 +0,0 @@
|
||||
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;
|
||||
@@ -1,94 +0,0 @@
|
||||
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;
|
||||
@@ -1,7 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const WritingEdit = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
export default WritingEdit;
|
||||
@@ -1,32 +0,0 @@
|
||||
import {EmploymentStatus, EMPLOYMENT_STATUS} from "@/interfaces/user";
|
||||
import {RadioGroup} from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface Props {
|
||||
value?: EmploymentStatus;
|
||||
onChange: (value?: EmploymentStatus) => void;
|
||||
}
|
||||
|
||||
export default function EmploymentStatusInput({value, onChange}: Props) {
|
||||
return (
|
||||
<div className="relative flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Employment Status *</label>
|
||||
<RadioGroup value={value} onChange={onChange} className="grid grid-cols-2 items-center gap-4 place-items-center">
|
||||
{EMPLOYMENT_STATUS.map(({status, label}) => (
|
||||
<RadioGroup.Option value={status} key={status}>
|
||||
{({checked}) => (
|
||||
<span
|
||||
className={clsx(
|
||||
"px-6 py-4 w-40 md:w-48 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"transition duration-300 ease-in-out",
|
||||
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||
)}>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import {Gender} from "@/interfaces/user";
|
||||
import {RadioGroup} from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface Props {
|
||||
value?: Gender;
|
||||
onChange: (value?: Gender) => void;
|
||||
}
|
||||
|
||||
export default function GenderInput({value, onChange}: Props) {
|
||||
return (
|
||||
<div className="relative flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Gender *</label>
|
||||
<RadioGroup value={value} onChange={onChange} className="flex flex-row gap-4 justify-between">
|
||||
<RadioGroup.Option value="male">
|
||||
{({checked}) => (
|
||||
<span
|
||||
className={clsx(
|
||||
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"transition duration-300 ease-in-out",
|
||||
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||
)}>
|
||||
Male
|
||||
</span>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
<RadioGroup.Option value="female">
|
||||
{({checked}) => (
|
||||
<span
|
||||
className={clsx(
|
||||
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"transition duration-300 ease-in-out",
|
||||
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||
)}>
|
||||
Female
|
||||
</span>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
<RadioGroup.Option value="other">
|
||||
{({checked}) => (
|
||||
<span
|
||||
className={clsx(
|
||||
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"transition duration-300 ease-in-out",
|
||||
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||
)}>
|
||||
Other
|
||||
</span>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import {User} from "@/interfaces/user";
|
||||
import clsx from "clsx";
|
||||
import {useRouter} from "next/router";
|
||||
import BottomBar from "../BottomBar";
|
||||
import Navbar from "../Navbar";
|
||||
import Sidebar from "../Sidebar";
|
||||
|
||||
@@ -10,34 +9,21 @@ interface Props {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
navDisabled?: boolean;
|
||||
focusMode?: boolean;
|
||||
onFocusLayerMouseEnter?: () => void;
|
||||
focusMode?: boolean
|
||||
onFocusLayerMouseEnter?: Function;
|
||||
}
|
||||
|
||||
export default function Layout({user, children, className, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
||||
export default function Layout({user, children, className, navDisabled = false, focusMode = false, onFocusLayerMouseEnter }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<main className="w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative">
|
||||
<Navbar
|
||||
path={router.pathname}
|
||||
user={user}
|
||||
navDisabled={navDisabled}
|
||||
focusMode={focusMode}
|
||||
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
|
||||
/>
|
||||
<main className="w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke">
|
||||
<Navbar user={user} navDisabled={navDisabled} focusMode={focusMode} onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
|
||||
<div className="h-full w-full flex gap-2">
|
||||
<Sidebar
|
||||
path={router.pathname}
|
||||
navDisabled={navDisabled}
|
||||
focusMode={focusMode}
|
||||
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
|
||||
className="-md:hidden"
|
||||
user={user}
|
||||
/>
|
||||
<Sidebar path={router.pathname} navDisabled={navDisabled} focusMode={focusMode} onFocusLayerMouseEnter={onFocusLayerMouseEnter}/>
|
||||
<div
|
||||
className={clsx(
|
||||
"w-full min-h-full h-fit md:mr-8 bg-white shadow-md rounded-2xl p-4 xl:p-10 pb-8 flex flex-col gap-8 relative overflow-hidden mt-2",
|
||||
"w-5/6 min-h-full h-fit mr-8 bg-white shadow-md rounded-2xl p-12 pb-8 flex flex-col gap-12 relative overflow-hidden mt-2",
|
||||
className,
|
||||
)}>
|
||||
{children}
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {
|
||||
Ticket,
|
||||
TicketStatus,
|
||||
TicketStatusLabel,
|
||||
TicketType,
|
||||
TicketTypeLabel,
|
||||
} from "@/interfaces/ticket";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||
import axios from "axios";
|
||||
import moment from "moment";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import ShortUniqueId from "short-unique-id";
|
||||
import Button from "../Low/Button";
|
||||
import Input from "../Low/Input";
|
||||
import Select from "../Low/Select";
|
||||
import { checkAccess } from "@/utils/permissions";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
ticket: Ticket;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function TicketDisplay({ user, ticket, onClose }: Props) {
|
||||
const [subject] = useState(ticket.subject);
|
||||
const [type, setType] = useState<TicketType>(ticket.type);
|
||||
const [description] = useState(ticket.description);
|
||||
const [reporter] = useState(ticket.reporter);
|
||||
const [reportedFrom] = useState(ticket.reportedFrom);
|
||||
const [status, setStatus] = useState(ticket.status);
|
||||
const [assignedTo, setAssignedTo] = useState<string | null>(
|
||||
ticket.assignedTo || null,
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { users } = useUsers();
|
||||
|
||||
const submit = () => {
|
||||
if (!type)
|
||||
return toast.error("Please choose a type!", { toastId: "missing-type" });
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.patch(`/api/tickets/${ticket.id}`, {
|
||||
subject,
|
||||
type,
|
||||
description,
|
||||
reporter,
|
||||
reportedFrom,
|
||||
status,
|
||||
assignedTo,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(`The ticket has been updated!`, { toastId: "submitted" });
|
||||
onClose();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong, please try again later!", {
|
||||
toastId: "error",
|
||||
});
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
const del = () => {
|
||||
if (!confirm("Are you sure you want to delete this ticket?")) return;
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.delete(`/api/tickets/${ticket.id}`)
|
||||
.then(() => {
|
||||
toast.success(`The ticket has been deleted!`, { toastId: "submitted" });
|
||||
onClose();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong, please try again later!", {
|
||||
toastId: "error",
|
||||
});
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4 pt-8">
|
||||
<Input
|
||||
label="Subject"
|
||||
type="text"
|
||||
name="subject"
|
||||
placeholder="Subject..."
|
||||
value={subject}
|
||||
onChange={(e) => null}
|
||||
disabled
|
||||
/>
|
||||
|
||||
<div className="-md:flex-col flex w-full items-center gap-4">
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Status
|
||||
</label>
|
||||
<Select
|
||||
options={Object.keys(TicketStatusLabel).map((x) => ({
|
||||
value: x,
|
||||
label: TicketStatusLabel[x as keyof typeof TicketStatusLabel],
|
||||
}))}
|
||||
value={{ value: status, label: TicketStatusLabel[status] }}
|
||||
onChange={(value) =>
|
||||
setStatus((value?.value as TicketStatus) ?? undefined)
|
||||
}
|
||||
placeholder="Status..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Type
|
||||
</label>
|
||||
<Select
|
||||
options={Object.keys(TicketTypeLabel).map((x) => ({
|
||||
value: x,
|
||||
label: TicketTypeLabel[x as keyof typeof TicketTypeLabel],
|
||||
}))}
|
||||
value={{ value: type, label: TicketTypeLabel[type] }}
|
||||
onChange={(value) => setType(value!.value as TicketType)}
|
||||
placeholder="Type..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Assignee
|
||||
</label>
|
||||
<Select
|
||||
options={[
|
||||
{ value: "me", label: "Assign to me" },
|
||||
...users
|
||||
.filter((x) => checkAccess(x, ["admin", "developer", "agent"]))
|
||||
.map((u) => ({
|
||||
value: u.id,
|
||||
label: `${u.name} - ${u.email}`,
|
||||
})),
|
||||
]}
|
||||
disabled={checkAccess(user, ["agent"])}
|
||||
value={
|
||||
assignedTo
|
||||
? {
|
||||
value: assignedTo,
|
||||
label: `${users.find((u) => u.id === assignedTo)?.name} - ${users.find((u) => u.id === assignedTo)?.email}`,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onChange={(value) =>
|
||||
value
|
||||
? setAssignedTo(value.value === "me" ? user.id : value.value)
|
||||
: setAssignedTo(null)
|
||||
}
|
||||
placeholder="Assignee..."
|
||||
isClearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="-md:flex-col flex w-full items-center gap-4">
|
||||
<Input
|
||||
label="Reported From"
|
||||
type="text"
|
||||
name="reportedFrom"
|
||||
onChange={() => null}
|
||||
value={reportedFrom}
|
||||
disabled
|
||||
/>
|
||||
<Input
|
||||
label="Date"
|
||||
type="text"
|
||||
name="date"
|
||||
onChange={() => null}
|
||||
value={moment(ticket.date).format("DD/MM/YYYY - HH:mm")}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="-md:flex-col flex w-full items-center gap-4">
|
||||
<Input
|
||||
label="Reporter's Name"
|
||||
type="text"
|
||||
name="reporter"
|
||||
onChange={() => null}
|
||||
value={reporter.name}
|
||||
disabled
|
||||
/>
|
||||
<Input
|
||||
label="Reporter's E-mail"
|
||||
type="text"
|
||||
name="reporter"
|
||||
onChange={() => null}
|
||||
value={reporter.email}
|
||||
disabled
|
||||
/>
|
||||
<Input
|
||||
label="Reporter's Type"
|
||||
type="text"
|
||||
name="reporterType"
|
||||
onChange={() => null}
|
||||
value={USER_TYPE_LABELS[reporter.type]}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8"
|
||||
placeholder="Write your ticket's description here..."
|
||||
contentEditable={false}
|
||||
value={description}
|
||||
spellCheck
|
||||
/>
|
||||
|
||||
<div className="-md:flex-col-reverse mt-2 flex w-full items-center justify-between gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
color="red"
|
||||
className="w-full md:max-w-[200px]"
|
||||
variant="outline"
|
||||
onClick={del}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
<div className="-md:flex-col-reverse flex w-full items-center justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
color="red"
|
||||
className="w-full md:max-w-[200px]"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full md:max-w-[200px]"
|
||||
isLoading={isLoading}
|
||||
onClick={submit}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import {Ticket, TicketType, TicketTypeLabel} from "@/interfaces/ticket";
|
||||
import {User} from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import axios from "axios";
|
||||
import {useState} from "react";
|
||||
import {toast} from "react-toastify";
|
||||
import ShortUniqueId from "short-unique-id";
|
||||
import Button from "../Low/Button";
|
||||
import Input from "../Low/Input";
|
||||
import Select from "../Low/Select";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
page: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function TicketSubmission({user, page, onClose}: Props) {
|
||||
const [subject, setSubject] = useState("");
|
||||
const [type, setType] = useState<TicketType>();
|
||||
const [description, setDescription] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const examState = useExamStore((state) => state);
|
||||
|
||||
const submit = () => {
|
||||
if (!type) return toast.error("Please choose a type!", {toastId: "missing-type"});
|
||||
if (subject.trim() === "")
|
||||
return toast.error("Please input a subject!", {
|
||||
toastId: "missing-subject",
|
||||
});
|
||||
if (description.trim() === "")
|
||||
return toast.error("Please describe your ticket!", {
|
||||
toastId: "missing-desc",
|
||||
});
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const shortUID = new ShortUniqueId();
|
||||
const ticket: Ticket = {
|
||||
id: shortUID.randomUUID(8),
|
||||
date: new Date().toISOString(),
|
||||
reporter: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
type: user.type,
|
||||
},
|
||||
status: "submitted",
|
||||
subject,
|
||||
type,
|
||||
reportedFrom: page,
|
||||
description,
|
||||
examInformation:
|
||||
page.includes("exam") || page.includes("exercises")
|
||||
? {
|
||||
exam: examState.exam?.id || "",
|
||||
exams: examState.exams.map((x) => x.id),
|
||||
exerciseIndex: examState.exerciseIndex,
|
||||
moduleIndex: examState.moduleIndex,
|
||||
partIndex: examState.partIndex,
|
||||
questionIndex: examState.questionIndex,
|
||||
selectedModules: examState.selectedModules,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
axios
|
||||
.post(`/api/tickets`, ticket)
|
||||
.then(() => {
|
||||
toast.success(`Your ticket has been submitted! You will be contacted by e-mail for further discussion.`, {toastId: "submitted"});
|
||||
onClose();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong, please try again later!", {
|
||||
toastId: "error",
|
||||
});
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4 pt-8">
|
||||
<Input label="Subject" type="text" name="subject" placeholder="Subject..." onChange={(e) => setSubject(e)} />
|
||||
<div className="-md:flex-col flex w-full items-center gap-4">
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<label className="text-mti-gray-dim text-base font-normal">Type</label>
|
||||
<Select
|
||||
options={Object.keys(TicketTypeLabel).map((x) => ({
|
||||
value: x,
|
||||
label: TicketTypeLabel[x as keyof typeof TicketTypeLabel],
|
||||
}))}
|
||||
onChange={(value) => setType((value?.value as TicketType) ?? undefined)}
|
||||
placeholder="Type..."
|
||||
/>
|
||||
</div>
|
||||
<Input label="Reporter" type="text" name="reporter" onChange={() => null} value={`${user.name} - ${user.email}`} disabled />
|
||||
</div>
|
||||
<textarea
|
||||
className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8"
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Write your ticket's description here..."
|
||||
spellCheck
|
||||
/>
|
||||
<div className="mt-2 flex w-full items-center justify-end gap-4">
|
||||
<Button type="button" color="red" className="w-full max-w-[200px]" variant="outline" onClick={onClose} isLoading={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" className="w-full max-w-[200px]" isLoading={isLoading} onClick={submit}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -11,10 +11,9 @@ interface Props {
|
||||
autoPlay?: boolean;
|
||||
disabled?: boolean;
|
||||
onEnd?: () => void;
|
||||
disablePause?: boolean;
|
||||
}
|
||||
|
||||
export default function AudioPlayer({src, color, autoPlay = false, disabled = false, onEnd, disablePause = false}: Props) {
|
||||
export default function AudioPlayer({src, color, autoPlay = false, disabled = false, onEnd}: Props) {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
@@ -22,19 +21,11 @@ export default function AudioPlayer({src, color, autoPlay = false, disabled = fa
|
||||
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const durationInterval = setInterval(() => {
|
||||
if (duration > 0) clearInterval(durationInterval);
|
||||
|
||||
const seconds = Math.floor(audioPlayerRef?.current?.duration || 0);
|
||||
if (seconds > 0) setDuration(seconds);
|
||||
}, 300);
|
||||
|
||||
if (duration > 0) clearInterval(durationInterval);
|
||||
|
||||
return () => {
|
||||
clearInterval(durationInterval);
|
||||
};
|
||||
}, [duration]);
|
||||
if (audioPlayerRef && audioPlayerRef.current) {
|
||||
const seconds = Math.floor(audioPlayerRef.current.duration);
|
||||
setDuration(seconds);
|
||||
}
|
||||
}, [audioPlayerRef?.current?.readyState]);
|
||||
|
||||
useEffect(() => {
|
||||
let playingInterval: NodeJS.Timer | undefined = undefined;
|
||||
@@ -63,8 +54,8 @@ export default function AudioPlayer({src, color, autoPlay = false, disabled = fa
|
||||
<div className="w-full h-fit flex gap-4 items-center mt-2">
|
||||
{isPlaying && (
|
||||
<BsPauseFill
|
||||
className={clsx("text-mti-gray-cool cursor-pointer w-5 h-5", (disabled || disablePause) && "opacity-60 cursor-not-allowed")}
|
||||
onClick={disabled || disablePause ? undefined : togglePlayPause}
|
||||
className={clsx("text-mti-gray-cool cursor-pointer w-5 h-5", disabled && "opacity-60 cursor-not-allowed")}
|
||||
onClick={disabled ? undefined : togglePlayPause}
|
||||
/>
|
||||
)}
|
||||
{!isPlaying && (
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import {Module} from "@/interfaces";
|
||||
import clsx from "clsx";
|
||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||
|
||||
interface Props {
|
||||
module: Module;
|
||||
children: string;
|
||||
}
|
||||
|
||||
export default function Badge({module, children}: Props) {
|
||||
return (
|
||||
<div
|
||||
key={module}
|
||||
className={clsx(
|
||||
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl",
|
||||
module === "reading" && "bg-ielts-reading",
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}>
|
||||
{module === "reading" && <BsBook className="w-4 h-4" />}
|
||||
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
||||
{module === "writing" && <BsPen className="w-4 h-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
||||
{module === "level" && <BsClipboard className="w-4 h-4" />}
|
||||
<span className="text-sm">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,17 @@
|
||||
import clsx from "clsx";
|
||||
import {ReactNode} from "react";
|
||||
import {BsArrowRepeat} from "react-icons/bs";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
color?: "rose" | "purple" | "red" | "green" | "gray" | "pink";
|
||||
color?: "rose" | "purple" | "red";
|
||||
variant?: "outline" | "solid";
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
onClick?: () => void;
|
||||
type?: "button" | "reset" | "submit";
|
||||
}
|
||||
|
||||
export default function Button({
|
||||
color = "purple",
|
||||
variant = "solid",
|
||||
disabled = false,
|
||||
isLoading = false,
|
||||
className,
|
||||
children,
|
||||
type,
|
||||
onClick,
|
||||
}: Props) {
|
||||
export default function Button({color = "purple", variant = "solid", disabled = false, className, children, onClick}: Props) {
|
||||
const colorClassNames: {[key in typeof color]: {[key in typeof variant]: string}} = {
|
||||
green: {
|
||||
solid: "bg-mti-green-light text-white border border-mti-green-light hover:bg-mti-green disabled:text-mti-green disabled:bg-mti-green-ultralight selection:bg-mti-green-dark",
|
||||
outline:
|
||||
"bg-transparent text-mti-green-light border border-mti-green-light hover:bg-mti-green-light disabled:text-mti-green disabled:bg-mti-green-ultralight disabled:border-none selection:bg-mti-green-dark hover:text-white selection:text-white",
|
||||
},
|
||||
purple: {
|
||||
solid: "bg-mti-purple-light text-white border border-mti-purple-light hover:bg-mti-purple disabled:text-mti-purple disabled:bg-mti-purple-ultralight selection:bg-mti-purple-dark",
|
||||
outline:
|
||||
@@ -39,39 +22,23 @@ export default function Button({
|
||||
outline:
|
||||
"bg-transparent text-mti-red-light border border-mti-red-light hover:bg-mti-red-light disabled:text-mti-red disabled:bg-mti-red-ultralight disabled:border-none selection:bg-mti-red-dark hover:text-white selection:text-white",
|
||||
},
|
||||
gray: {
|
||||
solid: "bg-mti-gray-davy text-white border border-mti-gray-davy hover:bg-mti-gray-davy disabled:text-mti-gray-davy disabled:bg-mti-gray-davy selection:bg-mti-gray-davy",
|
||||
outline:
|
||||
"bg-transparent text-mti-gray-davy border border-mti-gray-davy hover:bg-mti-gray-davy disabled:text-mti-gray-davy disabled:bg-mti-gray-davy disabled:border-none selection:bg-mti-gray-davy hover:text-white selection:text-white",
|
||||
},
|
||||
rose: {
|
||||
solid: "bg-mti-rose-light text-white border border-mti-rose-light hover:bg-mti-rose disabled:text-mti-rose disabled:bg-mti-rose-ultralight selection:bg-mti-rose-dark",
|
||||
outline:
|
||||
"bg-transparent text-mti-rose-light border border-mti-rose-light hover:bg-mti-rose-light disabled:text-mti-rose disabled:bg-mti-rose-ultralight disabled:border-none selection:bg-mti-rose-dark hover:text-white selection:text-white",
|
||||
},
|
||||
pink: {
|
||||
solid: "bg-ielts-speaking text-white border border-ielts-speaking hover:bg-ielts-speaking disabled:text-ielts-speaking disabled:bg-ielts-speaking-transparent selection:bg-ielts-speaking",
|
||||
outline:
|
||||
"bg-transparent text-ielts-speaking border border-ielts-speaking hover:bg-ielts-speaking disabled:text-ielts-speaking disabled:bg-ielts-speaking-transparent disabled:border-none selection:bg-ielts-speaking hover:text-white selection:text-white",
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
className={clsx(
|
||||
"py-4 px-6 rounded-full transition ease-in-out duration-300 disabled:cursor-not-allowed cursor-pointer",
|
||||
colorClassNames[color][variant],
|
||||
"py-4 px-6 rounded-full transition ease-in-out duration-300 disabled:cursor-not-allowed",
|
||||
className,
|
||||
colorClassNames[color][variant],
|
||||
)}
|
||||
disabled={disabled || isLoading}>
|
||||
{!isLoading && children}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
||||
</div>
|
||||
)}
|
||||
disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import clsx from "clsx";
|
||||
import {ReactElement, ReactNode} from "react";
|
||||
import {BsCheck} from "react-icons/bs";
|
||||
|
||||
interface Props {
|
||||
isChecked: boolean;
|
||||
onChange: (isChecked: boolean) => void;
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function Checkbox({isChecked, onChange, children, disabled}: Props) {
|
||||
return (
|
||||
<div className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer" onClick={() => {
|
||||
if(disabled) return;
|
||||
onChange(!isChecked);
|
||||
}}>
|
||||
<input type="checkbox" className="hidden" />
|
||||
<div
|
||||
className={clsx(
|
||||
"w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
|
||||
"transition duration-300 ease-in-out",
|
||||
isChecked && "!bg-mti-purple-light ",
|
||||
)}>
|
||||
<BsCheck color="white" className="w-full h-full" />
|
||||
</div>
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import {countries, TCountries} from "countries-list";
|
||||
import {Fragment, useState} from "react";
|
||||
import {Combobox, Transition} from "@headlessui/react";
|
||||
import {BsChevronExpand} from "react-icons/bs";
|
||||
import countryCodes from "country-codes-list";
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const mapCountries = (codes: string[]) => {
|
||||
return codes.map((code) => ({
|
||||
label: `${countryCodes.findOne("countryCode" as any, code).flag} ${countries[code as unknown as keyof TCountries].name} (+${
|
||||
countries[code as unknown as keyof TCountries].phone
|
||||
})`,
|
||||
code,
|
||||
}));
|
||||
};
|
||||
|
||||
export default function CountrySelect({value, disabled = false, onChange}: Props) {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const filteredCountries =
|
||||
query === ""
|
||||
? mapCountries(Object.keys(countries))
|
||||
: mapCountries(
|
||||
Object.keys(countries).filter((x) =>
|
||||
countries[x as unknown as keyof TCountries].name.toLowerCase().includes(query.toLowerCase()),
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Combobox value={value} onChange={onChange} disabled={disabled}>
|
||||
<div className="relative mt-1">
|
||||
<div className="relative w-full cursor-default overflow-hidden ">
|
||||
<Combobox.Input
|
||||
className="py-6 w-full px-8 text-sm font-normal placeholder:text-mti-gray-cool bg-white disabled:bg-mti-gray-platinum/40 rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
displayValue={(code: string) => {
|
||||
const country = countries[code as unknown as keyof TCountries];
|
||||
|
||||
return `${countryCodes.findOne("countryCode" as any, code)?.flag || ""} ${country?.name || "N/A"} (+${
|
||||
country?.phone || "N/A"
|
||||
})`;
|
||||
}}
|
||||
/>
|
||||
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-8">
|
||||
<BsChevronExpand />
|
||||
</Combobox.Button>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
afterLeave={() => setQuery("")}>
|
||||
<Combobox.Options className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-xl bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
|
||||
{filteredCountries.length === 0 && query !== "" ? (
|
||||
<div className="relative cursor-default select-none py-2 px-4 text-gray-700">Nothing found.</div>
|
||||
) : (
|
||||
filteredCountries.map((country) => (
|
||||
<Combobox.Option
|
||||
key={country.code}
|
||||
value={country.code}
|
||||
className={({active}) =>
|
||||
`relative cursor-default select-none py-2 pl-10 pr-4 ${
|
||||
active ? "bg-mti-purple-light text-white" : "text-gray-900"
|
||||
}`
|
||||
}>
|
||||
{country.label}
|
||||
</Combobox.Option>
|
||||
))
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</Combobox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,16 @@
|
||||
import clsx from "clsx";
|
||||
import {useState} from "react";
|
||||
|
||||
interface Props {
|
||||
type: "email" | "text" | "password" | "tel" | "number";
|
||||
roundness?: "full" | "xl";
|
||||
type: "email" | "text" | "password";
|
||||
required?: boolean;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string | number;
|
||||
value?: string | number;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
defaultValue?: string;
|
||||
name: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function Input({
|
||||
type,
|
||||
label,
|
||||
placeholder,
|
||||
name,
|
||||
required = false,
|
||||
value,
|
||||
defaultValue,
|
||||
className,
|
||||
roundness = "full",
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: Props) {
|
||||
export default function Input({type, label, placeholder, name, required = false, defaultValue, onChange}: Props) {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
if (type === "password") {
|
||||
@@ -45,7 +28,6 @@ export default function Input({
|
||||
name={name}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
defaultValue={defaultValue}
|
||||
className="w-full px-8 py-6 text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||
/>
|
||||
<p
|
||||
@@ -60,7 +42,7 @@ export default function Input({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx("flex flex-col gap-3 w-full", className)}>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
{label && (
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
{label}
|
||||
@@ -70,16 +52,9 @@ export default function Input({
|
||||
<input
|
||||
type={type}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
min={type === "number" ? 0 : undefined}
|
||||
placeholder={placeholder}
|
||||
className={clsx(
|
||||
"px-8 py-6 text-sm font-normal bg-white border border-mti-gray-platinum focus:outline-none",
|
||||
"placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed",
|
||||
roundness === "full" ? "rounded-full" : "rounded-xl",
|
||||
)}
|
||||
className="px-8 py-6 text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||
required={required}
|
||||
defaultValue={defaultValue}
|
||||
/>
|
||||
|
||||
@@ -5,14 +5,11 @@ interface Props {
|
||||
label: string;
|
||||
percentage: number;
|
||||
color: "red" | "rose" | "purple" | Module;
|
||||
mark?: number;
|
||||
markLabel?: string;
|
||||
useColor?: boolean;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
}
|
||||
|
||||
export default function ProgressBar({label, percentage, color, mark, markLabel, useColor = false, className, textClassName}: Props) {
|
||||
export default function ProgressBar({label, percentage, color, useColor = false, className}: Props) {
|
||||
const progressColorClass: {[key in typeof color]: string} = {
|
||||
red: "bg-mti-red-light",
|
||||
rose: "bg-mti-rose-light",
|
||||
@@ -21,7 +18,6 @@ export default function ProgressBar({label, percentage, color, mark, markLabel,
|
||||
listening: "bg-ielts-listening",
|
||||
writing: "bg-ielts-writing",
|
||||
speaking: "bg-ielts-speaking",
|
||||
level: "bg-ielts-level",
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -32,14 +28,11 @@ export default function ProgressBar({label, percentage, color, mark, markLabel,
|
||||
!useColor ? "bg-mti-gray-anti-flash" : progressColorClass[color],
|
||||
useColor && "bg-opacity-20",
|
||||
)}>
|
||||
{mark && (
|
||||
<div style={{left: `${mark}%`}} className={clsx("w-3 h-2 bg-mti-gray-davy/60 absolute -translate-x-1/2 top-0 z-20 cursor-pointer")} />
|
||||
)}
|
||||
<div
|
||||
style={{width: `${percentage}%`}}
|
||||
className={clsx("absolute transition-all duration-300 ease-in-out top-0 left-0 h-full overflow-hidden", progressColorClass[color])}
|
||||
/>
|
||||
<span className={clsx("z-[1] justify-self-center text-white text-sm font-bold", textClassName)}>{label}</span>
|
||||
<span className="z-10 justify-self-center text-white text-sm font-bold">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import clsx from "clsx";
|
||||
import {ComponentProps, useEffect, useState} from "react";
|
||||
import ReactSelect, {GroupBase, StylesConfig} from "react-select";
|
||||
|
||||
interface Option {
|
||||
[key: string]: any;
|
||||
value: string | null;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
defaultValue?: Option;
|
||||
value?: Option | null;
|
||||
options: Option[];
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
onChange: (value: Option | null) => void;
|
||||
isClearable?: boolean;
|
||||
styles?: StylesConfig<Option, boolean, GroupBase<Option>>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Select({value, defaultValue, options, placeholder, disabled, onChange, styles, isClearable, className}: Props) {
|
||||
const [target, setTarget] = useState<HTMLElement>();
|
||||
|
||||
useEffect(() => {
|
||||
if (document) setTarget(document.body);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ReactSelect
|
||||
className={
|
||||
styles
|
||||
? undefined
|
||||
: clsx(
|
||||
"placeholder:text-mti-gray-cool border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none",
|
||||
disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
|
||||
className,
|
||||
)
|
||||
}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange as any}
|
||||
placeholder={placeholder}
|
||||
menuPortalTarget={target}
|
||||
defaultValue={defaultValue}
|
||||
styles={
|
||||
styles || {
|
||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}
|
||||
}
|
||||
isDisabled={disabled}
|
||||
isClearable={isClearable}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Fragment, useState } from "react";
|
||||
import { Combobox, Transition } from "@headlessui/react";
|
||||
import { BsChevronExpand } from "react-icons/bs";
|
||||
import moment from "moment-timezone";
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function TimezoneSelect({
|
||||
value,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: Props) {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const timezones = moment.tz.names();
|
||||
|
||||
const filteredTimezones = query === "" ? timezones : timezones.filter((x) => x.toLowerCase().includes(query.toLowerCase()));
|
||||
return (
|
||||
<>
|
||||
<Combobox value={value} onChange={onChange} disabled={disabled}>
|
||||
<div className="relative mt-1">
|
||||
<div className="relative w-full cursor-default overflow-hidden ">
|
||||
<Combobox.Input
|
||||
className="py-6 w-full px-8 text-sm font-normal placeholder:text-mti-gray-cool bg-white disabled:bg-mti-gray-platinum/40 rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-8">
|
||||
<BsChevronExpand />
|
||||
</Combobox.Button>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
afterLeave={() => setQuery("")}
|
||||
>
|
||||
<Combobox.Options className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-xl bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
|
||||
{filteredTimezones.map((timezone: string) => (
|
||||
<Combobox.Option
|
||||
key={timezone}
|
||||
value={timezone}
|
||||
className={({ active }) =>
|
||||
`relative cursor-default select-none py-2 pl-10 pr-4 ${
|
||||
active
|
||||
? "bg-mti-purple-light text-white"
|
||||
: "text-gray-900"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{timezone}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</Combobox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { Invite } from "@/interfaces/invite";
|
||||
import { User } from "@/interfaces/user";
|
||||
import axios from "axios";
|
||||
import { useState } from "react";
|
||||
import { BsArrowRepeat } from "react-icons/bs";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
interface Props {
|
||||
invite: Invite;
|
||||
users: User[];
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
export default function InviteCard({ invite, users, reload }: Props) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const inviter = users.find((u) => u.id === invite.from);
|
||||
const name = !inviter
|
||||
? null
|
||||
: inviter.type === "corporate"
|
||||
? inviter.corporateInformation?.companyInformation?.name || inviter.name
|
||||
: inviter.name;
|
||||
|
||||
const decide = (decision: "accept" | "decline") => {
|
||||
if (!confirm(`Are you sure you want to ${decision} this invite?`)) return;
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get(`/api/invites/${decision}/${invite.id}`)
|
||||
.then(() => {
|
||||
toast.success(
|
||||
`Successfully ${decision === "accept" ? "accepted" : "declined"} the invite!`,
|
||||
{ toastId: "success" },
|
||||
);
|
||||
reload();
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.success(`Something went wrong, please try again later!`, {
|
||||
toastId: "error",
|
||||
});
|
||||
reload();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-mti-gray-anti-flash flex min-w-[200px] flex-col gap-6 rounded-xl border p-4 text-black">
|
||||
<span>Invited by {name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => decide("accept")}
|
||||
disabled={isLoading}
|
||||
className="bg-mti-green-ultralight hover:bg-mti-green-light w-24 rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed"
|
||||
>
|
||||
{!isLoading && "Accept"}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => decide("decline")}
|
||||
disabled={isLoading}
|
||||
className="bg-mti-red-ultralight hover:bg-mti-red-light w-24 rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed"
|
||||
>
|
||||
{!isLoading && "Decline"}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import {Module} from "@/interfaces";
|
||||
import {writingMarking} from "@/utils/score";
|
||||
import {Menu} from "@headlessui/react";
|
||||
import {Dispatch, SetStateAction} from "react";
|
||||
import {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||
|
||||
type Levels = {[key in Module]: number};
|
||||
|
||||
interface Props {
|
||||
levels: Levels;
|
||||
setLevels: Dispatch<SetStateAction<Levels>>;
|
||||
}
|
||||
|
||||
export default function ModuleLevelSelector({levels, setLevels}: Props) {
|
||||
return (
|
||||
<div className="flex flex-col gap-32 w-full">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-16">
|
||||
<div className="w-full flex flex-col gap-3.5 relative">
|
||||
<span className="text-sm text-mti-gray-dim">
|
||||
<span className="font-bold">Reading</span> level
|
||||
</span>
|
||||
<Menu>
|
||||
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
||||
<BsBook className="text-ielts-reading" size={34} />
|
||||
<span className="text-mti-gray-cool text-sm">
|
||||
{levels.reading === -1 ? "Select your reading level" : `Level ${levels.reading}`}
|
||||
</span>
|
||||
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
||||
</Menu.Button>
|
||||
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
|
||||
{Object.values(writingMarking).map((x) => (
|
||||
<Menu.Item key={x}>
|
||||
<span
|
||||
onClick={() => setLevels((prev) => ({...prev, reading: x}))}
|
||||
className="w-full py-4 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
||||
Level {x}
|
||||
</span>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-3.5 relative">
|
||||
<span className="text-sm text-mti-gray-dim">
|
||||
<span className="font-bold">Listening</span> level
|
||||
</span>
|
||||
<Menu>
|
||||
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
||||
<BsHeadphones className="text-ielts-listening" size={34} />
|
||||
<span className="text-mti-gray-cool text-sm">
|
||||
{levels.listening === -1 ? "Select your listening level" : `Level ${levels.listening}`}
|
||||
</span>
|
||||
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
||||
</Menu.Button>
|
||||
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-50 drop-shadow-lg rounded-2xl">
|
||||
{Object.values(writingMarking).map((x) => (
|
||||
<Menu.Item key={x}>
|
||||
<span
|
||||
onClick={() => setLevels((prev) => ({...prev, listening: x}))}
|
||||
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
||||
Level {x}
|
||||
</span>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-3.5 relative">
|
||||
<span className="text-sm text-mti-gray-dim">
|
||||
<span className="font-bold">Writing</span> level
|
||||
</span>
|
||||
<Menu>
|
||||
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
||||
<BsPen className="text-ielts-writing" size={34} />
|
||||
<span className="text-mti-gray-cool text-sm">
|
||||
{levels.writing === -1 ? "Select your writing level" : `Level ${levels.writing}`}
|
||||
</span>
|
||||
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
||||
</Menu.Button>
|
||||
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
|
||||
{Object.values(writingMarking).map((x) => (
|
||||
<Menu.Item key={x}>
|
||||
<span
|
||||
onClick={() => setLevels((prev) => ({...prev, writing: x}))}
|
||||
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
||||
Level {x}
|
||||
</span>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-3.5 relative">
|
||||
<span className="text-sm text-mti-gray-dim">
|
||||
<span className="font-bold">Speaking</span> level
|
||||
</span>
|
||||
<Menu>
|
||||
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
||||
<BsMegaphone className="text-ielts-speaking" size={34} />
|
||||
<span className="text-mti-gray-cool text-sm">
|
||||
{levels.speaking === -1 ? "Select your speaking level" : `Level ${levels.speaking}`}
|
||||
</span>
|
||||
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
||||
</Menu.Button>
|
||||
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
|
||||
{Object.values(writingMarking).map((x) => (
|
||||
<Menu.Item key={x}>
|
||||
<span
|
||||
onClick={() => setLevels((prev) => ({...prev, speaking: x}))}
|
||||
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
||||
Level {x}
|
||||
</span>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {moduleLabels} from "@/utils/moduleUtils";
|
||||
import clsx from "clsx";
|
||||
import {motion} from "framer-motion";
|
||||
import {ReactNode, useEffect, useState} from "react";
|
||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
|
||||
import {BsBook, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
|
||||
import ProgressBar from "../Low/ProgressBar";
|
||||
import TimerEndedModal from "../TimerEndedModal";
|
||||
|
||||
@@ -21,11 +21,7 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
|
||||
const [timer, setTimer] = useState(minTimer * 60);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [warningMode, setWarningMode] = useState(false);
|
||||
|
||||
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
|
||||
const {timeSpent} = useExamStore((state) => state);
|
||||
|
||||
useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!disableTimer) {
|
||||
@@ -50,7 +46,6 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
|
||||
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,
|
||||
writing: <BsPen className="text-ielts-writing w-6 h-6" />,
|
||||
speaking: <BsMegaphone className="text-ielts-speaking w-6 h-6" />,
|
||||
level: <BsClipboard className="text-ielts-level w-6 h-6" />,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -64,14 +59,14 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
|
||||
/>
|
||||
<motion.div
|
||||
className={clsx(
|
||||
"absolute top-4 right-6 bg-mti-gray-seasalt px-4 py-3 flex items-center gap-2 rounded-full text-mti-gray-davy",
|
||||
"absolute top-4 right-6 bg-mti-gray-seasalt px-3 py-2 flex items-center gap-2 rounded-full text-mti-gray-davy",
|
||||
warningMode && !disableTimer && "bg-mti-red-light text-mti-gray-seasalt",
|
||||
)}
|
||||
initial={{scale: warningMode && !disableTimer ? 0.8 : 1}}
|
||||
animate={{scale: warningMode && !disableTimer ? 1.1 : 1}}
|
||||
transition={{repeat: Infinity, repeatType: "reverse", duration: 0.5, ease: "easeInOut"}}>
|
||||
<BsStopwatch className="w-6 h-6" />
|
||||
<span className="text-base font-semibold w-12">
|
||||
<BsStopwatch className="w-4 h-4" />
|
||||
<span className="text-sm font-semibold w-11">
|
||||
{timer > 0 && (
|
||||
<>
|
||||
{Math.floor(timer / 60)
|
||||
@@ -93,8 +88,8 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
|
||||
<span className="text-base font-semibold">
|
||||
{moduleLabels[module]} exam {label && `- ${label}`}
|
||||
</span>
|
||||
<span className="text-sm font-semibold self-end">
|
||||
Exercise {exerciseIndex}/{totalExercises}
|
||||
<span className="text-xs font-normal self-end text-mti-gray-davy">
|
||||
Question {exerciseIndex}/{totalExercises}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import {Session} from "@/hooks/useSessions";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {sortByModuleName} from "@/utils/moduleUtils";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize} from "lodash";
|
||||
import moment from "moment";
|
||||
import {useState} from "react";
|
||||
import {BsArrowRepeat, BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
|
||||
export default function SessionCard({
|
||||
session,
|
||||
reload,
|
||||
loadSession,
|
||||
}: {
|
||||
session: Session;
|
||||
reload: () => void;
|
||||
loadSession: (session: Session) => Promise<void>;
|
||||
}) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const deleteSession = async () => {
|
||||
if (!confirm("Are you sure you want to delete this session?")) return;
|
||||
|
||||
setIsLoading(true);
|
||||
await axios
|
||||
.delete(`/api/sessions/${session.sessionId}`)
|
||||
.then(() => {
|
||||
toast.success(`Successfully delete session "${session.sessionId}"`);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
toast.error("Something went wrong, please try again later");
|
||||
})
|
||||
.finally(() => {
|
||||
reload();
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-mti-gray-anti-flash flex w-64 flex-col gap-3 rounded-xl border p-4 text-black">
|
||||
<span className="flex gap-1">
|
||||
<b>ID:</b>
|
||||
{session.sessionId}
|
||||
</span>
|
||||
<span className="flex gap-1">
|
||||
<b>Date:</b>
|
||||
{moment(session.date).format("DD/MM/YYYY - HH:mm")}
|
||||
</span>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-center justify-center gap-2">
|
||||
{session.selectedModules.sort(sortByModuleName).map((module) => (
|
||||
<div
|
||||
key={module}
|
||||
data-tip={capitalize(module)}
|
||||
className={clsx(
|
||||
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||
module === "reading" && "bg-ielts-reading",
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}>
|
||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<button
|
||||
onClick={async () => await loadSession(session)}
|
||||
disabled={isLoading}
|
||||
className="bg-mti-green-ultralight w-full hover:bg-mti-green-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
|
||||
{!isLoading && "Resume"}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={deleteSession}
|
||||
disabled={isLoading}
|
||||
className="bg-mti-red-ultralight w-full hover:bg-mti-red-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
|
||||
{!isLoading && "Delete"}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import topics from "@/resources/topics";
|
||||
import {useState} from "react";
|
||||
import {BsArrowLeft, BsArrowRight} from "react-icons/bs";
|
||||
import Button from "../Low/Button";
|
||||
import Modal from "../Modal";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
initialTopics: string[];
|
||||
onClose: VoidFunction;
|
||||
selectTopics: (topics: string[]) => void;
|
||||
}
|
||||
|
||||
export default function TopicModal({isOpen, initialTopics, onClose, selectTopics}: Props) {
|
||||
const [selectedTopics, setSelectedTopics] = useState([...initialTopics]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Preferred Topics">
|
||||
<div className="flex flex-col w-full h-full gap-4 mt-4">
|
||||
<div className="w-full h-full grid grid-cols-2 -md:gap-1 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="border-b border-b-neutral-400/30">Available Topics</span>
|
||||
<div className=" max-h-[500px] overflow-y-scroll scrollbar-hide">
|
||||
{topics
|
||||
.filter((x) => !selectedTopics.includes(x))
|
||||
.map((x) => (
|
||||
<div key={x} className="odd:bg-mti-purple-ultralight/40 p-2 flex justify-between items-center">
|
||||
<span>{x}</span>
|
||||
<button
|
||||
onClick={() => setSelectedTopics((prev) => [...prev, x])}
|
||||
className="border border-mti-purple-light cursor-pointer p-2 rounded-lg bg-white drop-shadow transition ease-in-out duration-300 hover:bg-mti-purple hover:text-white">
|
||||
<BsArrowRight />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="border-b border-b-neutral-400/30">Preferred Topics ({selectedTopics.length || "All"})</span>
|
||||
<div className=" max-h-[500px] overflow-y-scroll scrollbar-hide">
|
||||
{selectedTopics.map((x) => (
|
||||
<div key={x} className="odd:bg-mti-purple-ultralight/40 p-2 flex justify-between items-center text-right">
|
||||
<button
|
||||
onClick={() => setSelectedTopics((prev) => [...prev.filter((y) => y !== x)])}
|
||||
className="border border-mti-purple-light cursor-pointer p-2 rounded-lg bg-white drop-shadow transition ease-in-out duration-300 hover:bg-mti-purple hover:text-white">
|
||||
<BsArrowLeft />
|
||||
</button>
|
||||
<span>{x}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex gap-4 items-center justify-end">
|
||||
<Button variant="outline" color="rose" className="w-full max-w-[200px]" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full max-w-[200px]"
|
||||
onClick={() => {
|
||||
selectTopics(selectedTopics);
|
||||
onClose();
|
||||
}}>
|
||||
Select
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
import { User } from "@/interfaces/user";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { Fragment } from "react";
|
||||
import { BsXLg } from "react-icons/bs";
|
||||
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
path: string;
|
||||
user: User;
|
||||
disableNavigation?: boolean;
|
||||
}
|
||||
|
||||
export default function MobileMenu({
|
||||
isOpen,
|
||||
onClose,
|
||||
path,
|
||||
user,
|
||||
disableNavigation,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const logout = async () => {
|
||||
axios.post("/api/logout").finally(() => {
|
||||
setTimeout(() => router.reload(), 500);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center text-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="flex h-screen w-full transform flex-col gap-8 overflow-hidden bg-white text-left align-middle text-black shadow-xl transition-all">
|
||||
<Dialog.Title
|
||||
as="header"
|
||||
className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden"
|
||||
>
|
||||
<Link href={disableNavigation ? "" : "/"}>
|
||||
<Image
|
||||
src="/logo_title.png"
|
||||
alt="EnCoach logo"
|
||||
width={69}
|
||||
height={69}
|
||||
/>
|
||||
</Link>
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={onClose}
|
||||
tabIndex={0}
|
||||
>
|
||||
<BsXLg
|
||||
className="text-mti-purple-light text-2xl"
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
</Dialog.Title>
|
||||
<div className="flex h-full flex-col gap-6 px-8 text-lg">
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||
)}
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
{checkAccess(user, ["student", "teacher", "developer"]) && (
|
||||
<>
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/exam"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/exam" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||
)}
|
||||
>
|
||||
Exams
|
||||
</Link>
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/exercises"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/exercises" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||
)}
|
||||
>
|
||||
Exercises
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/stats"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/stats" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||
)}
|
||||
>
|
||||
Stats
|
||||
</Link>
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/record"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/record" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||
)}
|
||||
>
|
||||
Record
|
||||
</Link>
|
||||
{checkAccess(user, [
|
||||
"admin",
|
||||
"developer",
|
||||
"agent",
|
||||
"corporate",
|
||||
"mastercorporate",
|
||||
]) && (
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/payment-record"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/payment-record" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||
)}
|
||||
>
|
||||
Payment Record
|
||||
</Link>
|
||||
)}
|
||||
{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
|
||||
className={clsx(
|
||||
"w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out"
|
||||
)}
|
||||
onClick={logout}
|
||||
>
|
||||
Logout
|
||||
</span>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import {Dialog, Transition} from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import {Fragment, ReactElement} from "react";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
className?: string;
|
||||
children?: ReactElement;
|
||||
}
|
||||
|
||||
export default function Modal({isOpen, title, className, onClose, children}: Props) {
|
||||
return (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-[200]" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95">
|
||||
<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 && (
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
)}
|
||||
{children}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
@@ -1,219 +1,32 @@
|
||||
import { User } from "@/interfaces/user";
|
||||
import {User} from "@/interfaces/user";
|
||||
import Link from "next/link";
|
||||
import FocusLayer from "@/components/FocusLayer";
|
||||
import {Avatar} from "primereact/avatar";
|
||||
import FocusLayer from '@/components/FocusLayer';
|
||||
import { preventNavigation } from "@/utils/navigation.disabled";
|
||||
import { useRouter } from "next/router";
|
||||
import { BsList, BsQuestionCircle, BsQuestionCircleFill } from "react-icons/bs";
|
||||
import clsx from "clsx";
|
||||
import moment from "moment";
|
||||
import MobileMenu from "./MobileMenu";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Type } from "@/interfaces/user";
|
||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import { isUserFromCorporate } from "@/utils/groups";
|
||||
import Button from "./Low/Button";
|
||||
import Modal from "./Modal";
|
||||
import Input from "./Low/Input";
|
||||
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 {
|
||||
user: User;
|
||||
navDisabled?: boolean;
|
||||
focusMode?: boolean;
|
||||
onFocusLayerMouseEnter?: () => void;
|
||||
path: string;
|
||||
user: User;
|
||||
navDisabled?: boolean;
|
||||
focusMode?: boolean;
|
||||
onFocusLayerMouseEnter?: Function;
|
||||
}
|
||||
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
export default function Navbar({
|
||||
user,
|
||||
path,
|
||||
navDisabled = false,
|
||||
focusMode = false,
|
||||
onFocusLayerMouseEnter,
|
||||
}: Props) {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
|
||||
const [isTicketOpen, setIsTicketOpen] = useState(false);
|
||||
export default function Navbar({user, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||
|
||||
const expirationDateColor = (date: Date) => {
|
||||
const momentDate = moment(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(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 = () => {
|
||||
if (!user.subscriptionExpirationDate) return false;
|
||||
|
||||
const momentDate = moment(user.subscriptionExpirationDate);
|
||||
const today = moment(new Date());
|
||||
|
||||
return today.add(7, "days").isAfter(momentDate);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user.type !== "student" && user.type !== "teacher")
|
||||
return setDisablePaymentPage(false);
|
||||
isUserFromCorporate(user.id).then((result) =>
|
||||
setDisablePaymentPage(result)
|
||||
);
|
||||
}, [user]);
|
||||
|
||||
const badges = [
|
||||
{
|
||||
module: "reading",
|
||||
icon: () => <BsBook className="h-4 w-4 text-white" />,
|
||||
achieved: user.levels.reading >= user.desiredLevels.reading,
|
||||
},
|
||||
|
||||
{
|
||||
module: "listening",
|
||||
icon: () => <BsHeadphones className="h-4 w-4 text-white" />,
|
||||
achieved: user.levels.listening >= user.desiredLevels.listening,
|
||||
},
|
||||
{
|
||||
module: "writing",
|
||||
icon: () => <BsPen className="h-4 w-4 text-white" />,
|
||||
achieved: user.levels.writing >= user.desiredLevels.writing,
|
||||
},
|
||||
{
|
||||
module: "speaking",
|
||||
icon: () => <BsMegaphone className="h-4 w-4 text-white" />,
|
||||
achieved: user.levels.speaking >= user.desiredLevels.speaking,
|
||||
},
|
||||
{
|
||||
module: "level",
|
||||
icon: () => <BsClipboard className="h-4 w-4 text-white" />,
|
||||
achieved: user.levels.level >= user.desiredLevels.level,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isTicketOpen}
|
||||
onClose={() => setIsTicketOpen(false)}
|
||||
title="Submit a ticket"
|
||||
>
|
||||
<TicketSubmission
|
||||
user={user}
|
||||
page={router.asPath}
|
||||
onClose={() => setIsTicketOpen(false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{user && (
|
||||
<MobileMenu
|
||||
disableNavigation={disableNavigation}
|
||||
path={path}
|
||||
isOpen={isMenuOpen}
|
||||
onClose={() => setIsMenuOpen(false)}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
<header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4">
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/"}
|
||||
className=" flex items-center gap-8 md:px-8"
|
||||
>
|
||||
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" />
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<header className="w-full bg-transparent py-4 gap-2 flex items-center relative">
|
||||
<h1 className="font-bold text-2xl w-1/6 px-8">EnCoach</h1>
|
||||
<div className="flex justify-between w-5/6 mr-8">
|
||||
<input type="text" placeholder="Search..." className="rounded-full py-4 px-6 border border-mti-gray-platinum outline-none" />
|
||||
<Link href={disableNavigation ? "" : "/profile"} className="flex gap-3 items-center justify-end">
|
||||
<img src={user.profilePicture} alt={user.name} className="w-10 h-10 rounded-full object-cover" />
|
||||
<span className="text-right">{user.name}</span>
|
||||
</Link>
|
||||
</div>
|
||||
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter}/>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
import React, {ChangeEvent} from "react";
|
||||
import {BsUpload, BsDownload, BsTrash, BsArrowRepeat, BsXCircleFill} from "react-icons/bs";
|
||||
import {FilesStorage} from "@/interfaces/storage.files";
|
||||
import axios from "axios";
|
||||
|
||||
interface Asset {
|
||||
file: string | File;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
const PaymentAssetManager = (props: {
|
||||
asset: string | undefined;
|
||||
permissions: "read" | "write";
|
||||
type: FilesStorage;
|
||||
reload: () => void;
|
||||
paymentId: string;
|
||||
}) => {
|
||||
const {asset, permissions, type, paymentId} = props;
|
||||
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const fileInputReplaceRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const [managingAsset, setManagingAsset] = React.useState<Asset>({
|
||||
file: asset || "",
|
||||
complete: asset ? true : false,
|
||||
});
|
||||
|
||||
const {file, complete} = managingAsset;
|
||||
|
||||
const deleteAsset = () => {
|
||||
if (confirm("Are you sure you want to delete this document?")) {
|
||||
axios
|
||||
.delete(`/api/payments/files/${type}/${paymentId}`)
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
console.log("File deleted successfully!");
|
||||
setManagingAsset({
|
||||
file: "",
|
||||
complete: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("File deletion failed");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error occurred during file deletion:", error);
|
||||
})
|
||||
.finally(props.reload);
|
||||
}
|
||||
};
|
||||
|
||||
const renderFileInput = (onChange: any, ref: React.RefObject<HTMLInputElement>) => (
|
||||
<input type="file" ref={ref} style={{display: "none"}} onChange={onChange} multiple={false} accept="application/pdf" />
|
||||
);
|
||||
|
||||
const handleFileChange = async (e: Event, method: "post" | "patch") => {
|
||||
const newFile = (e.target as HTMLInputElement).files?.[0];
|
||||
if (newFile) {
|
||||
setManagingAsset({
|
||||
file: newFile,
|
||||
complete: false,
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", newFile);
|
||||
|
||||
axios[method](`/api/payments/files/${type}/${paymentId}`, formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
console.log("File uploaded successfully!");
|
||||
console.log("Uploaded File URL:", response.data.ref);
|
||||
// Further actions upon successful upload
|
||||
setManagingAsset({
|
||||
file: response.data.ref,
|
||||
complete: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("File upload failed");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error occurred during file upload:", error);
|
||||
})
|
||||
.finally(props.reload);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadAsset = () => {
|
||||
axios
|
||||
.get(`/api/payments/files/${type}/${paymentId}`)
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
console.log("Uploaded File URL:", response.data.url);
|
||||
const link = document.createElement("a");
|
||||
link.download = response.data.filename;
|
||||
link.href = response.data.url;
|
||||
link.click();
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("Failed to download file");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error occurred during file upload:", error);
|
||||
});
|
||||
};
|
||||
|
||||
if (permissions === "read") {
|
||||
if (file) return <BsDownload onClick={downloadAsset} />;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (file) {
|
||||
if (complete) {
|
||||
return (
|
||||
<>
|
||||
<BsDownload onClick={downloadAsset} />
|
||||
<BsArrowRepeat onClick={() => fileInputReplaceRef.current?.click()} />
|
||||
<BsTrash onClick={deleteAsset} />
|
||||
{renderFileInput((e: Event) => handleFileChange(e, "patch"), fileInputReplaceRef)}
|
||||
{renderFileInput((e: Event) => handleFileChange(e, "post"), fileInputRef)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="loading loading-infinity w-8" />;
|
||||
}
|
||||
|
||||
return permissions === "write" ? (
|
||||
<>
|
||||
<BsUpload onClick={() => fileInputRef.current?.click()} />
|
||||
{renderFileInput((e: Event) => handleFileChange(e, "post"), fileInputRef)}
|
||||
</>
|
||||
) : (
|
||||
<BsXCircleFill />
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentAssetManager;
|
||||
@@ -1,77 +0,0 @@
|
||||
import {PaymentIntention} from "@/interfaces/paymob";
|
||||
import {DurationUnit} from "@/interfaces/paypal";
|
||||
import {User} from "@/interfaces/user";
|
||||
import axios from "axios";
|
||||
import {useRouter} from "next/router";
|
||||
import {useState} from "react";
|
||||
import Button from "./Low/Button";
|
||||
import Input from "./Low/Input";
|
||||
import Modal from "./Modal";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
currency: string;
|
||||
price: number;
|
||||
setIsPaymentLoading: (v: boolean) => void;
|
||||
duration: number;
|
||||
duration_unit: DurationUnit;
|
||||
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
|
||||
}
|
||||
|
||||
export default function PaymobPayment({user, price, setIsPaymentLoading, currency, duration, duration_unit, onSuccess}: Props) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const handleCardPayment = async () => {
|
||||
try {
|
||||
setIsPaymentLoading(true);
|
||||
|
||||
const paymentIntention: PaymentIntention = {
|
||||
amount: price * 1000,
|
||||
currency: "OMR",
|
||||
items: [],
|
||||
payment_methods: [],
|
||||
customer: {
|
||||
email: user.email,
|
||||
first_name: user.name.split(" ")[0],
|
||||
last_name: [...user.name.split(" ")].pop() || "N/A",
|
||||
extras: {
|
||||
re: user.id,
|
||||
},
|
||||
},
|
||||
billing_data: {
|
||||
apartment: "N/A",
|
||||
building: "N/A",
|
||||
country: user.demographicInformation?.country || "N/A",
|
||||
email: user.email,
|
||||
first_name: user.name.split(" ")[0],
|
||||
last_name: [...user.name.split(" ")].pop() || "N/A",
|
||||
floor: "N/A",
|
||||
phone_number: user.demographicInformation?.phone || "N/A",
|
||||
state: "N/A",
|
||||
street: "N/A",
|
||||
},
|
||||
extras: {
|
||||
userID: user.id,
|
||||
duration,
|
||||
duration_unit,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await axios.post<{iframeURL: string}>(`/api/paymob`, paymentIntention);
|
||||
|
||||
router.push(response.data.iframeURL);
|
||||
} catch (error) {
|
||||
console.error("Error starting card payment process:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button isLoading={isLoading} onClick={handleCardPayment}>
|
||||
Select
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
31
src/components/ProfileCard.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {User} from "@/interfaces/user";
|
||||
import clsx from "clsx";
|
||||
import LevelLabel from "./LevelLabel";
|
||||
import LevelProgressBar from "./LevelProgressBar";
|
||||
import {Avatar} from "primereact/avatar";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
className: string;
|
||||
}
|
||||
|
||||
export default function ProfileCard({user, className}: Props) {
|
||||
return (
|
||||
<div className={clsx("bg-white drop-shadow-xl p-4 md:p-8 rounded-xl w-full flex flex-col gap-6", className)}>
|
||||
<div className="flex w-full items-center gap-8">
|
||||
<div className="w-16 md:w-24 h-16 md:h-24 rounded-full border-2 md:border-4 border-white drop-shadow-md md:drop-shadow-xl">
|
||||
{user.profilePicture.length > 0 && <img src={user.profilePicture} alt="Profile picture" className="rounded-full object-cover" />}
|
||||
{user.profilePicture.length === 0 && (
|
||||
<Avatar size="xlarge" style={{width: "100%", height: "100%"}} label={user.name.slice(0, 1)} shape="circle" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col justify-center">
|
||||
<span className="text-neutral-600 font-bold text-xl lg:text-2xl">{user.name}</span>
|
||||
<LevelLabel experience={user.experience} />
|
||||
</div>
|
||||
</div>
|
||||
<LevelProgressBar experience={user.experience} progressBarWidth="w-32 md:w-96" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
src/components/ProfileLevel.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {User} from "@/interfaces/user";
|
||||
import {levelCalculator} from "@/resources/level";
|
||||
import clsx from "clsx";
|
||||
import LevelLabel from "./LevelLabel";
|
||||
import LevelProgressBar from "./LevelProgressBar";
|
||||
import {Avatar} from "primereact/avatar";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ProfileLevel({user, className}: Props) {
|
||||
const levelResult = levelCalculator(user.experience);
|
||||
|
||||
return (
|
||||
<div className={clsx("flex flex-col items-center justify-center gap-4", className)}>
|
||||
<div className="w-16 md:w-24 h-16 md:h-24 rounded-full">
|
||||
{user.profilePicture.length > 0 && <img src={user.profilePicture} alt="Profile picture" className="rounded-full object-cover" />}
|
||||
{user.profilePicture.length === 0 && (
|
||||
<Avatar size="xlarge" style={{width: "100%", height: "100%"}} label={user.name.slice(0, 1)} shape="circle" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 items-center">
|
||||
<LevelLabel experience={user.experience} />
|
||||
<LevelProgressBar experience={user.experience} className="text-black" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {User} from "@/interfaces/user";
|
||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||
import {calculateAverageLevel} from "@/utils/score";
|
||||
import {capitalize} from "lodash";
|
||||
import {ReactElement} from "react";
|
||||
import ProgressBar from "./Low/ProgressBar";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
items: {
|
||||
icon: ReactElement;
|
||||
value: string | number;
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
}[];
|
||||
children?: ReactElement;
|
||||
}
|
||||
|
||||
export default function ProfileSummary({user, items}: Props) {
|
||||
return (
|
||||
<section className="w-full flex -md:flex-col gap-4 md:gap-8">
|
||||
<img
|
||||
src={user.profilePicture}
|
||||
alt={user.name}
|
||||
className="aspect-square h-20 md:h-64 rounded-3xl drop-shadow-xl object-cover -md:hidden"
|
||||
/>
|
||||
<div className="flex md:flex-col gap-4 md:py-4 w-full -md:items-center">
|
||||
<img src={user.profilePicture} alt={user.name} className="aspect-square h-24 md:hidden rounded-3xl drop-shadow-xl object-cover" />
|
||||
<div className="flex -md:flex-col justify-between w-full gap-8">
|
||||
<div className="flex flex-col gap-2 py-2">
|
||||
<h1 className="font-bold text-2xl md:text-4xl">{user.name}</h1>
|
||||
<h6 className="font-normal text-base text-mti-gray-taupe">{USER_TYPE_LABELS[user.type]}</h6>
|
||||
</div>
|
||||
<ProgressBar
|
||||
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
|
||||
percentage={100}
|
||||
color="purple"
|
||||
className="max-w-xs w-32 md:self-end h-10 -md:hidden"
|
||||
/>
|
||||
</div>
|
||||
<ProgressBar
|
||||
label=""
|
||||
percentage={Math.round((calculateAverageLevel(user.levels) * 100) / calculateAverageLevel(user.desiredLevels))}
|
||||
color="red"
|
||||
className="w-full h-3 drop-shadow-lg -md:hidden"
|
||||
/>
|
||||
|
||||
<div className="flex justify-between w-full mt-8 -md:hidden">
|
||||
{items.map((item) => (
|
||||
<div className="flex gap-4 items-center" key={item.label}>
|
||||
<div
|
||||
className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl relative group tooltip tooltip-bottom"
|
||||
data-tip={item.tooltip}
|
||||
>
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-xl">{item.value}</span>
|
||||
<span className="font-normal text-base text-mti-gray-dim">{item.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProgressBar
|
||||
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
|
||||
percentage={Math.round((calculateAverageLevel(user.levels) * 100) / calculateAverageLevel(user.desiredLevels))}
|
||||
color="purple"
|
||||
className="w-full md:hidden h-8"
|
||||
textClassName="!text-mti-black"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 w-full mt-4 md:hidden">
|
||||
{items.map((item) => (
|
||||
<div className="flex gap-4 items-center" key={item.label}>
|
||||
<div className="w-12 h-12 md:w-16 md:h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-lg md:rounded-xl">
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-lg md:text-xl">{item.value}</span>
|
||||
<span className="font-normal text-sm md:text-base text-mti-gray-dim">{item.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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;
|
||||
@@ -1,48 +0,0 @@
|
||||
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;
|
||||
@@ -1,39 +1,20 @@
|
||||
import clsx from "clsx";
|
||||
import {IconType} from "react-icons";
|
||||
import {MdSpaceDashboard} from "react-icons/md";
|
||||
import {
|
||||
BsFileEarmarkText,
|
||||
BsClockHistory,
|
||||
BsPencil,
|
||||
BsGraphUp,
|
||||
BsChevronBarRight,
|
||||
BsChevronBarLeft,
|
||||
BsShieldFill,
|
||||
BsCloudFill,
|
||||
BsCurrencyDollar,
|
||||
BsClipboardData,
|
||||
BsFileLock,
|
||||
} from "react-icons/bs";
|
||||
import {BsFileEarmarkText, BsClockHistory, BsPencil, BsGraphUp, BsShield} from "react-icons/bs";
|
||||
import {RiLogoutBoxFill} from "react-icons/ri";
|
||||
import {SlPencil} from "react-icons/sl";
|
||||
import {FaAward} from "react-icons/fa";
|
||||
import Link from "next/link";
|
||||
import {useRouter} from "next/router";
|
||||
import axios from "axios";
|
||||
import FocusLayer from "@/components/FocusLayer";
|
||||
import {preventNavigation} from "@/utils/navigation.disabled";
|
||||
import {useEffect, useState} from "react";
|
||||
import usePreferencesStore from "@/stores/preferencesStore";
|
||||
import {User} from "@/interfaces/user";
|
||||
import useTicketsListener from "@/hooks/useTicketsListener";
|
||||
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
||||
import FocusLayer from '@/components/FocusLayer';
|
||||
import { preventNavigation } from "@/utils/navigation.disabled";
|
||||
interface Props {
|
||||
path: string;
|
||||
navDisabled?: boolean;
|
||||
focusMode?: boolean;
|
||||
onFocusLayerMouseEnter?: () => void;
|
||||
className?: string;
|
||||
user: User;
|
||||
onFocusLayerMouseEnter?: Function;
|
||||
}
|
||||
|
||||
interface NavProps {
|
||||
@@ -42,186 +23,52 @@ interface NavProps {
|
||||
path: string;
|
||||
keyPath: string;
|
||||
disabled?: boolean;
|
||||
isMinimized?: boolean;
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false, badge}: NavProps) => {
|
||||
return (
|
||||
<Link
|
||||
href={!disabled ? keyPath : ""}
|
||||
className={clsx(
|
||||
"flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white",
|
||||
"transition-all duration-300 ease-in-out relative",
|
||||
disabled ? "hover:bg-mti-gray-dim cursor-not-allowed" : "hover:bg-mti-purple-light cursor-pointer",
|
||||
path === keyPath && "bg-mti-purple-light text-white",
|
||||
isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]",
|
||||
)}>
|
||||
<Icon size={24} />
|
||||
{!isMinimized && <span className="text-lg font-semibold">{label}</span>}
|
||||
{!!badge && badge > 0 && (
|
||||
<div
|
||||
className={clsx(
|
||||
"bg-mti-purple-light h-5 w-5 text-xs rounded-full flex items-center justify-center text-white",
|
||||
"transition ease-in-out duration-300",
|
||||
isMinimized && "absolute right-0 top-0",
|
||||
)}>
|
||||
{badge}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
const Nav = ({Icon, label, path, keyPath, disabled = false}: NavProps) => (
|
||||
<Link
|
||||
href={!disabled ? keyPath : ""}
|
||||
className={clsx(
|
||||
"p-4 px-8 rounded-full flex gap-4 items-center cursor-pointer text-gray-500 hover:bg-mti-purple-light hover:text-white transition duration-300 ease-in-out",
|
||||
path === keyPath && "bg-mti-purple-light text-white",
|
||||
)}>
|
||||
<Icon size={20} />
|
||||
<span className="text-lg font-semibold">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
export default function Sidebar({path, navDisabled = false, focusMode = false, user, onFocusLayerMouseEnter, className}: Props) {
|
||||
export default function Sidebar({path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
|
||||
|
||||
const {totalAssignedTickets} = useTicketsListener(user.id);
|
||||
|
||||
const logout = async () => {
|
||||
axios.post("/api/logout").finally(() => {
|
||||
setTimeout(() => router.reload(), 500);
|
||||
});
|
||||
};
|
||||
|
||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||
const disableNavigation: Boolean = preventNavigation(navDisabled, focusMode);
|
||||
|
||||
return (
|
||||
<section
|
||||
className={clsx(
|
||||
"relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8",
|
||||
isMinimized ? "w-fit" : "-xl:w-fit w-1/6",
|
||||
className,
|
||||
)}>
|
||||
<div className="-xl:hidden flex-col gap-3 xl:flex">
|
||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
|
||||
{checkAccess(user, ["student", "teacher", "developer"], "viewExams") && (
|
||||
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={isMinimized} />
|
||||
)}
|
||||
{checkAccess(user, ["student", "teacher", "developer"], "viewExercises") && (
|
||||
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={isMinimized} />
|
||||
)}
|
||||
{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
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCurrencyDollar}
|
||||
label="Payment Record"
|
||||
path={path}
|
||||
keyPath="/payment-record"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"]) && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsShieldFill}
|
||||
label="Settings"
|
||||
path={path}
|
||||
keyPath="/settings"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{checkAccess(user, ["admin", "developer", "agent"], "viewTickets") && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsClipboardData}
|
||||
label="Tickets"
|
||||
path={path}
|
||||
keyPath="/tickets"
|
||||
isMinimized={isMinimized}
|
||||
badge={totalAssignedTickets}
|
||||
/>
|
||||
)}
|
||||
{checkAccess(user, ["developer", "admin"]) && (
|
||||
<>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCloudFill}
|
||||
label="Generation"
|
||||
path={path}
|
||||
keyPath="/generation"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsFileLock}
|
||||
label="Permissions"
|
||||
path={path}
|
||||
keyPath="/permissions"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="-xl:flex flex-col gap-3 xl:hidden">
|
||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} />
|
||||
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} />
|
||||
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
|
||||
{checkAccess(user, getTypesOfUser(["agent"]), "viewStats") && (
|
||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
|
||||
)}
|
||||
{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} />
|
||||
)}
|
||||
{checkAccess(user, getTypesOfUser(["student"])) && (
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<section className="h-full flex bg-transparent flex-col justify-between w-1/6 px-4 py-4 pb-8 relative">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" />
|
||||
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" />
|
||||
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" />
|
||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" />
|
||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" />
|
||||
<Nav disabled={disableNavigation} Icon={BsShield} label="Admin" path={path} keyPath="/admin" />
|
||||
</div>
|
||||
|
||||
<div className="fixed bottom-12 flex flex-col gap-0">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={1}
|
||||
onClick={toggleMinimize}
|
||||
className={clsx(
|
||||
"hover:text-mti-rose -xl:hidden flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
|
||||
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
||||
)}>
|
||||
{isMinimized ? <BsChevronBarRight size={24} /> : <BsChevronBarLeft size={24} />}
|
||||
{!isMinimized && <span className="text-lg font-medium">Minimize</span>}
|
||||
</div>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={1}
|
||||
onClick={focusMode ? () => {} : logout}
|
||||
className={clsx(
|
||||
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
|
||||
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
||||
)}>
|
||||
<RiLogoutBoxFill size={24} />
|
||||
{!isMinimized && <span className="-xl:hidden text-lg font-medium">Log Out</span>}
|
||||
</div>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={1}
|
||||
onClick={focusMode ? () => {} : logout}
|
||||
className={clsx(
|
||||
"p-4 px-8 rounded-full flex gap-4 items-center cursor-pointer text-black hover:text-mti-rose transition duration-300 ease-in-out",
|
||||
"absolute bottom-8",
|
||||
)}>
|
||||
<RiLogoutBoxFill size={20} />
|
||||
<span className="text-lg font-medium">Log Out</span>
|
||||
</div>
|
||||
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
||||
</section>
|
||||
|
||||
@@ -5,31 +5,11 @@ import {CommonProps} from ".";
|
||||
import {Fragment} from "react";
|
||||
import Button from "../Low/Button";
|
||||
|
||||
export default function FillBlanksSolutions({
|
||||
id,
|
||||
type,
|
||||
prompt,
|
||||
solutions,
|
||||
words,
|
||||
text,
|
||||
userSolutions,
|
||||
onNext,
|
||||
onBack,
|
||||
}: FillBlanksExercise & CommonProps) {
|
||||
export default function FillBlanksSolutions({id, type, prompt, solutions, text, userSolutions, onNext, onBack}: FillBlanksExercise & CommonProps) {
|
||||
const calculateScore = () => {
|
||||
const total = text.match(/({{\d+}})/g)?.length || 0;
|
||||
const correct = userSolutions.filter((x) => {
|
||||
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution.toLowerCase();
|
||||
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 correct = userSolutions.filter((x) => solutions.find((y) => x.id === y.id)?.solution === x.solution.toLowerCase() || false).length;
|
||||
const missing = total - userSolutions.filter((x) => solutions.find((y) => x.id === y.id)).length;
|
||||
|
||||
return {total, correct, missing};
|
||||
};
|
||||
@@ -37,7 +17,7 @@ export default function FillBlanksSolutions({
|
||||
const renderLines = (line: string) => {
|
||||
return (
|
||||
<span>
|
||||
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
||||
{reactStringReplace(line, /({{\d}})/g, (match) => {
|
||||
const id = match.replaceAll(/[\{\}]/g, "");
|
||||
const userSolution = userSolutions.find((x) => x.id === id);
|
||||
const solution = solutions.find((x) => x.id === id)!;
|
||||
@@ -46,21 +26,14 @@ export default function FillBlanksSolutions({
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
"rounded-full hover:text-white hover:bg-mti-gray-davy transition duration-300 ease-in-out my-1 px-5 py-2 text-center text-white bg-mti-gray-davy",
|
||||
"rounded-full hover:text-white hover:bg-mti-red transition duration-300 ease-in-out my-1 px-5 py-2 text-center text-white bg-mti-red-light",
|
||||
)}>
|
||||
{solution?.solution}
|
||||
{solution.solution}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
if (userSolution.solution === solution.solution) {
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
@@ -72,7 +45,7 @@ export default function FillBlanksSolutions({
|
||||
);
|
||||
}
|
||||
|
||||
if (userSolutionText !== solution.solution) {
|
||||
if (userSolution.solution !== solution.solution) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
@@ -80,7 +53,7 @@ export default function FillBlanksSolutions({
|
||||
"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",
|
||||
)}>
|
||||
{userSolutionText}
|
||||
{userSolution.solution}
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -100,7 +73,7 @@ export default function FillBlanksSolutions({
|
||||
|
||||
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 mb-20">
|
||||
<span className="text-sm w-full leading-6">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
@@ -110,13 +83,12 @@ export default function FillBlanksSolutions({
|
||||
))}
|
||||
</span>
|
||||
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
||||
{userSolutions &&
|
||||
text.split("\\n").map((line, index) => (
|
||||
<p key={index}>
|
||||
{renderLines(line)}
|
||||
<br />
|
||||
</p>
|
||||
))}
|
||||
{text.split("\\n").map((line, index) => (
|
||||
<p key={index}>
|
||||
{renderLines(line)}
|
||||
<br />
|
||||
</p>
|
||||
))}
|
||||
</span>
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="flex gap-2 items-center">
|
||||
@@ -124,7 +96,7 @@ export default function FillBlanksSolutions({
|
||||
Correct
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-4 h-4 rounded-full bg-mti-gray-davy" />
|
||||
<div className="w-4 h-4 rounded-full bg-mti-red" />
|
||||
Unanswered
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {InteractiveSpeakingExercise} from "@/interfaces/exam";
|
||||
import {CommonProps} from ".";
|
||||
import {useEffect, useState} from "react";
|
||||
import Button from "../Low/Button";
|
||||
import dynamic from "next/dynamic";
|
||||
import axios from "axios";
|
||||
import {speakingReverseMarking} from "@/utils/score";
|
||||
import {Tab} from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import Modal from "../Modal";
|
||||
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
|
||||
|
||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||
|
||||
export default function InteractiveSpeaking({
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
text,
|
||||
prompts,
|
||||
userSolutions,
|
||||
onNext,
|
||||
onBack,
|
||||
}: InteractiveSpeakingExercise & CommonProps) {
|
||||
const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
|
||||
const [diffNumber, setDiffNumber] = useState<0 | 1 | 2 | 3>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
|
||||
Promise.all(userSolutions[0].solution.map((x) => axios.post(`/api/speaking`, {path: x.answer}, {responseType: "arraybuffer"}))).then(
|
||||
(values) => {
|
||||
setSolutionsURL(
|
||||
values.map(({data}) => {
|
||||
const blob = new Blob([data], {type: "audio/wav"});
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
return url;
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}, [userSolutions]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal title={`Correction (Prompt ${diffNumber})`} isOpen={diffNumber !== 0} onClose={() => setDiffNumber(0)}>
|
||||
<>
|
||||
{userSolutions &&
|
||||
userSolutions.length > 0 &&
|
||||
diffNumber !== 0 &&
|
||||
userSolutions[0].evaluation &&
|
||||
userSolutions[0].evaluation[`transcript_${diffNumber}`] &&
|
||||
userSolutions[0].evaluation[`fixed_text_${diffNumber}`] && (
|
||||
<div className="w-full h-full rounded-xl overflow-hidden flex flex-col mt-4">
|
||||
<div className="w-full grid grid-cols-2 bg-neutral-100">
|
||||
<span className="p-3 font-medium text-lg border-r border-r-neutral-200">Transcript</span>
|
||||
<span className="p-3 font-medium text-lg border-l border-l-neutral-200">Recommended Improvements</span>
|
||||
</div>
|
||||
<ReactDiffViewer
|
||||
styles={{
|
||||
contentText: {
|
||||
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
|
||||
padding: "32px 28px",
|
||||
},
|
||||
marker: {display: "none"},
|
||||
diffRemoved: {padding: "32px 28px"},
|
||||
diffAdded: {padding: "32px 28px"},
|
||||
|
||||
wordRemoved: {padding: "0px", display: "initial"},
|
||||
wordAdded: {padding: "0px", display: "initial"},
|
||||
wordDiff: {padding: "0px", display: "initial"},
|
||||
}}
|
||||
oldValue={userSolutions[0].evaluation[`transcript_${diffNumber}`]?.replaceAll("\\n", "\n")}
|
||||
newValue={userSolutions[0].evaluation[`fixed_text_${diffNumber}`]?.replaceAll("\\n", "\n")}
|
||||
splitView
|
||||
hideLineNumbers
|
||||
showDiffOnly={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</Modal>
|
||||
|
||||
<div className="flex flex-col h-full w-full gap-8 mb-20">
|
||||
<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">
|
||||
<span className="font-semibold">{title}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="font-bold">You should talk about the following things:</span>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 text-center">
|
||||
{prompts.map((x, index) => (
|
||||
<div className="italic flex flex-col gap-2 text-sm" key={index}>
|
||||
<video key={index} controls className="">
|
||||
<source src={x.video_url} />
|
||||
</video>
|
||||
<span>{x.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full h-full flex flex-col gap-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{solutionsURL.map((x, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex flex-col gap-4">
|
||||
<div className="flex gap-8 items-center justify-center py-8">
|
||||
<Waveform audio={x} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||
</div>
|
||||
{userSolutions &&
|
||||
userSolutions.length > 0 &&
|
||||
userSolutions[0].evaluation &&
|
||||
userSolutions[0].evaluation[`transcript_${(index + 1) as 1 | 2 | 3}`] &&
|
||||
userSolutions[0].evaluation[`fixed_text_${(index + 1) as 1 | 2 | 3}`] && (
|
||||
<Button
|
||||
className="w-full max-w-[180px] !py-2 self-center"
|
||||
color="pink"
|
||||
variant="outline"
|
||||
onClick={() => setDiffNumber((index + 1) as 1 | 2 | 3)}>
|
||||
View Correction
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex gap-4 px-1">
|
||||
{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 className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2")} key={key}>
|
||||
{key}: Level {grade}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{userSolutions[0].evaluation &&
|
||||
Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length === 3 ? (
|
||||
<Tab.Group>
|
||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/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",
|
||||
)
|
||||
}>
|
||||
Evaluation
|
||||
</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",
|
||||
)
|
||||
}>
|
||||
Recommended Answer (Prompt 1)
|
||||
</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",
|
||||
)
|
||||
}>
|
||||
Recommended Answer (Prompt 2)
|
||||
</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",
|
||||
)
|
||||
}>
|
||||
Recommended Answer (Prompt 3)
|
||||
</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.Panels>
|
||||
<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">{userSolutions[0].evaluation!.comment}</span>
|
||||
</Tab.Panel>
|
||||
<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">
|
||||
{userSolutions[0].evaluation!.perfect_answer_1!.answer.replaceAll(/\s{2,}/g, "\n\n")}
|
||||
</span>
|
||||
</Tab.Panel>
|
||||
<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">
|
||||
{userSolutions[0].evaluation!.perfect_answer_2!.answer.replaceAll(/\s{2,}/g, "\n\n")}
|
||||
</span>
|
||||
</Tab.Panel>
|
||||
<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">
|
||||
{userSolutions[0].evaluation!.perfect_answer_3!.answer.replaceAll(/\s{2,}/g, "\n\n")}
|
||||
</span>
|
||||
</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.Group>
|
||||
) : (
|
||||
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-ielts-speaking/10 rounded-3xl">
|
||||
{userSolutions[0].evaluation!.comment}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onBack({
|
||||
exercise: id,
|
||||
solutions: userSolutions,
|
||||
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
||||
type,
|
||||
})
|
||||
}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
color="purple"
|
||||
onClick={() =>
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: userSolutions,
|
||||
score: {
|
||||
total: 100,
|
||||
missing: 0,
|
||||
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
|
||||
},
|
||||
type,
|
||||
})
|
||||
}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {MatchSentenceExerciseSentence, MatchSentencesExercise} from "@/interfaces/exam";
|
||||
import {MatchSentencesExercise} from "@/interfaces/exam";
|
||||
import clsx from "clsx";
|
||||
import LineTo from "react-lineto";
|
||||
import {CommonProps} from ".";
|
||||
@@ -9,48 +9,6 @@ import {Fragment} from "react";
|
||||
import Button from "../Low/Button";
|
||||
import Xarrow from "react-xarrows";
|
||||
|
||||
function QuestionSolutionArea({
|
||||
question,
|
||||
userSolution,
|
||||
}: {
|
||||
question: MatchSentenceExerciseSentence;
|
||||
userSolution?: {question: string; option: string};
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="flex items-center gap-3 cursor-pointer col-span-2">
|
||||
<button
|
||||
className={clsx(
|
||||
"text-white w-8 h-8 rounded-full z-10",
|
||||
!userSolution
|
||||
? "bg-mti-gray-davy"
|
||||
: userSolution.option.toString() === question.solution.toString()
|
||||
? "bg-mti-purple"
|
||||
: "bg-mti-rose",
|
||||
"transition duration-300 ease-in-out",
|
||||
)}>
|
||||
{question.id}
|
||||
</button>
|
||||
<span>{question.sentence}</span>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
"w-56 h-10 border rounded-xl items-center justify-center flex gap-3 px-2",
|
||||
!userSolution
|
||||
? "border-mti-gray-davy"
|
||||
: userSolution.option.toString() === question.solution.toString()
|
||||
? "border-mti-purple"
|
||||
: "border-mti-rose",
|
||||
)}>
|
||||
<span className="line-through">
|
||||
{userSolution && userSolution?.option.toString() !== question.solution.toString() && `Paragraph ${userSolution.option}`}
|
||||
</span>
|
||||
<span className="font-semibold">Paragraph {question.solution}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MatchSentencesSolutions({
|
||||
id,
|
||||
type,
|
||||
@@ -63,17 +21,15 @@ export default function MatchSentencesSolutions({
|
||||
}: MatchSentencesExercise & CommonProps) {
|
||||
const calculateScore = () => {
|
||||
const total = sentences.length;
|
||||
const correct = userSolutions.filter(
|
||||
(x) => sentences.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false,
|
||||
).length;
|
||||
const missing = total - userSolutions.filter((x) => sentences.find((y) => y.id.toString() === x.question.toString())).length;
|
||||
const correct = userSolutions.filter((x) => sentences.find((y) => y.id === x.question)?.solution === x.option || false).length;
|
||||
const missing = total - userSolutions.filter((x) => sentences.find((y) => y.id === x.question)).length;
|
||||
|
||||
return {total, correct, missing};
|
||||
};
|
||||
|
||||
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 mb-20">
|
||||
<span className="text-sm w-full leading-6">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
@@ -82,24 +38,62 @@ export default function MatchSentencesSolutions({
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
<div className="flex flex-col gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
|
||||
<div className="flex gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
{sentences.map((question) => (
|
||||
<QuestionSolutionArea
|
||||
question={question}
|
||||
userSolution={userSolutions.find((x) => x.question.toString() === question.id.toString())}
|
||||
key={`question_${question.id}`}
|
||||
/>
|
||||
{sentences.map(({sentence, id, solution}) => (
|
||||
<div key={`question_${id}`} className="flex items-center justify-end gap-2 cursor-pointer">
|
||||
<span>{sentence} </span>
|
||||
<button
|
||||
id={id}
|
||||
className={clsx(
|
||||
"w-8 h-8 rounded-full z-10 text-white",
|
||||
"transition duration-300 ease-in-out",
|
||||
!userSolutions.find((x) => x.question === id) && "!bg-mti-red",
|
||||
userSolutions.find((x) => x.question === id)?.option === solution && "bg-mti-purple",
|
||||
userSolutions.find((x) => x.question === id)?.option !== solution && "bg-mti-rose",
|
||||
)}>
|
||||
{id}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{options.map(({sentence, id}) => (
|
||||
<div key={`answer_${id}`} className={clsx("flex items-center justify-start gap-2 cursor-pointer")}>
|
||||
<button
|
||||
id={id}
|
||||
className={clsx(
|
||||
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
||||
"transition duration-300 ease-in-out",
|
||||
)}>
|
||||
{id}
|
||||
</button>
|
||||
<span>{sentence}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{sentences.map((sentence, index) => (
|
||||
<Xarrow
|
||||
key={index}
|
||||
start={sentence.id}
|
||||
end={sentence.solution}
|
||||
lineColor={
|
||||
!userSolutions.find((x) => x.question === sentence.id)
|
||||
? "#CC5454"
|
||||
: userSolutions.find((x) => x.question === sentence.id)?.option === sentence.solution
|
||||
? "#7872BF"
|
||||
: "#CC5454"
|
||||
}
|
||||
showHead={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-4 h-4 rounded-full bg-mti-purple" /> Correct
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-4 h-4 rounded-full bg-mti-gray-davy" /> Unanswered
|
||||
<div className="w-4 h-4 rounded-full bg-mti-red" /> Unanswered
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-4 h-4 rounded-full bg-mti-rose" /> Wrong
|
||||
|
||||
@@ -1,30 +1,20 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import clsx from "clsx";
|
||||
import {useEffect, useState} from "react";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import {useState} from "react";
|
||||
import {CommonProps} from ".";
|
||||
import Button from "../Low/Button";
|
||||
|
||||
function Question({
|
||||
id,
|
||||
variant,
|
||||
prompt,
|
||||
solution,
|
||||
options,
|
||||
userSolution,
|
||||
}: 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) => {
|
||||
if (option === solution && !userSolution) {
|
||||
return "!border-mti-gray-davy !text-mti-gray-davy";
|
||||
return "!border-mti-red-light !text-mti-red-light";
|
||||
}
|
||||
|
||||
if (option === solution) {
|
||||
@@ -36,15 +26,7 @@ function Question({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{isNaN(Number(id)) ? (
|
||||
<span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
||||
) : (
|
||||
<span className="">
|
||||
<>
|
||||
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
||||
</>
|
||||
</span>
|
||||
)}
|
||||
<span>{prompt}</span>
|
||||
<div className="grid grid-cols-4 gap-4 place-items-center">
|
||||
{variant === "image" &&
|
||||
options.map((option) => (
|
||||
@@ -73,14 +55,12 @@ function Question({
|
||||
}
|
||||
|
||||
export default function MultipleChoice({id, type, prompt, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
|
||||
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
|
||||
const [questionIndex, setQuestionIndex] = useState(0);
|
||||
|
||||
const calculateScore = () => {
|
||||
const total = questions.length;
|
||||
const correct = userSolutions.filter(
|
||||
(x) => questions.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false,
|
||||
).length;
|
||||
const missing = total - userSolutions.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length;
|
||||
const correct = userSolutions.filter((x) => questions.find((y) => y.id === x.question)?.solution === x.option || false).length;
|
||||
const missing = total - userSolutions.filter((x) => questions.find((y) => y.id === x.question)).length;
|
||||
|
||||
return {total, correct, missing};
|
||||
};
|
||||
@@ -89,7 +69,7 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
|
||||
if (questionIndex === questions.length - 1) {
|
||||
onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
|
||||
} else {
|
||||
setQuestionIndex(questionIndex + 1);
|
||||
setQuestionIndex((prev) => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -97,7 +77,7 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
|
||||
if (questionIndex === 0) {
|
||||
onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type});
|
||||
} else {
|
||||
setQuestionIndex(questionIndex - 1);
|
||||
setQuestionIndex((prev) => prev - 1);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -106,7 +86,7 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
|
||||
<div className="flex flex-col gap-4 w-full h-full mb-20">
|
||||
<div className="flex flex-col gap-2 mt-4 h-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||
<span className="text-xl font-semibold">{prompt}</span>
|
||||
{userSolutions && questionIndex < questions.length && (
|
||||
{questionIndex < questions.length && (
|
||||
<Question
|
||||
{...questions[questionIndex]}
|
||||
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option}
|
||||
@@ -119,7 +99,7 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
|
||||
Correct
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-4 h-4 rounded-full bg-mti-gray-davy" />
|
||||
<div className="w-4 h-4 rounded-full bg-mti-red" />
|
||||
Unanswered
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
|
||||
@@ -5,212 +5,66 @@ import {Fragment, useEffect, useState} from "react";
|
||||
import Button from "../Low/Button";
|
||||
import dynamic from "next/dynamic";
|
||||
import axios from "axios";
|
||||
import {speakingReverseMarking} from "@/utils/score";
|
||||
import {Tab} from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import Modal from "../Modal";
|
||||
import {BsQuestionCircleFill} from "react-icons/bs";
|
||||
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
|
||||
|
||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||
|
||||
export default function Speaking({id, type, title, video_url, text, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
|
||||
export default function Speaking({id, type, title, text, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
|
||||
const [solutionURL, setSolutionURL] = useState<string>();
|
||||
const [showDiff, setShowDiff] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
|
||||
const solution = userSolutions[0].solution;
|
||||
axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => {
|
||||
const blob = new Blob([data], {type: "audio/wav"});
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
if (solution.startsWith("https://")) return setSolutionURL(solution);
|
||||
axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => {
|
||||
const blob = new Blob([data], {type: "audio/wav"});
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
setSolutionURL(url);
|
||||
});
|
||||
}
|
||||
setSolutionURL(url);
|
||||
});
|
||||
}, [userSolutions]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal title="Correction" isOpen={showDiff} onClose={() => setShowDiff(false)}>
|
||||
<>
|
||||
{userSolutions &&
|
||||
userSolutions.length > 0 &&
|
||||
userSolutions[0].evaluation?.transcript_1 &&
|
||||
userSolutions[0].evaluation?.fixed_text_1 && (
|
||||
<div className="w-full h-full rounded-xl overflow-hidden flex flex-col mt-4">
|
||||
<div className="w-full grid grid-cols-2 bg-neutral-100">
|
||||
<span className="p-3 font-medium text-lg border-r border-r-neutral-200">Transcript</span>
|
||||
<span className="p-3 font-medium text-lg border-l border-l-neutral-200">Recommended Improvements</span>
|
||||
</div>
|
||||
<ReactDiffViewer
|
||||
styles={{
|
||||
contentText: {
|
||||
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
|
||||
padding: "32px 28px",
|
||||
},
|
||||
marker: {display: "none"},
|
||||
diffRemoved: {padding: "32px 28px"},
|
||||
diffAdded: {padding: "32px 28px"},
|
||||
|
||||
wordRemoved: {padding: "0px", display: "initial"},
|
||||
wordAdded: {padding: "0px", display: "initial"},
|
||||
wordDiff: {padding: "0px", display: "initial"},
|
||||
}}
|
||||
oldValue={userSolutions[0].evaluation.transcript_1.replaceAll("\\n", "\n")}
|
||||
newValue={userSolutions[0].evaluation.fixed_text_1.replaceAll("\\n", "\n")}
|
||||
splitView
|
||||
hideLineNumbers
|
||||
showDiffOnly={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</Modal>
|
||||
|
||||
<div className="flex flex-col h-full w-full gap-8 mb-20">
|
||||
<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-14 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="font-semibold">{title}</span>
|
||||
{!video_url && (
|
||||
<span className="font-regular">
|
||||
{text.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
<span>{line}</span>
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-regular">
|
||||
{text.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
<span>{line}</span>
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
{video_url && (
|
||||
<div className="flex flex-col gap-4 w-full items-center">
|
||||
<video key={id} autoPlay controls className="max-w-3xl rounded-xl">
|
||||
<source src={video_url} />
|
||||
</video>
|
||||
</div>
|
||||
)}
|
||||
{prompts && prompts.length > 0 && (
|
||||
<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 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 className="w-full h-full flex flex-col gap-8 relative">
|
||||
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center relative">
|
||||
<div className="w-full h-full flex flex-col gap-8">
|
||||
<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="flex gap-8 items-center justify-center py-8">
|
||||
{solutionURL && <Waveform audio={solutionURL} waveColor="#FCDDEC" progressColor="#EF5DA8" />}
|
||||
|
||||
{userSolutions &&
|
||||
userSolutions.length > 0 &&
|
||||
userSolutions[0].evaluation?.transcript_1 &&
|
||||
userSolutions[0].evaluation?.fixed_text_1 && (
|
||||
<Button className="w-full max-w-[180px] !py-2" color="pink" variant="outline" onClick={() => setShowDiff(true)}>
|
||||
View Correction
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||
{userSolutions && userSolutions.length > 0 && (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex gap-4 px-1">
|
||||
{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 className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2")} key={key}>
|
||||
{key}: Level {grade}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{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}>
|
||||
{key}: Level {userSolutions[0].evaluation!.task_response[key]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-mti-gray-smoke rounded-3xl">
|
||||
{userSolutions[0].evaluation!.comment}
|
||||
</div>
|
||||
{userSolutions[0].evaluation &&
|
||||
(userSolutions[0].evaluation.perfect_answer || userSolutions[0].evaluation.perfect_answer_1) ? (
|
||||
<Tab.Group>
|
||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/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",
|
||||
)
|
||||
}>
|
||||
Evaluation
|
||||
</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",
|
||||
)
|
||||
}>
|
||||
Recommended Answer
|
||||
</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.Panels>
|
||||
<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">{userSolutions[0].evaluation!.comment}</span>
|
||||
</Tab.Panel>
|
||||
<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">
|
||||
{userSolutions[0].evaluation!.perfect_answer &&
|
||||
userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n")}
|
||||
{userSolutions[0].evaluation!.perfect_answer_1 &&
|
||||
userSolutions[0].evaluation!.perfect_answer_1.replaceAll(/\s{2,}/g, "\n\n")}
|
||||
</span>
|
||||
</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.Group>
|
||||
) : (
|
||||
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-ielts-speaking/10 rounded-3xl">
|
||||
{userSolutions[0].evaluation!.comment}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -224,7 +78,7 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
||||
onBack({
|
||||
exercise: id,
|
||||
solutions: userSolutions,
|
||||
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
type,
|
||||
})
|
||||
}
|
||||
@@ -237,11 +91,7 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: userSolutions,
|
||||
score: {
|
||||
total: 100,
|
||||
missing: 0,
|
||||
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
|
||||
},
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
type,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,10 +10,8 @@ type Solution = "true" | "false" | "not_given";
|
||||
export default function TrueFalseSolution({prompt, type, id, questions, userSolutions, onNext, onBack}: TrueFalseExercise & CommonProps) {
|
||||
const calculateScore = () => {
|
||||
const total = questions.length || 0;
|
||||
const correct = userSolutions.filter(
|
||||
(x) => questions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false,
|
||||
).length;
|
||||
const missing = total - userSolutions.filter((x) => questions.find((y) => x.id.toString() === y.id.toString())).length;
|
||||
const correct = userSolutions.filter((x) => questions.find((y) => x.id === y.id)?.solution === x.solution.toLowerCase() || false).length;
|
||||
const missing = total - userSolutions.filter((x) => questions.find((y) => x.id === y.id)).length;
|
||||
|
||||
return {total, correct, missing};
|
||||
};
|
||||
@@ -33,12 +31,12 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
||||
return "rose";
|
||||
}
|
||||
|
||||
return "gray";
|
||||
return "red";
|
||||
};
|
||||
|
||||
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 mb-20">
|
||||
<span className="text-sm w-full leading-6">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
@@ -64,41 +62,37 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
||||
</div>
|
||||
<span className="text-sm w-full leading-6">You can click a selected option again to deselect it.</span>
|
||||
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-8">
|
||||
{userSolutions &&
|
||||
questions.map((question, index) => {
|
||||
const userSolution = userSolutions.find((x) => x.id === question.id.toString());
|
||||
const solution = question.solution.toString().toLowerCase() as Solution;
|
||||
{questions.map((question, index) => {
|
||||
const userSolution = userSolutions.find((x) => x.id === question.id);
|
||||
|
||||
return (
|
||||
<div key={question.id.toString()} className="flex flex-col gap-4">
|
||||
<span>
|
||||
{index + 1}. {question.prompt}
|
||||
</span>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
variant={solution === "true" || userSolution?.solution.toLowerCase() === "true" ? "solid" : "outline"}
|
||||
className="!py-2"
|
||||
color={getButtonColor("true", solution, userSolution?.solution.toLowerCase() as Solution)}>
|
||||
True
|
||||
</Button>
|
||||
<Button
|
||||
variant={solution === "false" || userSolution?.solution.toLowerCase() === "false" ? "solid" : "outline"}
|
||||
className="!py-2"
|
||||
color={getButtonColor("false", solution, userSolution?.solution.toLowerCase() as Solution)}>
|
||||
False
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
solution === "not_given" || userSolution?.solution.toLowerCase() === "not_given" ? "solid" : "outline"
|
||||
}
|
||||
className="!py-2"
|
||||
color={getButtonColor("not_given", solution, userSolution?.solution.toLowerCase() as Solution)}>
|
||||
Not Given
|
||||
</Button>
|
||||
</div>
|
||||
return (
|
||||
<div key={question.id} className="flex flex-col gap-4">
|
||||
<span>
|
||||
{index + 1}. {question.prompt}
|
||||
</span>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
variant={question.solution === "true" || userSolution?.solution === "true" ? "solid" : "outline"}
|
||||
className="!py-2"
|
||||
color={getButtonColor("true", question.solution, userSolution?.solution)}>
|
||||
True
|
||||
</Button>
|
||||
<Button
|
||||
variant={question.solution === "false" || userSolution?.solution === "false" ? "solid" : "outline"}
|
||||
className="!py-2"
|
||||
color={getButtonColor("false", question.solution, userSolution?.solution)}>
|
||||
False
|
||||
</Button>
|
||||
<Button
|
||||
variant={question.solution === "not_given" || userSolution?.solution === "not_given" ? "solid" : "outline"}
|
||||
className="!py-2"
|
||||
color={getButtonColor("not_given", question.solution, userSolution?.solution)}>
|
||||
Not Given
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="flex gap-2 items-center">
|
||||
@@ -106,7 +100,7 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
||||
Correct
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-4 h-4 rounded-full bg-mti-gray-davy" />
|
||||
<div className="w-4 h-4 rounded-full bg-mti-red" />
|
||||
Unanswered
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
|
||||
@@ -29,6 +29,7 @@ function Blank({
|
||||
useEffect(() => {
|
||||
const words = userInput.split(" ").filter((x) => x !== "");
|
||||
if (words.length >= maxWords) {
|
||||
toast.warning(`You have reached your word limit of ${maxWords} words!`, {toastId: "word-limit"});
|
||||
setUserInput(words.join(" ").trim());
|
||||
if (setUserSolution) setUserSolution(words.join(" ").trim());
|
||||
}
|
||||
@@ -38,28 +39,30 @@ function Blank({
|
||||
|
||||
const getSolutionStyling = () => {
|
||||
if (!userSolution) {
|
||||
return "bg-mti-gray-davy text-white";
|
||||
return "bg-mti-red-ultralight text-mti-red-light";
|
||||
}
|
||||
|
||||
return "bg-mti-purple-ultralight text-mti-purple-light";
|
||||
};
|
||||
|
||||
return (
|
||||
<span className="inline-flex gap-2 ml-2">
|
||||
<span className="inline-flex gap-2">
|
||||
{userSolution && !isUserSolutionCorrect() && (
|
||||
<div
|
||||
className="py-2 px-3 rounded-2xl w-fit focus:outline-none my-2 bg-mti-rose-ultralight text-mti-rose-light"
|
||||
<input
|
||||
className="py-2 px-3 rounded-2xl w-48 focus:outline-none my-2 bg-mti-rose-ultralight text-mti-rose-light"
|
||||
placeholder={id}
|
||||
contentEditable={disabled}>
|
||||
{userSolution}
|
||||
</div>
|
||||
onChange={(e) => setUserInput(e.target.value)}
|
||||
value={userSolution}
|
||||
contentEditable={disabled}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={clsx("py-2 px-3 rounded-2xl w-fit focus:outline-none my-2", getSolutionStyling())}
|
||||
<input
|
||||
className={clsx("py-2 px-3 rounded-2xl w-48 focus:outline-none my-2", getSolutionStyling())}
|
||||
placeholder={id}
|
||||
contentEditable={disabled}>
|
||||
{!solutions ? userInput : solutions.join(" / ")}
|
||||
</div>
|
||||
onChange={(e) => setUserInput(e.target.value)}
|
||||
value={!solutions ? userInput : solutions.join(" / ")}
|
||||
contentEditable={disabled}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -80,11 +83,11 @@ export default function WriteBlanksSolutions({
|
||||
const correct = userSolutions.filter(
|
||||
(x) =>
|
||||
solutions
|
||||
.find((y) => x.id.toString() === y.id.toString())
|
||||
?.solution.map((y) => y.toLowerCase().trim())
|
||||
.includes(x.solution.toLowerCase().trim()) || false,
|
||||
.find((y) => x.id === y.id)
|
||||
?.solution.map((y) => y.toLowerCase())
|
||||
.includes(x.solution.toLowerCase()) || false,
|
||||
).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 === y.id)).length;
|
||||
|
||||
return {total, correct, missing};
|
||||
};
|
||||
@@ -93,13 +96,11 @@ export default function WriteBlanksSolutions({
|
||||
return (
|
||||
<span className="text-base leading-5">
|
||||
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
||||
const id = match.replaceAll(/[\{\}]/g, "").toString();
|
||||
const userSolution = userSolutions.find((x) => x.id.toString() === id.toString());
|
||||
const solution = solutions.find((x) => x.id.toString() === id.toString())!;
|
||||
const id = match.replaceAll(/[\{\}]/g, "");
|
||||
const userSolution = userSolutions.find((x) => x.id === id);
|
||||
const solution = solutions.find((x) => x.id === id)!;
|
||||
|
||||
return (
|
||||
<Blank userSolution={userSolution?.solution} maxWords={maxWords} id={id.toString()} solutions={solution.solution} disabled />
|
||||
);
|
||||
return <Blank userSolution={userSolution?.solution} maxWords={maxWords} id={id} solutions={solution.solution} disabled />;
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
@@ -107,7 +108,7 @@ export default function WriteBlanksSolutions({
|
||||
|
||||
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 mb-20">
|
||||
<span className="text-sm w-full leading-6">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
@@ -117,13 +118,12 @@ export default function WriteBlanksSolutions({
|
||||
))}
|
||||
</span>
|
||||
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
||||
{userSolutions &&
|
||||
text.split("\\n").map((line, index) => (
|
||||
<p key={index}>
|
||||
{renderLines(line)}
|
||||
<br />
|
||||
</p>
|
||||
))}
|
||||
{text.split("\\n").map((line, index) => (
|
||||
<p key={index}>
|
||||
{renderLines(line)}
|
||||
<br />
|
||||
</p>
|
||||
))}
|
||||
</span>
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="flex gap-2 items-center">
|
||||
@@ -131,7 +131,7 @@ export default function WriteBlanksSolutions({
|
||||
Correct
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-4 h-4 rounded-full bg-mti-gray-davy" />
|
||||
<div className="w-4 h-4 rounded-full bg-mti-red" />
|
||||
Unanswered
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
|
||||