Compare commits
346 Commits
feature/di
...
improvemen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0ecc5be05 | ||
|
|
77af0b3495 | ||
|
|
e2e38284a7 | ||
|
|
ccd2560451 | ||
|
|
390658f2b0 | ||
|
|
450a4e9fe3 | ||
|
|
dfbbf0456d | ||
|
|
d46f92edb2 | ||
|
|
26c4368f31 | ||
|
|
ec56a5426b | ||
|
|
fe32584ff9 | ||
|
|
db7762c6e2 | ||
|
|
e70e26f84c | ||
|
|
7dc9d568d1 | ||
|
|
0049ab272b | ||
|
|
f48885bba6 | ||
|
|
5eaa0ac269 | ||
|
|
f7af21878e | ||
|
|
9d4071d4cd | ||
|
|
6f5dd86cd1 | ||
|
|
8b9537b272 | ||
|
|
a526e76c70 | ||
|
|
62b2f477f4 | ||
|
|
f36384fdb4 | ||
|
|
9c8d7988c5 | ||
|
|
18f163768c | ||
|
|
72083439af | ||
|
|
523149327b | ||
|
|
58c18133ec | ||
|
|
03520b650b | ||
|
|
556884058b | ||
|
|
73b0d5d41d | ||
|
|
7c589327f7 | ||
|
|
5c8867555d | ||
|
|
36be5267a2 | ||
|
|
4ebfd49cb9 | ||
|
|
96fe83de14 | ||
|
|
1746db3752 | ||
|
|
58b4883236 | ||
|
|
a3864eb7d3 | ||
|
|
1f0e5f4a08 | ||
|
|
c90234cefc | ||
|
|
f354a4f4fe | ||
|
|
7e0c071eee | ||
|
|
9bed726062 | ||
|
|
3878d4761e | ||
|
|
81f5af5629 | ||
|
|
5f76e430af | ||
|
|
facac33a89 | ||
|
|
f36c63f1b2 | ||
|
|
b1f07b877c | ||
|
|
70611305a7 | ||
|
|
fdedc2c5d3 | ||
|
|
75875b49e6 | ||
|
|
37e52886b5 | ||
|
|
a5dfe69220 | ||
|
|
1c36c7f1e1 | ||
|
|
9de39485de | ||
|
|
0fe2e0d393 | ||
|
|
dbb5e131fc | ||
|
|
ebda1e1717 | ||
|
|
8cbec131fe | ||
|
|
472d4a3331 | ||
|
|
c2f83d996a | ||
|
|
43bd6b24c5 | ||
|
|
ca89261e10 | ||
|
|
a9bbbe8b52 | ||
|
|
fa544bf4e8 | ||
|
|
7e91a989b3 | ||
|
|
c312260721 | ||
|
|
23f2bace5d | ||
|
|
7e2f1fcf9d | ||
|
|
6e420a8a82 | ||
|
|
cd81547022 | ||
|
|
a2baedb80c | ||
|
|
8072cefbe6 | ||
|
|
6bf666d01c | ||
|
|
7672e29063 | ||
|
|
51e7c535df | ||
|
|
d0f89cfe01 | ||
|
|
8de60aeb32 | ||
|
|
0e28473c31 | ||
|
|
52d4b831ae | ||
|
|
cdc8cfe46e | ||
|
|
4c7e8f56d8 | ||
|
|
4753b85ab5 | ||
|
|
13c8459d4b | ||
|
|
19b3bbe139 | ||
|
|
44a89c6645 | ||
|
|
4a51bd7dfa | ||
|
|
dc759a368e | ||
|
|
c28f7bb024 | ||
|
|
d412c1616f | ||
|
|
c2a807efc7 | ||
|
|
6056735c72 | ||
|
|
261ba74105 | ||
|
|
4328a1d72d | ||
|
|
82643b51d3 | ||
|
|
a38e5e2f0a | ||
|
|
19624e97bd | ||
|
|
536c1dfab3 | ||
|
|
c2acb39859 | ||
|
|
dd2ddc0e5b | ||
|
|
40f095191a | ||
|
|
37baa11987 | ||
|
|
3ecc2b7982 | ||
|
|
ba3588e97d | ||
|
|
bd6892dcf1 | ||
|
|
dc13a4a7b7 | ||
|
|
37c889a39a | ||
|
|
c5ece3a5f8 | ||
|
|
a20b980adb | ||
|
|
6e31a05f21 | ||
|
|
0aefbb85ec | ||
|
|
15f8d25bc9 | ||
|
|
c0269fca45 | ||
|
|
bdb0ffde95 | ||
|
|
8515eaf4ee | ||
|
|
3bb27a692f | ||
|
|
3528eb227e | ||
|
|
729204a095 | ||
|
|
cf5a9c9780 | ||
|
|
8b872020c6 | ||
|
|
e16a9873be | ||
|
|
faced0b20c | ||
|
|
7e576738ce | ||
|
|
e10aebf4c0 | ||
|
|
9f9e36f0cd | ||
|
|
913ed54cf9 | ||
|
|
960c5b8c6f | ||
|
|
57f2135848 | ||
|
|
171f328278 | ||
|
|
ffe534edd9 | ||
|
|
fb15668288 | ||
|
|
b00d155aa1 | ||
|
|
9b852bd6be | ||
|
|
550cdba5a7 | ||
|
|
aaa7d6deb3 | ||
|
|
e2567e128e | ||
|
|
5c11087cec | ||
|
|
635c92791c | ||
|
|
e8b44ee10e | ||
|
|
932a2e4081 | ||
|
|
11777b1bea | ||
|
|
69425d0b93 | ||
|
|
1895ffb5c3 | ||
|
|
f51dc450b9 | ||
|
|
18e12db7c5 | ||
|
|
ebb6bb2a1a | ||
|
|
348a020e7f | ||
|
|
ca96b37303 | ||
|
|
1a255b5a4d | ||
|
|
320aedefb1 | ||
|
|
da135d3e6f | ||
|
|
4c95d85cf9 | ||
|
|
1d27da71ec | ||
|
|
a84edcd237 | ||
|
|
25ce3bdf8f | ||
|
|
634a396434 | ||
|
|
1aa4f0ddfd | ||
|
|
0c9a49a9c3 | ||
|
|
cb3790dc1d | ||
|
|
d4c4546c88 | ||
|
|
ebe4c41f76 | ||
|
|
5e1b9ce2c7 | ||
|
|
2d095316a7 | ||
|
|
73d3922f18 | ||
|
|
925250d2c5 | ||
|
|
29914d3e89 | ||
|
|
1ccb9555b6 | ||
|
|
07e73b0d88 | ||
|
|
9239068cde | ||
|
|
0f0b7748d7 | ||
|
|
551faadd28 | ||
|
|
b58957e38e | ||
|
|
782976c14f | ||
|
|
2a68d37de8 | ||
|
|
a47ee28ca5 | ||
|
|
169ae2c959 | ||
|
|
a568950aa9 | ||
|
|
f9cd477114 | ||
|
|
75fb6ab197 | ||
|
|
7af607d476 | ||
|
|
41040f92c3 | ||
|
|
9dbf43cf22 | ||
|
|
4873832437 | ||
|
|
a362dc5a11 | ||
|
|
f3fea7fc66 | ||
|
|
64e7fcbcc7 | ||
|
|
733138f2be | ||
|
|
b0a11a5f8d | ||
|
|
8fb1d8e886 | ||
|
|
3491efb494 | ||
|
|
5564b4c181 | ||
|
|
7d4d228f0d | ||
|
|
8b7e7cf0ad | ||
|
|
cb5434d166 | ||
|
|
8b51e50f15 | ||
|
|
6dda49a917 | ||
|
|
7a957e4d78 | ||
|
|
a9ceecdc84 | ||
|
|
d46d0ab42f | ||
|
|
1bac6eb110 | ||
|
|
1e4316a57e | ||
|
|
9a45f53062 | ||
|
|
0ca2649040 | ||
|
|
395afbb4ee | ||
|
|
7cdff84d5e | ||
|
|
f5de8f5e10 | ||
|
|
4d364bd597 | ||
|
|
3e010572f6 | ||
|
|
68fb5e5bc7 | ||
|
|
efb341355d | ||
|
|
161d5236b4 | ||
|
|
91495d6a34 | ||
|
|
05ca96e476 | ||
|
|
dc8682e1c3 | ||
|
|
3a51185942 | ||
|
|
39a2813bde | ||
|
|
27c6eff590 | ||
|
|
8dcfb8a670 | ||
|
|
8cbf56b81a | ||
|
|
27ff2bd158 | ||
|
|
dadaa831ba | ||
|
|
27956d311c | ||
|
|
cca94db9bf | ||
|
|
42a0821677 | ||
|
|
9de62cc55f | ||
|
|
af70d36b7c | ||
|
|
b1461d1c04 | ||
|
|
f91cd0ca63 | ||
|
|
5211e92c65 | ||
|
|
af994cfadb | ||
|
|
caddc57ef8 | ||
|
|
578f42d9b1 | ||
|
|
1a0ec780b9 | ||
|
|
10b2f09c7f | ||
|
|
5263cc260d | ||
|
|
0013d86ef8 | ||
|
|
41d6303403 | ||
|
|
f2323b35b8 | ||
|
|
b3b804fc11 | ||
|
|
7dd96bf259 | ||
|
|
c2fb398707 | ||
|
|
f4b0d6822d | ||
|
|
511c30d635 | ||
|
|
084d19600a | ||
|
|
b75a0be52c | ||
|
|
c23c07ae38 | ||
|
|
17ff85b62b | ||
|
|
84a42d0e14 | ||
|
|
437c405c74 | ||
|
|
b4d856d32f | ||
|
|
3c711e0279 | ||
|
|
c879d5d8de | ||
|
|
82d2c548ef | ||
|
|
cdb4f329cf | ||
|
|
c91264e455 | ||
|
|
14a719b8b5 | ||
|
|
78c5b7027e | ||
|
|
cd71cf4833 | ||
|
|
93a5bcf40f | ||
|
|
dd0acbea61 | ||
|
|
ef736bc63e | ||
|
|
d9ca0e84a6 | ||
|
|
db54d58bab | ||
|
|
5099721b9b | ||
|
|
2c2fbffd8c | ||
|
|
3fee4292f1 | ||
|
|
7e9e28f134 | ||
|
|
d879f4afab | ||
|
|
d38ca76182 | ||
|
|
77692d270e | ||
|
|
f5c3abb310 | ||
|
|
02260d496c | ||
|
|
581adbb56e | ||
|
|
6ade34d243 | ||
|
|
16ea0b497e | ||
|
|
ea41875e36 | ||
|
|
eae0a4ae4e | ||
|
|
fea788bdc4 | ||
|
|
86c69e5993 | ||
|
|
f01794fed8 | ||
|
|
cc4b38fbbd | ||
|
|
121ac8ba4d | ||
|
|
2c10a203a5 | ||
|
|
6a2fab4f88 | ||
|
|
9637cb6477 | ||
|
|
ce90de1b74 | ||
|
|
49e24865a3 | ||
|
|
dceff807e9 | ||
|
|
3c4dba69db | ||
|
|
3fac92b54d | ||
|
|
139f527fdd | ||
|
|
93cef3d58f | ||
|
|
60b23ce1b5 | ||
|
|
d3a37eed3e | ||
|
|
447cecbf3f | ||
|
|
b2cc706a5e | ||
|
|
9cbb5b93c8 | ||
|
|
747c07f84e | ||
|
|
79ed521703 | ||
|
|
fe4a97ec85 | ||
|
|
b194a9183e | ||
|
|
f369234e8a | ||
|
|
808ec6315b | ||
|
|
d2cf50be68 | ||
|
|
294f00952e | ||
|
|
7beb1c84e7 | ||
|
|
3a7c29de56 | ||
|
|
dd357d991c | ||
|
|
47b1784615 | ||
|
|
d4156c83f4 | ||
|
|
572bc25eed | ||
|
|
e80b163b4a | ||
|
|
87e0610c79 | ||
|
|
52218ff8b8 | ||
|
|
84b0b8ac42 | ||
|
|
989a7449bf | ||
|
|
bc7eaea911 | ||
|
|
f5ec910010 | ||
|
|
2d46bad40f | ||
|
|
65ebdd7dde | ||
|
|
60217e9a66 | ||
|
|
ec3157870e | ||
|
|
9cf4bf7184 | ||
|
|
f5fc85e1a7 | ||
|
|
31f2eb510e | ||
|
|
31e2e56833 | ||
|
|
efaa32cd68 | ||
|
|
b41ee8e2ad | ||
|
|
e055b84688 | ||
|
|
1e286bb65b | ||
|
|
abe986313f | ||
|
|
088b77a66b | ||
|
|
72fc98fccd | ||
|
|
9ce45dfc30 | ||
|
|
e864e16064 | ||
|
|
6fe8a678ea | ||
|
|
b2232df0c7 | ||
|
|
9a7853bd05 | ||
|
|
1e8e95da34 | ||
|
|
4d37bf536a | ||
|
|
d0704e573b | ||
|
|
31dc29b812 | ||
|
|
9ed3672cb6 |
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
.git
|
||||
3
.gitignore
vendored
@@ -36,3 +36,6 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
.env
|
||||
.yarn/*
|
||||
.history*
|
||||
__ENV.js
|
||||
4
.husky/pre-commit
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
yarn build
|
||||
5
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"dbaeumer.vscode-eslint"
|
||||
]
|
||||
}
|
||||
28
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
57
Dockerfile
Normal file
@@ -0,0 +1,57 @@
|
||||
#syntax=docker/dockerfile:1.4
|
||||
FROM node:18-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY package.json yarn.lock* ./
|
||||
RUN yarn --frozen-lockfile
|
||||
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Next.js collects completely anonymous telemetry data about general usage.
|
||||
# Learn more here: https://nextjs.org/telemetry
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
# ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN yarn build
|
||||
|
||||
# If using npm comment out above and use below instead
|
||||
# RUN npm run build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN \
|
||||
addgroup --system --gid 1001 nodejs; \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder --chown=1001:1001 /app/.next/standalone ./
|
||||
COPY --from=builder --chown=1001:1001 /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT 3000
|
||||
ENV HOSTNAME localhost
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,6 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
}
|
||||
reactStrictMode: true,
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
module.exports = nextConfig
|
||||
module.exports = nextConfig;
|
||||
|
||||
810
package-lock.json
generated
44
package.json
@@ -6,50 +6,86 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"dependencies": {
|
||||
"@beam-australia/react-env": "^3.1.1",
|
||||
"@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",
|
||||
"@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",
|
||||
"daisyui": "^2.50.0",
|
||||
"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",
|
||||
"next": "13.1.6",
|
||||
"nodemailer": "^6.9.5",
|
||||
"nodemailer-express-handlebars": "^6.1.0",
|
||||
"primeicons": "^6.0.1",
|
||||
"primereact": "^9.2.3",
|
||||
"random-words": "^2.0.0",
|
||||
"react": "18.2.0",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-currency-input-field": "^3.6.12",
|
||||
"react-datepicker": "^4.18.0",
|
||||
"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.6",
|
||||
"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-xarrows": "^2.0.2",
|
||||
"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/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/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"
|
||||
"tailwindcss": "^3.2.4",
|
||||
"types/": "paypal/react-paypal-js"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/audio/check.mp3
Normal file
BIN
public/audio/sent.mp3
Normal file
BIN
public/defaultAvatar.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 5.5 KiB |
BIN
public/logo.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
public/logo_title.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
public/people-talking-tablet.png
Normal file
|
After Width: | Height: | Size: 832 KiB |
55
src/components/AbandonPopup.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import {Dialog, Transition} from "@headlessui/react";
|
||||
import {Fragment} from "react";
|
||||
import Button from "./Low/Button";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
abandonPopupTitle: string;
|
||||
abandonPopupDescription: string;
|
||||
abandonConfirmButtonText: string;
|
||||
onAbandon: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
56
src/components/BlankQuestionsModal.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import {Dialog, Transition} from "@headlessui/react";
|
||||
import {Fragment} from "react";
|
||||
import Button from "./Low/Button";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: (next?: boolean) => void;
|
||||
}
|
||||
|
||||
export default function BlankQuestionsModal({isOpen, onClose}: Props) {
|
||||
return (
|
||||
<Transition show={isOpen} as={Fragment}>
|
||||
<Dialog onClose={() => onClose(false)} 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">Questions Unanswered</Dialog.Title>
|
||||
<span>
|
||||
Please note that you are finishing the current module and once you proceed to the next module, you will no longer be
|
||||
able to change the answers in the current one, including your unanswered questions. <br />
|
||||
<br />
|
||||
Are you sure you want to continue without completing those questions?
|
||||
</span>
|
||||
<div className="w-full flex justify-between mt-8">
|
||||
<Button color="purple" onClick={() => onClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
|
||||
Go Back
|
||||
</Button>
|
||||
<Button color="purple" onClick={() => onClose(true)} className="max-w-[200px] self-end w-full">
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
63
src/components/BottomBar.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
172
src/components/DemographicInformationInput.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import {EmploymentStatus, EMPLOYMENT_STATUS, Gender, User} from "@/interfaces/user";
|
||||
import {FormEvent, 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";
|
||||
|
||||
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 [gender, setGender] = useState<Gender>();
|
||||
const [employment, setEmployment] = useState<EmploymentStatus>();
|
||||
const [position, setPosition] = useState<string>();
|
||||
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,
|
||||
},
|
||||
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="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 className="relative flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Gender *</label>
|
||||
<RadioGroup value={gender} onChange={setGender} className="flex flex-row 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>
|
||||
{user.type === "corporate" && (
|
||||
<Input name="position" onChange={setPosition} type="text" label="Position" placeholder="CEO, Head of Marketing..." required />
|
||||
)}
|
||||
{user.type !== "corporate" && (
|
||||
<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={employment} onChange={setEmployment} 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-44 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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -3,116 +3,228 @@ import {BAND_SCORES} from "@/constants/ielts";
|
||||
import {Module} from "@/interfaces";
|
||||
import {User} from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {getExam, getExamById} from "@/utils/exams";
|
||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||
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 {useState} from "react";
|
||||
import {useEffect, useState} from "react";
|
||||
import {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen, BsQuestionSquare} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import Button from "./Low/Button";
|
||||
|
||||
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 [isInsert, setIsInsert] = useState(false);
|
||||
const [levels, setLevels] = useState({reading: 0, listening: 0, writing: 0, speaking: 0});
|
||||
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();
|
||||
|
||||
const setExams = useExamStore((state) => state.setExams);
|
||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||
|
||||
const isNextDisabled = () => {
|
||||
if (!focus) return true;
|
||||
return Object.values(levels).includes(-1);
|
||||
};
|
||||
|
||||
const selectExam = () => {
|
||||
const examPromises = DIAGNOSTIC_EXAMS.map((exam) => getExamById(exam[0] as Module, exam[1]));
|
||||
const examPromises = MODULE_ARRAY.map((module) => getExam(module, true));
|
||||
|
||||
Promise.all(examPromises).then((exams) => {
|
||||
if (exams.every((x) => !!x)) {
|
||||
setExams(exams.map((x) => x!));
|
||||
setSelectedModules(exams.map((x) => x!.module));
|
||||
router.push("/exam");
|
||||
router.push("/exercises");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const updateUser = (callback: () => void) => {
|
||||
axios
|
||||
.patch("/api/users/update", {focus, levels, isFirstLogin: false})
|
||||
.patch("/api/users/update", {
|
||||
focus,
|
||||
levels: Object.values(levels).includes(-1) ? {reading: 0, listening: 0, writing: 0, speaking: 0} : levels,
|
||||
desiredLevels,
|
||||
isFirstLogin: false,
|
||||
})
|
||||
.then(callback)
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"});
|
||||
});
|
||||
};
|
||||
|
||||
if (!focus) {
|
||||
return (
|
||||
<div className="bg-white p-16 rounded-2xl flex flex-col items-center justify-center gap-8 h-96 relative shadow-md">
|
||||
<h2 className="absolute top-8 font-semibold text-xl">What is your focus?</h2>
|
||||
<div className="flex flex-col gap-4 justify-self-stretch">
|
||||
<button onClick={() => setFocus("academic")} className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}>
|
||||
Academic
|
||||
</button>
|
||||
<button onClick={() => setFocus("general")} className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}>
|
||||
General
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isInsert) {
|
||||
return (
|
||||
<div className="bg-white p-16 rounded-2xl flex flex-col items-center justify-center gap-8 shadow-md">
|
||||
<h2 className="font-semibold text-xl">What is your level?</h2>
|
||||
<div className="flex w-full flex-col gap-4 justify-self-stretch">
|
||||
{Object.keys(levels).map((module) => (
|
||||
<div key={module} className="flex items-center gap-4 justify-between">
|
||||
<span className="font-medium text-lg">{capitalize(module)}</span>
|
||||
<input
|
||||
type="number"
|
||||
className={clsx(
|
||||
"input input-bordered bg-white w-24",
|
||||
!BAND_SCORES[module as Module].includes(levels[module as keyof typeof levels]) && "input-error",
|
||||
)}
|
||||
value={levels[module as keyof typeof levels]}
|
||||
min={0}
|
||||
max={9}
|
||||
step={0.5}
|
||||
onChange={(e) => setLevels((prev) => ({...prev, [module]: parseFloat(e.target.value)}))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateUser(onFinish)}
|
||||
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
|
||||
disabled={!Object.keys(levels).every((module) => BAND_SCORES[module as Module].includes(levels[module as keyof typeof levels]))}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white p-16 rounded-2xl flex flex-col items-center justify-center gap-8 h-96 relative shadow-md">
|
||||
<h2 className="absolute top-8 font-semibold text-xl">What is your current IELTS level?</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
<button onClick={() => setIsInsert(true)} className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}>
|
||||
Insert my IELTS level
|
||||
</button>
|
||||
<button onClick={() => updateUser(selectExam)} className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}>
|
||||
Perform a Diagnosis Test
|
||||
</button>
|
||||
<div className="flex flex-col items-center justify-center gap-12 w-full">
|
||||
<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">
|
||||
<button
|
||||
onClick={() => setFocus("academic")}
|
||||
className={clsx(
|
||||
"w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-center items-center gap-12 bg-white",
|
||||
"hover:bg-mti-purple-light hover:text-white",
|
||||
focus === "academic" && "!bg-mti-purple-light !text-white",
|
||||
"transition duration-300 ease-in-out",
|
||||
)}>
|
||||
Academic
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFocus("general")}
|
||||
className={clsx(
|
||||
"w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-center items-center gap-12 bg-white",
|
||||
"hover:bg-mti-purple-light hover:text-white",
|
||||
focus === "general" && "!bg-mti-purple-light !text-white",
|
||||
"transition duration-300 ease-in-out",
|
||||
)}>
|
||||
General
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<div className="flex flex-col gap-32 w-full mb-20">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-16 mb-24">
|
||||
<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-20 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 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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,95 +1,123 @@
|
||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import {FillBlanksExercise} from "@/interfaces/exam";
|
||||
import {Dialog, Transition} from "@headlessui/react";
|
||||
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import clsx from "clsx";
|
||||
import {Fragment, useState} from "react";
|
||||
import {Fragment, useEffect, useState} from "react";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import {CommonProps} from ".";
|
||||
import Button from "../Low/Button";
|
||||
|
||||
interface WordsPopoutProps {
|
||||
interface WordsDrawerProps {
|
||||
words: {word: string; isDisabled: boolean}[];
|
||||
isOpen: boolean;
|
||||
blankId?: string;
|
||||
previouslySelectedWord?: string;
|
||||
onCancel: () => void;
|
||||
onAnswer: (answer: string) => void;
|
||||
}
|
||||
|
||||
function WordsPopout({words, isOpen, onCancel, onAnswer}: WordsPopoutProps) {
|
||||
function WordsDrawer({words, isOpen, blankId, previouslySelectedWord, onCancel, onAnswer}: WordsDrawerProps) {
|
||||
const [selectedWord, setSelectedWord] = useState<string | undefined>(previouslySelectedWord);
|
||||
|
||||
return (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={onCancel}>
|
||||
<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="w-fit transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all flex flex-col">
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
|
||||
List of words
|
||||
</Dialog.Title>
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{words.map((word) => (
|
||||
<button
|
||||
key={word.word}
|
||||
onClick={() => onAnswer(word.word)}
|
||||
disabled={word.isDisabled}
|
||||
className={clsx("btn sm:btn-wide gap-4 relative text-white", infoButtonStyle)}>
|
||||
{word.word}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 self-end">
|
||||
<button onClick={onCancel} className={clsx("btn md:btn-wide gap-4 relative text-white", errorButtonStyle)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
"w-full h-full absolute top-0 left-0 bg-gradient-to-t from-mti-black to-transparent z-10",
|
||||
isOpen ? "visible opacity-10" : "invisible opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute w-full bg-white px-7 py-8 bottom-0 left-0 shadow-2xl rounded-2xl z-20 flex flex-col gap-8 transition-opacity duration-300 ease-in-out",
|
||||
isOpen ? "visible opacity-100" : "invisible opacity-0",
|
||||
)}>
|
||||
<div className="w-full flex gap-2">
|
||||
<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>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
<div className="grid grid-cols-6 gap-6" key="word-array">
|
||||
{words.map(({word, isDisabled}) => (
|
||||
<button
|
||||
key={`${word}_${blankId}`}
|
||||
onClick={() => setSelectedWord((prev) => (prev === word ? undefined : word))}
|
||||
className={clsx(
|
||||
"rounded-full py-3 text-center transition duration-300 ease-in-out",
|
||||
selectedWord === word ? "text-white bg-mti-purple-light" : "bg-mti-purple-ultralight",
|
||||
!isDisabled && "hover:text-white hover:bg-mti-purple",
|
||||
"disabled:cursor-not-allowed disabled:text-mti-gray-dim",
|
||||
)}
|
||||
disabled={isDisabled}>
|
||||
{word}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between w-full">
|
||||
<Button color="purple" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
|
||||
Back
|
||||
</Button>
|
||||
<Button color="purple" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FillBlanks({id, allowRepetition, type, prompt, solutions, text, words, onNext, onBack}: FillBlanksExercise & CommonProps) {
|
||||
const [userSolutions, setUserSolutions] = useState<{id: string; solution: string}[]>([]);
|
||||
export default function FillBlanks({
|
||||
id,
|
||||
allowRepetition,
|
||||
type,
|
||||
prompt,
|
||||
solutions,
|
||||
text,
|
||||
words,
|
||||
userSolutions,
|
||||
onNext,
|
||||
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);
|
||||
const allBlanks = Array.from(text.match(/({{\d+}})/g) || []).map((x) => x.replaceAll("{", "").replaceAll("}", ""));
|
||||
|
||||
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
|
||||
}, [hasExamEnded]);
|
||||
|
||||
const calculateScore = () => {
|
||||
const total = text.match(/({{\d+}})/g)?.length || 0;
|
||||
const correct = userSolutions.filter((x) => solutions.find((y) => x.id === y.id)?.solution === x.solution.toLowerCase() || false).length;
|
||||
const correct = answers.filter(
|
||||
(x) => solutions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false,
|
||||
).length;
|
||||
const missing = total - answers.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
|
||||
|
||||
return {total, correct};
|
||||
return {total, correct, missing};
|
||||
};
|
||||
|
||||
const renderLines = (line: string) => {
|
||||
return (
|
||||
<span>
|
||||
<span className="text-base leading-5">
|
||||
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
||||
const id = match.replaceAll(/[\{\}]/g, "");
|
||||
const userSolution = userSolutions.find((x) => x.id === id);
|
||||
const userSolution = answers.find((x) => x.id === id);
|
||||
|
||||
return (
|
||||
<button className="border-2 rounded-xl px-4 text-blue-400 border-blue-400 my-2" onClick={() => setCurrentBlankId(id)}>
|
||||
<button
|
||||
className={clsx(
|
||||
"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",
|
||||
)}
|
||||
onClick={() => setCurrentBlankId(id)}>
|
||||
{userSolution ? userSolution.solution : id}
|
||||
</button>
|
||||
);
|
||||
@@ -100,17 +128,26 @@ export default function FillBlanks({id, allowRepetition, type, prompt, solutions
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<WordsPopout
|
||||
words={words.map((word) => ({word, isDisabled: allowRepetition ? false : userSolutions.map((x) => x.solution).includes(word)}))}
|
||||
isOpen={!!currentBlankId}
|
||||
onCancel={() => setCurrentBlankId(undefined)}
|
||||
onAnswer={(solution: string) => {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.id !== currentBlankId), {id: currentBlankId!, solution}]);
|
||||
setCurrentBlankId(undefined);
|
||||
}}
|
||||
/>
|
||||
<span className="text-base md:text-lg font-medium text-center px-2 md:px-4 lg:px-48">
|
||||
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
||||
{(!!currentBlankId || isDrawerShowing) && (
|
||||
<WordsDrawer
|
||||
key={currentBlankId}
|
||||
blankId={currentBlankId}
|
||||
words={words.map((word) => ({word, isDisabled: allowRepetition ? false : answers.map((x) => x.solution).includes(word)}))}
|
||||
previouslySelectedWord={currentBlankId ? answers.find((x) => x.id === currentBlankId)?.solution : undefined}
|
||||
isOpen={isDrawerShowing}
|
||||
onCancel={() => setCurrentBlankId(undefined)}
|
||||
onAnswer={(solution: string) => {
|
||||
setAnswers((prev) => [...prev.filter((x) => x.id !== currentBlankId), {id: currentBlankId!, solution}]);
|
||||
if (allBlanks.findIndex((x) => x === currentBlankId) + 1 < allBlanks.length) {
|
||||
setCurrentBlankId(allBlanks[allBlanks.findIndex((x) => x === currentBlankId) + 1]);
|
||||
return;
|
||||
}
|
||||
setCurrentBlankId(undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm w-full leading-6">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
{line}
|
||||
@@ -118,31 +155,31 @@ export default function FillBlanks({id, allowRepetition, type, prompt, solutions
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
<span>
|
||||
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
||||
{text.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
<p key={index}>
|
||||
{renderLines(line)}
|
||||
<br />
|
||||
</Fragment>
|
||||
</p>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
|
||||
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
|
||||
<div className="absolute left-4">
|
||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
||||
</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: answers, score: calculateScore(), type})}
|
||||
className="max-w-[200px] w-full">
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
|
||||
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color="purple"
|
||||
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
<div className="absolute right-4">
|
||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
||||
</div>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
253
src/components/Exercises/InteractiveSpeaking.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
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";
|
||||
|
||||
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,
|
||||
text,
|
||||
type,
|
||||
prompts,
|
||||
updateIndex,
|
||||
onNext,
|
||||
onBack,
|
||||
}: InteractiveSpeakingExercise & CommonProps) {
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||
const [promptIndex, setPromptIndex] = useState(0);
|
||||
const [answers, setAnswers] = useState<{prompt: string; blob: string}[]>([]);
|
||||
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
|
||||
useEffect(() => {
|
||||
if (updateIndex) updateIndex(promptIndex);
|
||||
}, [promptIndex, updateIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded) {
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||
score: {correct: 1, total: 1, 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 (promptIndex === answers.length - 1) {
|
||||
setMediaBlob(answers[promptIndex].blob);
|
||||
}
|
||||
}, [answers, promptIndex]);
|
||||
|
||||
const saveAnswer = () => {
|
||||
const answer = {
|
||||
prompt: prompts[promptIndex].text,
|
||||
blob: mediaBlob!,
|
||||
};
|
||||
|
||||
setAnswers((prev) => [...prev, answer]);
|
||||
setMediaBlob(undefined);
|
||||
};
|
||||
|
||||
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">{title}</span>
|
||||
</div>
|
||||
{prompts && prompts.length > 0 && (
|
||||
<div className="flex flex-col gap-4 w-full items-center">
|
||||
<video key={promptIndex} autoPlay controls className="max-w-3xl rounded-xl">
|
||||
<source src={prompts[promptIndex].video_url} />
|
||||
</video>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ReactMediaRecorder
|
||||
audio
|
||||
key={promptIndex}
|
||||
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"
|
||||
onClick={() =>
|
||||
onBack({
|
||||
exercise: id,
|
||||
solutions: answers,
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
type,
|
||||
})
|
||||
}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
color="purple"
|
||||
disabled={!mediaBlob}
|
||||
onClick={() => {
|
||||
saveAnswer();
|
||||
if (promptIndex + 1 < prompts.length) {
|
||||
setPromptIndex((prev) => prev + 1);
|
||||
return;
|
||||
}
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: [
|
||||
...answers,
|
||||
{
|
||||
prompt: prompts[promptIndex].text,
|
||||
blob: mediaBlob!,
|
||||
},
|
||||
],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
type,
|
||||
});
|
||||
}}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
{promptIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,35 +3,44 @@ import {MatchSentencesExercise} from "@/interfaces/exam";
|
||||
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import clsx from "clsx";
|
||||
import {Fragment, useState} from "react";
|
||||
import {Fragment, useEffect, useState} from "react";
|
||||
import LineTo from "react-lineto";
|
||||
import {CommonProps} from ".";
|
||||
import Button from "../Low/Button";
|
||||
import Xarrow from "react-xarrows";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
|
||||
export default function MatchSentences({id, options, type, prompt, sentences, onNext, onBack}: MatchSentencesExercise & CommonProps) {
|
||||
export default function MatchSentences({id, options, type, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) {
|
||||
const [selectedQuestion, setSelectedQuestion] = useState<string>();
|
||||
const [userSolutions, setUserSolutions] = useState<{question: string; option: string}[]>([]);
|
||||
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
||||
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
|
||||
const calculateScore = () => {
|
||||
const total = sentences.length;
|
||||
const correct = userSolutions.filter((x) => sentences.find((y) => y.id === x.question)?.solution === x.option || false).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;
|
||||
|
||||
return {total, correct};
|
||||
return {total, correct, missing};
|
||||
};
|
||||
|
||||
const selectOption = (option: string) => {
|
||||
if (!selectedQuestion) return;
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.question !== selectedQuestion), {question: selectedQuestion, option}]);
|
||||
setAnswers((prev) => [...prev.filter((x) => x.question !== selectedQuestion), {question: selectedQuestion, option}]);
|
||||
setSelectedQuestion(undefined);
|
||||
};
|
||||
|
||||
const getSentenceColor = (id: string) => {
|
||||
return sentences.find((x) => x.id === id)?.color || "";
|
||||
};
|
||||
useEffect(() => {
|
||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasExamEnded]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-8">
|
||||
<span className="text-base md:text-lg font-medium text-center px-2 md:px-4 lg:px-48">
|
||||
<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}>
|
||||
{line}
|
||||
@@ -39,74 +48,63 @@ export default function MatchSentences({id, options, type, prompt, sentences, on
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-16 place-items-center">
|
||||
<div className="flex flex-col gap-1">
|
||||
{sentences.map(({sentence, id, color}) => (
|
||||
<div
|
||||
key={`question_${id}`}
|
||||
className="flex items-center justify-end gap-2 cursor-pointer"
|
||||
onClick={() => setSelectedQuestion((prev) => (prev === id ? undefined : id))}>
|
||||
<span>
|
||||
<span className="font-semibold">{id}.</span> {sentence}{" "}
|
||||
</span>
|
||||
<div
|
||||
style={{borderColor: color, backgroundColor: selectedQuestion === id ? color : "transparent"}}
|
||||
className={clsx("border-2 border-blue-500 w-4 h-4 rounded-full", id)}
|
||||
/>
|
||||
<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 className="flex flex-col gap-1">
|
||||
<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")}
|
||||
onClick={() => selectOption(id)}>
|
||||
<div
|
||||
style={
|
||||
userSolutions.find((x) => x.option === id)
|
||||
? {
|
||||
border: `2px solid ${getSentenceColor(userSolutions.find((x) => x.option === id)!.question)}`,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
className={clsx("border-2 border-green-500 bg-transparent w-4 h-4 rounded-full", id)}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-semibold">{id}.</span> {sentence}{" "}
|
||||
</span>
|
||||
<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>
|
||||
{userSolutions.map((solution, index) => (
|
||||
<div key={`solution_${index}`} className="absolute">
|
||||
<LineTo
|
||||
className="rounded-full"
|
||||
from={solution.question}
|
||||
to={solution.option}
|
||||
borderColor={sentences.find((x) => x.id === solution.question)!.color}
|
||||
borderWidth={5}
|
||||
/>
|
||||
</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 flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
|
||||
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
|
||||
<div className="absolute left-4">
|
||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
||||
</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: answers, score: calculateScore(), type})}
|
||||
className="max-w-[200px] w-full">
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
|
||||
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color="purple"
|
||||
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
<div className="absolute right-4">
|
||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
||||
</div>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,80 +1,45 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
|
||||
import {mdiArrowLeft, mdiArrowRight, mdiCheck, mdiClose} from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import clsx from "clsx";
|
||||
import {useState} from "react";
|
||||
import {useEffect, useState} from "react";
|
||||
import {CommonProps} from ".";
|
||||
import Button from "../Low/Button";
|
||||
|
||||
function Question({
|
||||
variant,
|
||||
prompt,
|
||||
solution,
|
||||
options,
|
||||
userSolution,
|
||||
onSelectOption,
|
||||
showSolution = false,
|
||||
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
|
||||
const optionColor = (option: string) => {
|
||||
if (!showSolution) {
|
||||
return userSolution === option ? "border-blue-400" : "";
|
||||
}
|
||||
|
||||
if (option === solution) {
|
||||
return "border-green-500 text-green-500";
|
||||
}
|
||||
|
||||
return userSolution === option ? "border-red-500 text-red-500" : "";
|
||||
};
|
||||
|
||||
const optionBadge = (option: string) => {
|
||||
if (option === userSolution) {
|
||||
if (solution === option) {
|
||||
return (
|
||||
<div className="badge badge-lg bg-green-500 border-green-500 absolute -top-2 -right-4">
|
||||
<div className="tooltip" data-tip="You have correctly answered!">
|
||||
<Icon path={mdiCheck} color="white" size={0.8} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="badge badge-lg bg-red-500 border-red-500 absolute -top-2 -right-4">
|
||||
<div className="tooltip" data-tip="You have wrongly answered!">
|
||||
<Icon path={mdiClose} color="white" size={0.8} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<span className="text-center">{prompt}</span>
|
||||
<div className="grid grid-rows-4 md:grid-rows-1 md:grid-cols-4 gap-4 place-items-center">
|
||||
<div className="flex flex-col gap-10">
|
||||
<span className="">{prompt}</span>
|
||||
<div className="flex flex-wrap gap-4 justify-between">
|
||||
{variant === "image" &&
|
||||
options.map((option) => (
|
||||
<div
|
||||
key={option.id}
|
||||
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
|
||||
key={option.id.toString()}
|
||||
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
|
||||
className={clsx(
|
||||
"flex flex-col items-center border-2 p-4 rounded-xl gap-4 cursor-pointer bg-white relative",
|
||||
optionColor(option.id),
|
||||
"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",
|
||||
)}>
|
||||
{showSolution && optionBadge(option.id)}
|
||||
<img src={option.src!} alt={`Option ${option.id}`} />
|
||||
<span>{option.id}</span>
|
||||
<span className={clsx("text-sm", userSolution !== option.id.toString() && "opacity-50")}>{option.id.toString()}</span>
|
||||
<img src={option.src!} alt={`Option ${option.id.toString()}`} />
|
||||
</div>
|
||||
))}
|
||||
{variant === "text" &&
|
||||
options.map((option) => (
|
||||
<div
|
||||
key={option.id}
|
||||
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
|
||||
className={clsx("flex border-2 p-4 rounded-xl gap-2 cursor-pointer bg-white", optionColor(option.id))}>
|
||||
<span className="font-bold">{option.id}.</span>
|
||||
key={option.id.toString()}
|
||||
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : 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",
|
||||
)}>
|
||||
<span className="font-semibold">{option.id.toString()}.</span>
|
||||
<span>{option.text}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -83,25 +48,48 @@ function Question({
|
||||
);
|
||||
}
|
||||
|
||||
export default function MultipleChoice({id, prompt, type, questions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
|
||||
const [userSolutions, setUserSolutions] = useState<{question: string; option: string}[]>([]);
|
||||
export default function MultipleChoice({
|
||||
id,
|
||||
prompt,
|
||||
type,
|
||||
questions,
|
||||
userSolutions,
|
||||
updateIndex,
|
||||
onNext,
|
||||
onBack,
|
||||
}: MultipleChoiceExercise & CommonProps) {
|
||||
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
||||
const [questionIndex, setQuestionIndex] = useState(0);
|
||||
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasExamEnded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (updateIndex) updateIndex(questionIndex);
|
||||
}, [questionIndex, updateIndex]);
|
||||
|
||||
const onSelectOption = (option: string) => {
|
||||
const question = questions[questionIndex];
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);
|
||||
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);
|
||||
};
|
||||
|
||||
const calculateScore = () => {
|
||||
const total = questions.length;
|
||||
const correct = userSolutions.filter((x) => questions.find((y) => y.id === x.question)?.solution === x.option || false).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;
|
||||
|
||||
return {total, correct};
|
||||
return {total, correct, missing};
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
if (questionIndex === questions.length - 1) {
|
||||
onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
|
||||
onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||
} else {
|
||||
setQuestionIndex((prev) => prev + 1);
|
||||
}
|
||||
@@ -109,7 +97,7 @@ export default function MultipleChoice({id, prompt, type, questions, onNext, onB
|
||||
|
||||
const back = () => {
|
||||
if (questionIndex === 0) {
|
||||
onBack();
|
||||
onBack({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||
} else {
|
||||
setQuestionIndex((prev) => prev - 1);
|
||||
}
|
||||
@@ -117,30 +105,25 @@ export default function MultipleChoice({id, prompt, type, questions, onNext, onB
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<span className="text-base md:text-lg font-medium text-center px-2 md:px-4 lg:px-48">{prompt}</span>
|
||||
<div className="flex flex-col gap-2 mt-4 h-fit mb-20 bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||
<span className="text-xl font-semibold">{prompt}</span>
|
||||
{questionIndex < questions.length && (
|
||||
<Question
|
||||
{...questions[questionIndex]}
|
||||
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option}
|
||||
userSolution={answers.find((x) => questions[questionIndex].id === x.question)?.option}
|
||||
onSelectOption={onSelectOption}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
|
||||
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={back}>
|
||||
<div className="absolute left-4">
|
||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
||||
</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={back} className="max-w-[200px] w-full">
|
||||
Back
|
||||
</button>
|
||||
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={next}>
|
||||
</Button>
|
||||
|
||||
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
<div className="absolute right-4">
|
||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
||||
</div>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,52 +1,233 @@
|
||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import {SpeakingExercise, WritingExercise} from "@/interfaces/exam";
|
||||
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import clsx from "clsx";
|
||||
import {SpeakingExercise} from "@/interfaces/exam";
|
||||
import {CommonProps} from ".";
|
||||
import {Fragment, useEffect, useState} from "react";
|
||||
import {toast} from "react-toastify";
|
||||
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";
|
||||
|
||||
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, onNext, onBack}: SpeakingExercise & CommonProps) {
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded) {
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||
score: {correct: 1, total: 1, 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]);
|
||||
|
||||
export default function Speaking({id, title, text, type, prompts, onNext, onBack}: SpeakingExercise & CommonProps) {
|
||||
return (
|
||||
<div className="flex flex-col h-full w-2/3 items-center justify-center gap-8">
|
||||
<div className="flex flex-col max-w-2xl gap-4">
|
||||
<span className="font-bold">{title}</span>
|
||||
<span className="font-regular ml-8">
|
||||
{text.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
<span>{line}</span>
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
<div className="flex gap-8">
|
||||
<span>You should talk about the following things:</span>
|
||||
<div className="flex flex-col gap-4">
|
||||
{prompts.map((x, index) => (
|
||||
<li className="italic" key={index}>
|
||||
{x}
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col h-full w-full gap-9">
|
||||
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
|
||||
<div className="flex flex-col gap-3">
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
|
||||
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
|
||||
<div className="absolute left-4">
|
||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
||||
<ReactMediaRecorder
|
||||
audio
|
||||
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"
|
||||
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
|
||||
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
|
||||
onClick={() => onNext({exercise: id, solutions: [], score: {correct: 1, total: 1}, type})}>
|
||||
</Button>
|
||||
<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
|
||||
<div className="absolute right-4">
|
||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
||||
</div>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
121
src/components/Exercises/TrueFalse.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import {TrueFalseExercise} from "@/interfaces/exam";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {Fragment, useEffect, useState} from "react";
|
||||
import {CommonProps} from ".";
|
||||
import Button from "../Low/Button";
|
||||
|
||||
export default function TrueFalse({id, type, prompt, questions, userSolutions, onNext, onBack}: TrueFalseExercise & CommonProps) {
|
||||
const [answers, setAnswers] = useState<{id: string; solution: "true" | "false" | "not_given"}[]>(userSolutions);
|
||||
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasExamEnded]);
|
||||
|
||||
const calculateScore = () => {
|
||||
const total = questions.length || 0;
|
||||
const correct = answers.filter(
|
||||
(x) => questions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false,
|
||||
).length;
|
||||
const missing = total - answers.filter((x) => questions.find((y) => x.id.toString() === y.id.toString())).length;
|
||||
|
||||
return {total, correct, missing};
|
||||
};
|
||||
|
||||
const toggleAnswer = (solution: "true" | "false" | "not_given", questionId: string) => {
|
||||
const answer = answers.find((x) => x.id === questionId);
|
||||
if (answer && answer.solution === solution) {
|
||||
setAnswers((prev) => prev.filter((x) => x.id !== questionId));
|
||||
return;
|
||||
}
|
||||
|
||||
setAnswers((prev) => [...prev.filter((x) => x.id !== questionId), {id: questionId, solution}]);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<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}>
|
||||
{line}
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
<div className="flex flex-col gap-6 mb-4">
|
||||
<p>For each of the questions below, select</p>
|
||||
<div className="pl-8 flex gap-8">
|
||||
<span className="flex flex-col gap-4">
|
||||
<span className="font-bold italic">TRUE</span>
|
||||
<span className="font-bold italic">FALSE</span>
|
||||
<span className="font-bold italic">NOT GIVEN</span>
|
||||
</span>
|
||||
<span className="flex flex-col gap-4">
|
||||
<span>if the statement agrees with the information</span>
|
||||
<span>if the statement contradicts with the information</span>
|
||||
<span>if there is no information on this</span>
|
||||
</span>
|
||||
</div>
|
||||
</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) => (
|
||||
<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() === question.id.toString())?.solution === "true" ? "solid" : "outline"
|
||||
}
|
||||
onClick={() => toggleAnswer("true", question.id.toString())}
|
||||
className="!py-2">
|
||||
True
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
answers.find((x) => x.id.toString() === question.id.toString())?.solution === "false" ? "solid" : "outline"
|
||||
}
|
||||
onClick={() => toggleAnswer("false", question.id.toString())}
|
||||
className="!py-2">
|
||||
False
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
answers.find((x) => x.id.toString() === question.id.toString())?.solution === "not_given"
|
||||
? "solid"
|
||||
: "outline"
|
||||
}
|
||||
onClick={() => toggleAnswer("not_given", question.id.toString())}
|
||||
className="!py-2">
|
||||
Not Given
|
||||
</Button>
|
||||
</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: answers, score: calculateScore(), type})}
|
||||
className="max-w-[200px] w-full">
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color="purple"
|
||||
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,14 +3,17 @@ import {WriteBlanksExercise} from "@/interfaces/exam";
|
||||
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import clsx from "clsx";
|
||||
import {useEffect, useState} from "react";
|
||||
import {Fragment, useEffect, useState} from "react";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import {CommonProps} from ".";
|
||||
import {toast} from "react-toastify";
|
||||
import Button from "../Low/Button";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
|
||||
function Blank({
|
||||
id,
|
||||
maxWords,
|
||||
userSolution,
|
||||
showSolutions = false,
|
||||
setUserSolution,
|
||||
}: {
|
||||
@@ -19,9 +22,9 @@ function Blank({
|
||||
userSolution?: string;
|
||||
maxWords: number;
|
||||
showSolutions?: boolean;
|
||||
setUserSolution?: (solution: string) => void;
|
||||
setUserSolution: (solution: string) => void;
|
||||
}) {
|
||||
const [userInput, setUserInput] = useState("");
|
||||
const [userInput, setUserInput] = useState(userSolution || "");
|
||||
|
||||
useEffect(() => {
|
||||
const words = userInput.split(" ").filter((x) => x !== "");
|
||||
@@ -33,39 +36,48 @@ function Blank({
|
||||
|
||||
return (
|
||||
<input
|
||||
className={clsx("input border rounded-xl px-2 py-1 bg-white text-blue-400 border-blue-400 my-2")}
|
||||
className="py-2 px-3 mx-2 rounded-2xl w-48 bg-white focus:outline-none my-2"
|
||||
placeholder={id}
|
||||
onChange={(e) => setUserInput(e.target.value)}
|
||||
onBlur={() => setUserSolution(userInput)}
|
||||
value={userInput}
|
||||
contentEditable={showSolutions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WriteBlanks({id, prompt, type, maxWords, solutions, text, onNext, onBack}: WriteBlanksExercise & CommonProps) {
|
||||
const [userSolutions, setUserSolutions] = useState<{id: string; solution: string}[]>([]);
|
||||
export default function WriteBlanks({id, prompt, type, maxWords, solutions, userSolutions, text, onNext, onBack}: WriteBlanksExercise & CommonProps) {
|
||||
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
|
||||
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasExamEnded]);
|
||||
|
||||
const calculateScore = () => {
|
||||
const total = text.match(/({{\d+}})/g)?.length || 0;
|
||||
const correct = userSolutions.filter(
|
||||
const correct = answers.filter(
|
||||
(x) =>
|
||||
solutions
|
||||
.find((y) => x.id === y.id)
|
||||
?.solution.map((y) => y.toLowerCase())
|
||||
.includes(x.solution.toLowerCase()) || false,
|
||||
.find((y) => x.id.toString() === y.id.toString())
|
||||
?.solution.map((y) => y.toLowerCase().trim())
|
||||
.includes(x.solution.toLowerCase().trim()) || false,
|
||||
).length;
|
||||
const missing = total - answers.filter((x) => solutions.find((y) => x.id === y.id)).length;
|
||||
|
||||
return {total, correct};
|
||||
return {total, correct, missing};
|
||||
};
|
||||
|
||||
const renderLines = (line: string) => {
|
||||
return (
|
||||
<span>
|
||||
<span className="text-base leading-5">
|
||||
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
||||
const id = match.replaceAll(/[\{\}]/g, "");
|
||||
const userSolution = userSolutions.find((x) => x.id === id);
|
||||
const userSolution = answers.find((x) => x.id === id);
|
||||
const setUserSolution = (solution: string) => {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.id !== id), {id, solution}]);
|
||||
setAnswers((prev) => [...prev.filter((x) => x.id !== id), {id, solution}]);
|
||||
};
|
||||
|
||||
return <Blank userSolution={userSolution?.solution} maxWords={maxWords} id={id} setUserSolution={setUserSolution} />;
|
||||
@@ -76,33 +88,40 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, text
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-lg font-medium text-center px-48">{prompt}</span>
|
||||
<span>
|
||||
{text.split("\\n").map((line) => (
|
||||
<>
|
||||
<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}>
|
||||
{line}
|
||||
<br />
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
||||
{text.split("\\n").map((line, index) => (
|
||||
<p key={index}>
|
||||
{renderLines(line)}
|
||||
<br />
|
||||
</>
|
||||
</p>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
|
||||
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
|
||||
<div className="absolute left-4">
|
||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
||||
</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: answers, score: calculateScore(), type})}
|
||||
className="max-w-[200px] w-full">
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
|
||||
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color="purple"
|
||||
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
<div className="absolute right-4">
|
||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
||||
</div>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,49 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import {WritingExercise} from "@/interfaces/exam";
|
||||
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import clsx from "clsx";
|
||||
import {CommonProps} from ".";
|
||||
import {Fragment, useEffect, useState} from "react";
|
||||
import React, {Fragment, useEffect, useRef, useState} from "react";
|
||||
import {toast} from "react-toastify";
|
||||
import Button from "../Low/Button";
|
||||
import {Dialog, Transition} from "@headlessui/react";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
|
||||
export default function Writing({id, prompt, info, type, wordCounter, attachment, onNext, onBack}: WritingExercise & CommonProps) {
|
||||
const [inputText, setInputText] = useState("");
|
||||
export default function Writing({
|
||||
id,
|
||||
prompt,
|
||||
prefix,
|
||||
suffix,
|
||||
type,
|
||||
wordCounter,
|
||||
attachment,
|
||||
userSolutions,
|
||||
onNext,
|
||||
onBack,
|
||||
}: WritingExercise & CommonProps) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : "");
|
||||
const [isSubmitEnabled, setIsSubmitEnabled] = useState(false);
|
||||
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
|
||||
useEffect(() => {
|
||||
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: 1, total: 1, missing: 0}, type});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasExamEnded]);
|
||||
|
||||
useEffect(() => {
|
||||
const words = inputText.split(" ").filter((x) => x !== "");
|
||||
|
||||
@@ -27,59 +59,82 @@ export default function Writing({id, prompt, info, type, wordCounter, attachment
|
||||
}, [inputText, wordCounter]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-2/3 items-center justify-center gap-8">
|
||||
<div className="flex flex-col max-w-2xl gap-2">
|
||||
<span>{info}</span>
|
||||
<span className="font-bold ml-8">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
<span>{line}</span>
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
<span>
|
||||
You should write {wordCounter.type === "min" ? "at least" : "at most"} {wordCounter.limit} words.
|
||||
</span>
|
||||
{attachment && <img src={attachment} alt="Exercise attachment" />}
|
||||
<>
|
||||
{attachment && (
|
||||
<Transition show={isModalOpen} as={Fragment}>
|
||||
<Dialog onClose={() => setIsModalOpen(false)} 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-fit h-fit rounded-xl bg-white">
|
||||
<img src={attachment.url} alt={attachment.description} className="max-w-4xl w-full self-center rounded-xl p-4" />
|
||||
</Dialog.Panel>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
)}
|
||||
<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}</span>
|
||||
<span className="font-semibold whitespace-pre-wrap">{prompt}</span>
|
||||
{attachment && (
|
||||
<img
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
src={attachment.url}
|
||||
alt={attachment.description}
|
||||
className="max-w-md self-center rounded-xl cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full h-full flex flex-col gap-4">
|
||||
<span className="whitespace-pre-wrap">{suffix}</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"
|
||||
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>
|
||||
|
||||
<textarea
|
||||
className="w-full h-1/3 cursor-text p-2 input input-bordered bg-white"
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
value={inputText}
|
||||
placeholder="Write your text here..."
|
||||
/>
|
||||
|
||||
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
|
||||
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
|
||||
<div className="absolute left-4">
|
||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
||||
</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: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type})}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
Back
|
||||
</button>
|
||||
{!isSubmitEnabled && (
|
||||
<div className="tooltip" data-tip={`You have not yet reached your minimum word count of ${wordCounter.limit} words!`}>
|
||||
<button
|
||||
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
|
||||
disabled={!isSubmitEnabled}
|
||||
onClick={() => onNext({exercise: id, solutions: [inputText], score: {correct: 1, total: 1}, type})}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isSubmitEnabled && (
|
||||
<button
|
||||
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
|
||||
disabled={!isSubmitEnabled}
|
||||
onClick={() => onNext({exercise: id, solutions: [inputText], score: {correct: 1, total: 1}, type})}>
|
||||
Next
|
||||
<div className="absolute right-4">
|
||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
color="purple"
|
||||
disabled={!isSubmitEnabled}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
Exercise,
|
||||
FillBlanksExercise,
|
||||
InteractiveSpeakingExercise,
|
||||
MatchSentencesExercise,
|
||||
MultipleChoiceExercise,
|
||||
SpeakingExercise,
|
||||
TrueFalseExercise,
|
||||
UserSolution,
|
||||
WriteBlanksExercise,
|
||||
WritingExercise,
|
||||
@@ -14,27 +16,55 @@ import MultipleChoice from "./MultipleChoice";
|
||||
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 {
|
||||
updateIndex?: (internalIndex: number) => void;
|
||||
onNext: (userSolutions: UserSolution) => void;
|
||||
onBack: () => void;
|
||||
onBack: (userSolutions: UserSolution) => void;
|
||||
}
|
||||
|
||||
export const renderExercise = (exercise: Exercise, onNext: (userSolutions: UserSolution) => void, onBack: () => void) => {
|
||||
export const renderExercise = (
|
||||
exercise: Exercise,
|
||||
onNext: (userSolutions: UserSolution) => void,
|
||||
onBack: (userSolutions: UserSolution) => void,
|
||||
updateIndex?: (internalIndex: number) => void,
|
||||
) => {
|
||||
switch (exercise.type) {
|
||||
case "fillBlanks":
|
||||
return <FillBlanks {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "trueFalse":
|
||||
return <TrueFalse key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "matchSentences":
|
||||
return <MatchSentences {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "multipleChoice":
|
||||
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return (
|
||||
<MultipleChoice
|
||||
key={exercise.id}
|
||||
{...(exercise as MultipleChoiceExercise)}
|
||||
updateIndex={updateIndex}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
case "writeBlanks":
|
||||
return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "writing":
|
||||
return <Writing {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "speaking":
|
||||
return <Speaking {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "interactiveSpeaking":
|
||||
return (
|
||||
<InteractiveSpeaking
|
||||
key={exercise.id}
|
||||
{...(exercise as InteractiveSpeakingExercise)}
|
||||
updateIndex={updateIndex}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
9
src/components/FocusLayer.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
interface Props {
|
||||
onFocusLayerMouseEnter?: () => void;
|
||||
}
|
||||
|
||||
export default function FocusLayer({onFocusLayerMouseEnter}: Props) {
|
||||
return <div className="absolute top-0 left-0 bottom-0 right-0" onMouseDown={onFocusLayerMouseEnter} />;
|
||||
}
|
||||
48
src/components/High/Layout.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
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";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
navDisabled?: boolean;
|
||||
focusMode?: boolean;
|
||||
onFocusLayerMouseEnter?: () => void;
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
<div className="h-full w-full flex gap-2">
|
||||
<Sidebar
|
||||
path={router.pathname}
|
||||
navDisabled={navDisabled}
|
||||
focusMode={focusMode}
|
||||
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
|
||||
className="-md:hidden"
|
||||
userType={user.type}
|
||||
/>
|
||||
<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",
|
||||
className,
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
96
src/components/Low/AudioPlayer.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import {Module} from "@/interfaces";
|
||||
import {formatTimeInMinutes} from "@/utils/string";
|
||||
import clsx from "clsx";
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import {BsPauseFill, BsPlayFill} from "react-icons/bs";
|
||||
import ProgressBar from "./ProgressBar";
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
color: "red" | "rose" | "purple" | Module;
|
||||
autoPlay?: boolean;
|
||||
disabled?: boolean;
|
||||
onEnd?: () => void;
|
||||
disablePause?: boolean;
|
||||
}
|
||||
|
||||
export default function AudioPlayer({src, color, autoPlay = false, disabled = false, onEnd, disablePause = false}: Props) {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
let playingInterval: NodeJS.Timer | undefined = undefined;
|
||||
if (isPlaying) {
|
||||
playingInterval = setInterval(() => setCurrentTime((prev) => prev + 1), 1000);
|
||||
} else if (playingInterval) {
|
||||
clearInterval(playingInterval);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (playingInterval) clearInterval(playingInterval);
|
||||
};
|
||||
}, [isPlaying]);
|
||||
|
||||
const togglePlayPause = () => {
|
||||
const prevValue = isPlaying;
|
||||
setIsPlaying(!prevValue);
|
||||
if (!prevValue) {
|
||||
audioPlayerRef?.current?.play();
|
||||
} else {
|
||||
audioPlayerRef?.current?.pause();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
{!isPlaying && (
|
||||
<BsPlayFill
|
||||
className={clsx("text-mti-gray-cool cursor-pointer w-5 h-5", disabled && "opacity-60 cursor-not-allowed")}
|
||||
onClick={disabled ? undefined : togglePlayPause}
|
||||
/>
|
||||
)}
|
||||
<audio
|
||||
src={src}
|
||||
autoPlay={autoPlay}
|
||||
ref={audioPlayerRef}
|
||||
preload="metadata"
|
||||
onEnded={() => {
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
if (onEnd) onEnd();
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 w-full relative">
|
||||
<div className="absolute w-full flex justify-between -top-5 text-xs px-1">
|
||||
<span>{formatTimeInMinutes(currentTime)}</span>
|
||||
<span>{formatTimeInMinutes(duration)}</span>
|
||||
</div>
|
||||
<ProgressBar label="" color={color} useColor percentage={(currentTime * 100) / duration} className="h-3 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
src/components/Low/Button.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import clsx from "clsx";
|
||||
import {ReactNode} from "react";
|
||||
import {BsArrowRepeat} from "react-icons/bs";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
color?: "rose" | "purple" | "red" | "green";
|
||||
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) {
|
||||
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:
|
||||
"bg-transparent text-mti-purple-light border border-mti-purple-light hover:bg-mti-purple-light disabled:text-mti-purple disabled:bg-mti-purple-ultralight disabled:border-none selection:bg-mti-purple-dark hover:text-white selection:text-white",
|
||||
},
|
||||
red: {
|
||||
solid: "bg-mti-red-light text-white border border-mti-red-light hover:bg-mti-red disabled:text-mti-red disabled:bg-mti-red-ultralight selection:bg-mti-red-dark",
|
||||
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",
|
||||
},
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
||||
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",
|
||||
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>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
26
src/components/Low/Checkbox.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export default function Checkbox({isChecked, onChange, children}: Props) {
|
||||
return (
|
||||
<div className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer" onClick={() => 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>
|
||||
);
|
||||
}
|
||||
81
src/components/Low/CountrySelect.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
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} (+${country.phone})`;
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
88
src/components/Low/Input.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import clsx from "clsx";
|
||||
import {useState} from "react";
|
||||
|
||||
interface Props {
|
||||
type: "email" | "text" | "password" | "tel" | "number";
|
||||
roundness?: "full" | "xl";
|
||||
required?: boolean;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string | number;
|
||||
value?: string | number;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
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) {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
if (type === "password") {
|
||||
return (
|
||||
<div className="relative flex flex-col gap-3 w-full">
|
||||
{label && (
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
{label}
|
||||
{required ? " *" : ""}
|
||||
</label>
|
||||
)}
|
||||
<div className="w-full h-fit relative">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
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
|
||||
role="button"
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
className="text-xs cursor-pointer absolute bottom-1/2 translate-y-1/2 right-8">
|
||||
{showPassword ? "Hide" : "Show"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx("flex flex-col gap-3 w-full", className)}>
|
||||
{label && (
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
{label}
|
||||
{required ? " *" : ""}
|
||||
</label>
|
||||
)}
|
||||
<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",
|
||||
)}
|
||||
required={required}
|
||||
defaultValue={defaultValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
src/components/Low/ProgressBar.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import {Module} from "@/interfaces";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
percentage: number;
|
||||
color: "red" | "rose" | "purple" | Module;
|
||||
useColor?: boolean;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
}
|
||||
|
||||
export default function ProgressBar({label, percentage, color, useColor = false, className, textClassName}: Props) {
|
||||
const progressColorClass: {[key in typeof color]: string} = {
|
||||
red: "bg-mti-red-light",
|
||||
rose: "bg-mti-rose-light",
|
||||
purple: "bg-mti-purple-light",
|
||||
reading: "bg-ielts-reading",
|
||||
listening: "bg-ielts-listening",
|
||||
writing: "bg-ielts-writing",
|
||||
speaking: "bg-ielts-speaking",
|
||||
level: "bg-ielts-level",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"relative rounded-full overflow-hidden flex items-center justify-center",
|
||||
className,
|
||||
!useColor ? "bg-mti-gray-anti-flash" : progressColorClass[color],
|
||||
useColor && "bg-opacity-20",
|
||||
)}>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
src/components/Medium/ModuleTitle.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import {Module} from "@/interfaces";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
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 ProgressBar from "../Low/ProgressBar";
|
||||
import TimerEndedModal from "../TimerEndedModal";
|
||||
|
||||
interface Props {
|
||||
minTimer: number;
|
||||
module: Module;
|
||||
label?: string;
|
||||
exerciseIndex: number;
|
||||
totalExercises: number;
|
||||
disableTimer?: boolean;
|
||||
}
|
||||
|
||||
export default function ModuleTitle({minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false}: Props) {
|
||||
const [timer, setTimer] = useState(minTimer * 60);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [warningMode, setWarningMode] = useState(false);
|
||||
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
|
||||
|
||||
useEffect(() => {
|
||||
if (!disableTimer) {
|
||||
const timerInterval = setInterval(() => setTimer((prev) => prev - 1), 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(timerInterval);
|
||||
};
|
||||
}
|
||||
}, [disableTimer, minTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (timer <= 0) setShowModal(true);
|
||||
}, [timer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (timer < 300 && !warningMode) setWarningMode(true);
|
||||
}, [timer, warningMode]);
|
||||
|
||||
const moduleIcon: {[key in Module]: ReactNode} = {
|
||||
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
|
||||
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 (
|
||||
<>
|
||||
<TimerEndedModal
|
||||
isOpen={showModal}
|
||||
onClose={() => {
|
||||
setHasExamEnded(true);
|
||||
setShowModal(false);
|
||||
}}
|
||||
/>
|
||||
<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",
|
||||
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">
|
||||
{timer > 0 && (
|
||||
<>
|
||||
{Math.floor(timer / 60)
|
||||
.toString(10)
|
||||
.padStart(2, "0")}
|
||||
:
|
||||
{Math.floor(timer % 60)
|
||||
.toString(10)
|
||||
.padStart(2, "0")}
|
||||
</>
|
||||
)}
|
||||
{timer <= 0 && <>00:00</>}
|
||||
</span>
|
||||
</motion.div>
|
||||
<div className="flex gap-6 w-full h-fit items-center mt-5">
|
||||
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg">{moduleIcon[module]}</div>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<div className="w-full flex justify-between">
|
||||
<span className="text-base font-semibold">
|
||||
{moduleLabels[module]} exam {label && `- ${label}`}
|
||||
</span>
|
||||
<span className="text-sm font-semibold self-end">
|
||||
Exercise {exerciseIndex}/{totalExercises}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
149
src/components/MobileMenu.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
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 {BsShield, BsShieldFill, BsXLg} from "react-icons/bs";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
path: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export default function MobileMenu({isOpen, onClose, path, user}: 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="w-full h-screen transform overflow-hidden bg-white text-left align-middle shadow-xl transition-all text-black flex flex-col gap-8">
|
||||
<Dialog.Title as="header" className="w-full px-8 py-2 -md:flex justify-between items-center md:hidden shadow-sm">
|
||||
<Link href="/">
|
||||
<Image src="/logo_title.png" alt="EnCoach logo" width={69} height={69} />
|
||||
</Link>
|
||||
<div className="cursor-pointer" onClick={onClose} tabIndex={0}>
|
||||
<BsXLg className="text-2xl text-mti-purple-light" onClick={onClose} />
|
||||
</div>
|
||||
</Dialog.Title>
|
||||
<div className="flex flex-col h-full gap-6 px-8 text-lg">
|
||||
<Link
|
||||
href="/"
|
||||
className={clsx(
|
||||
"transition ease-in-out duration-300 w-fit",
|
||||
path === "/" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
||||
)}>
|
||||
Dashboard
|
||||
</Link>
|
||||
{(user.type === "student" || user.type === "teacher" || user.type === "developer") && (
|
||||
<>
|
||||
<Link
|
||||
href="/exam"
|
||||
className={clsx(
|
||||
"transition ease-in-out duration-300 w-fit",
|
||||
path === "/exam" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
||||
)}>
|
||||
Exams
|
||||
</Link>
|
||||
<Link
|
||||
href="/exercises"
|
||||
className={clsx(
|
||||
"transition ease-in-out duration-300 w-fit",
|
||||
path === "/exercises" &&
|
||||
"text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
||||
)}>
|
||||
Exercises
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<Link
|
||||
href="/stats"
|
||||
className={clsx(
|
||||
"transition ease-in-out duration-300 w-fit",
|
||||
path === "/stats" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
||||
)}>
|
||||
Stats
|
||||
</Link>
|
||||
<Link
|
||||
href="/record"
|
||||
className={clsx(
|
||||
"transition ease-in-out duration-300 w-fit",
|
||||
path === "/record" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
||||
)}>
|
||||
Record
|
||||
</Link>
|
||||
{["admin", "developer", "agent"].includes(user.type) && (
|
||||
<Link
|
||||
href="/payment-record"
|
||||
className={clsx(
|
||||
"transition ease-in-out duration-300 w-fit",
|
||||
path === "/payment-record" &&
|
||||
"text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
||||
)}>
|
||||
Payment Record
|
||||
</Link>
|
||||
)}
|
||||
{["admin", "developer", "corporate", "teacher"].includes(user.type) && (
|
||||
<Link
|
||||
href="/settings"
|
||||
className={clsx(
|
||||
"transition ease-in-out duration-300 w-fit",
|
||||
path === "/settings" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
||||
)}>
|
||||
Settings
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href="/profile"
|
||||
className={clsx(
|
||||
"transition ease-in-out duration-300 w-fit",
|
||||
path === "/profile" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
|
||||
)}>
|
||||
Profile
|
||||
</Link>
|
||||
|
||||
<span
|
||||
className={clsx("transition ease-in-out duration-300 w-fit justify-self-end cursor-pointer")}
|
||||
onClick={logout}>
|
||||
Logout
|
||||
</span>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
50
src/components/Modal.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import {Dialog, Transition} from "@headlessui/react";
|
||||
import {Fragment, ReactElement} from "react";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children?: ReactElement;
|
||||
}
|
||||
|
||||
export default function Modal({isOpen, title, onClose, children}: Props) {
|
||||
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 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="w-full max-w-6xl transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
|
||||
{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,77 +1,82 @@
|
||||
import {Type} from "@/interfaces/user";
|
||||
import axios from "axios";
|
||||
import {User} from "@/interfaces/user";
|
||||
import Link from "next/link";
|
||||
import FocusLayer from "@/components/FocusLayer";
|
||||
import {preventNavigation} from "@/utils/navigation.disabled";
|
||||
import {useRouter} from "next/router";
|
||||
import {Button} from "primereact/button";
|
||||
import {Menubar} from "primereact/menubar";
|
||||
import {MenuItem} from "primereact/menuitem";
|
||||
import {BsList} from "react-icons/bs";
|
||||
import clsx from "clsx";
|
||||
import moment from "moment";
|
||||
import MobileMenu from "./MobileMenu";
|
||||
import {useState} from "react";
|
||||
|
||||
interface Props {
|
||||
profilePicture: string;
|
||||
userType: Type;
|
||||
timer?: number;
|
||||
showExamEnd?: boolean;
|
||||
user: User;
|
||||
navDisabled?: boolean;
|
||||
focusMode?: boolean;
|
||||
onFocusLayerMouseEnter?: () => void;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
export default function Navbar({profilePicture, userType, timer, showExamEnd = false}: Props) {
|
||||
export default function Navbar({user, path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||
const router = useRouter();
|
||||
|
||||
const logout = async () => {
|
||||
axios.post("/api/logout").finally(() => {
|
||||
router.push("/login");
|
||||
});
|
||||
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 items: MenuItem[] = [
|
||||
{
|
||||
label: "Home",
|
||||
icon: "pi pi-fw pi-home",
|
||||
url: "/",
|
||||
},
|
||||
{
|
||||
label: "Account",
|
||||
icon: "pi pi-fw pi-user",
|
||||
url: "/profile",
|
||||
},
|
||||
{
|
||||
label: "Exam",
|
||||
icon: "pi pi-fw pi-plus-circle",
|
||||
url: "/exam",
|
||||
},
|
||||
{
|
||||
label: "Users",
|
||||
icon: "pi pi-fw pi-users",
|
||||
items: [
|
||||
...(userType === "student" ? [] : [{label: "List", icon: "pi pi-fw pi-users", url: "/users"}]),
|
||||
{label: "Stats", icon: "pi pi-fw pi-chart-pie", url: "/stats"},
|
||||
{label: "History", icon: "pi pi-fw pi-history", url: "/history"},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Logout",
|
||||
icon: "pi pi-fw pi-power-off",
|
||||
command: logout,
|
||||
},
|
||||
];
|
||||
const showExpirationDate = () => {
|
||||
if (!user.subscriptionExpirationDate) return false;
|
||||
|
||||
const endTimer = timer && (
|
||||
<span className="pr-2 font-semibold">
|
||||
{Math.floor(timer / 60) < 10 ? "0" : ""}
|
||||
{Math.floor(timer / 60)}:{timer % 60 < 10 ? "0" : ""}
|
||||
{timer % 60}
|
||||
</span>
|
||||
);
|
||||
const momentDate = moment(user.subscriptionExpirationDate);
|
||||
const today = moment(new Date());
|
||||
|
||||
const endNewExam = (
|
||||
<Link href="/exam" className="pr-2">
|
||||
<Button text label="Exam" severity="secondary" size="small" />
|
||||
</Link>
|
||||
);
|
||||
return today.add(7, "days").isAfter(momentDate);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-neutral-100 z-10 w-full p-2">
|
||||
<Menubar model={items} end={showExamEnd ? endNewExam : endTimer} />
|
||||
</div>
|
||||
<>
|
||||
{user && <MobileMenu path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />}
|
||||
<header className="w-full bg-transparent py-2 md:py-4 -md:justify-between md:gap-12 flex items-center relative -md:px-4">
|
||||
<Link href={disableNavigation ? "" : "/"} className=" md:px-8 flex gap-8 items-center">
|
||||
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" />
|
||||
<h1 className="font-bold text-2xl w-1/6 -md:hidden">EnCoach</h1>
|
||||
</Link>
|
||||
<div className="flex justify-end -md:items-center gap-4 md:w-5/6 md:mr-8">
|
||||
{showExpirationDate() && (
|
||||
<Link
|
||||
href="/payment"
|
||||
data-tip="Expiry date"
|
||||
className={clsx(
|
||||
"py-2 px-6 w-fit flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"transition duration-300 ease-in-out tooltip tooltip-bottom",
|
||||
!user.subscriptionExpirationDate
|
||||
? "bg-mti-green-ultralight border-mti-green-light"
|
||||
: expirationDateColor(user.subscriptionExpirationDate),
|
||||
"bg-white border-mti-gray-platinum",
|
||||
)}>
|
||||
{!user.subscriptionExpirationDate && "Unlimited"}
|
||||
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
|
||||
</Link>
|
||||
)}
|
||||
<Link href={disableNavigation ? "" : "/profile"} className="flex gap-6 items-center justify-end -md:hidden">
|
||||
<img src={user.profilePicture} alt={user.name} className="w-10 h-10 rounded-full object-cover" />
|
||||
<span className="text-right -md:hidden">{user.name}</span>
|
||||
</Link>
|
||||
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}>
|
||||
<BsList className="text-mti-purple-light w-8 h-8" />
|
||||
</div>
|
||||
</div>
|
||||
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
66
src/components/PayPalPayment.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import {DurationUnit} from "@/interfaces/paypal";
|
||||
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OnCancelledActions, OrderResponseBody} from "@paypal/paypal-js";
|
||||
import {PayPalButtons, PayPalScriptProvider, usePayPalScriptReducer} from "@paypal/react-paypal-js";
|
||||
import axios from "axios";
|
||||
import {useEffect, useState} from "react";
|
||||
import {toast} from "react-toastify";
|
||||
|
||||
interface Props {
|
||||
clientID: string;
|
||||
currency: string;
|
||||
price: number;
|
||||
duration: number;
|
||||
duration_unit: DurationUnit;
|
||||
setIsLoading: (isLoading: boolean) => void;
|
||||
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
|
||||
}
|
||||
|
||||
export default function PayPalPayment({clientID, price, currency, duration, duration_unit, setIsLoading, onSuccess}: Props) {
|
||||
const createOrder = async (data: CreateOrderData, actions: CreateOrderActions): Promise<string> => {
|
||||
setIsLoading(true);
|
||||
|
||||
return axios
|
||||
.post<OrderResponseBody>("/api/paypal", {currencyCode: currency, price})
|
||||
.then((response) => response.data)
|
||||
.then((data) => data.id);
|
||||
};
|
||||
|
||||
const onApprove = async (data: OnApproveData, actions: OnApproveActions) => {
|
||||
const request = await axios.post<{ok: boolean; reason?: string}>("/api/paypal/approve", {id: data.orderID, duration, duration_unit});
|
||||
|
||||
if (request.status !== 200) {
|
||||
toast.error("Something went wrong, please try again later");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Your account has been credited more time!");
|
||||
return onSuccess(duration, duration_unit);
|
||||
};
|
||||
|
||||
const onError = async (data: Record<string, unknown>) => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const onCancel = async (data: Record<string, unknown>, actions: OnCancelledActions) => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<PayPalScriptProvider
|
||||
options={{
|
||||
clientId: clientID,
|
||||
currency,
|
||||
intent: "capture",
|
||||
commit: true,
|
||||
vault: true,
|
||||
}}>
|
||||
<PayPalButtons
|
||||
className="w-full"
|
||||
style={{layout: "vertical"}}
|
||||
createOrder={createOrder}
|
||||
onApprove={onApprove}
|
||||
onCancel={onCancel}
|
||||
onError={onError}></PayPalButtons>
|
||||
</PayPalScriptProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/* 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" />}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/* 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" />}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
86
src/components/ProfileSummary.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
/* 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;
|
||||
}[];
|
||||
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">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
174
src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
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,
|
||||
} 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 {useState} from "react";
|
||||
import usePreferencesStore from "@/stores/preferencesStore";
|
||||
import {Type} from "@/interfaces/user";
|
||||
interface Props {
|
||||
path: string;
|
||||
navDisabled?: boolean;
|
||||
focusMode?: boolean;
|
||||
onFocusLayerMouseEnter?: () => void;
|
||||
className?: string;
|
||||
userType?: Type;
|
||||
}
|
||||
|
||||
interface NavProps {
|
||||
Icon: IconType;
|
||||
label: string;
|
||||
path: string;
|
||||
keyPath: string;
|
||||
disabled?: boolean;
|
||||
isMinimized?: boolean;
|
||||
}
|
||||
|
||||
const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = 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-all duration-300 ease-in-out",
|
||||
path === keyPath && "bg-mti-purple-light text-white",
|
||||
isMinimized ? "w-fit" : "w-full min-w-[200px] 2xl:min-w-[220px] px-8",
|
||||
)}>
|
||||
<Icon size={24} />
|
||||
{!isMinimized && <span className="text-lg font-semibold">{label}</span>}
|
||||
</Link>
|
||||
);
|
||||
|
||||
export default function Sidebar({path, navDisabled = false, focusMode = false, userType, onFocusLayerMouseEnter, className}: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
|
||||
|
||||
const logout = async () => {
|
||||
axios.post("/api/logout").finally(() => {
|
||||
setTimeout(() => router.reload(), 500);
|
||||
});
|
||||
};
|
||||
|
||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||
|
||||
return (
|
||||
<section
|
||||
className={clsx(
|
||||
"h-full flex bg-transparent flex-col justify-between px-4 py-4 pb-8 relative",
|
||||
isMinimized ? "w-fit" : "w-1/6 -xl:w-fit",
|
||||
className,
|
||||
)}>
|
||||
<div className="xl:flex -xl:hidden flex-col gap-3">
|
||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
|
||||
{(userType === "student" || userType === "teacher" || userType === "developer") && (
|
||||
<>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsFileEarmarkText}
|
||||
label="Exams"
|
||||
path={path}
|
||||
keyPath="/exam"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsPencil}
|
||||
label="Exercises"
|
||||
path={path}
|
||||
keyPath="/exercises"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
|
||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
|
||||
{["admin", "developer", "agent"].includes(userType || "") && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCurrencyDollar}
|
||||
label="Payment Record"
|
||||
path={path}
|
||||
keyPath="/payment-record"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{["admin", "developer", "corporate", "teacher"].includes(userType || "") && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsShieldFill}
|
||||
label="Settings"
|
||||
path={path}
|
||||
keyPath="/settings"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{userType === "developer" && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCloudFill}
|
||||
label="Generation"
|
||||
path={path}
|
||||
keyPath="/generation"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="xl:hidden -xl:flex flex-col gap-3">
|
||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} />
|
||||
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} />
|
||||
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
|
||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
|
||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
|
||||
{userType !== "student" && (
|
||||
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized={true} />
|
||||
)}
|
||||
{userType === "developer" && (
|
||||
<Nav disabled={disableNavigation} Icon={BsCloudFill} label="Generation" path={path} keyPath="/generation" isMinimized={true} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-0 absolute bottom-12">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={1}
|
||||
onClick={toggleMinimize}
|
||||
className={clsx(
|
||||
"p-4 rounded-full flex gap-4 items-center cursor-pointer text-black hover:text-mti-rose -xl:hidden 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(
|
||||
"p-4 rounded-full flex gap-4 items-center cursor-pointer text-black hover:text-mti-rose transition duration-300 ease-in-out",
|
||||
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
||||
)}>
|
||||
<RiLogoutBoxFill size={24} />
|
||||
{!isMinimized && <span className="text-lg font-medium -xl:hidden">Log Out</span>}
|
||||
</div>
|
||||
</div>
|
||||
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +1,70 @@
|
||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import {FillBlanksExercise} from "@/interfaces/exam";
|
||||
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import clsx from "clsx";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import {CommonProps} from ".";
|
||||
import {Fragment} from "react";
|
||||
import Button from "../Low/Button";
|
||||
|
||||
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) => solutions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false,
|
||||
).length;
|
||||
const missing = total - userSolutions.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
|
||||
|
||||
return {total, correct, missing};
|
||||
};
|
||||
|
||||
export default function FillBlanksSolutions({prompt, solutions, text, userSolutions, onNext, onBack}: FillBlanksExercise & CommonProps) {
|
||||
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)!;
|
||||
|
||||
if (!userSolution) {
|
||||
return (
|
||||
<>
|
||||
<button className={clsx("border-2 rounded-xl px-4 text-gray-500 border-gray-500 my-2")}>{solution.solution}</button>
|
||||
</>
|
||||
<button
|
||||
className={clsx(
|
||||
"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}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (userSolution.solution === solution.solution) {
|
||||
return <button className={clsx("border-2 rounded-xl px-4 text-green-500 border-green-500 my-2")}>{solution.solution}</button>;
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
|
||||
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
|
||||
)}>
|
||||
{solution.solution}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (userSolution.solution !== solution.solution) {
|
||||
return (
|
||||
<>
|
||||
<button className={clsx("border-2 rounded-xl px-4 text-red-500 border-red-500 mr-1 my-2")}>
|
||||
<button
|
||||
className={clsx(
|
||||
"rounded-full hover:text-white hover:bg-mti-rose transition duration-300 ease-in-out my-1 mr-1",
|
||||
userSolution && "px-5 py-2 text-center text-white bg-mti-rose-light",
|
||||
)}>
|
||||
{userSolution.solution}
|
||||
</button>
|
||||
<button className={clsx("border-2 rounded-xl px-4 text-green-400 border-green-400 my-2")}>{solution.solution}</button>
|
||||
|
||||
<button
|
||||
className={clsx(
|
||||
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
|
||||
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
|
||||
)}>
|
||||
{solution.solution}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -45,8 +75,8 @@ export default function FillBlanksSolutions({prompt, solutions, text, userSoluti
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="text-base md:text-lg font-medium text-center px-2 md:px-4 lg:px-48">
|
||||
<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}>
|
||||
{line}
|
||||
@@ -54,29 +84,46 @@ export default function FillBlanksSolutions({prompt, solutions, text, userSoluti
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
<span>
|
||||
{text.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
{renderLines(line)}
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
<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>
|
||||
))}
|
||||
</span>
|
||||
<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-red" />
|
||||
Unanswered
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-4 h-4 rounded-full bg-mti-rose" />
|
||||
Wrong
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
|
||||
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
|
||||
<div className="absolute left-4">
|
||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
||||
</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: calculateScore(), type})}
|
||||
className="max-w-[200px] w-full">
|
||||
Back
|
||||
</button>
|
||||
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={onNext}>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color="purple"
|
||||
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
<div className="absolute right-4">
|
||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
||||
</div>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
198
src/components/Solutions/InteractiveSpeaking.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
/* 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";
|
||||
|
||||
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[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userSolutions && userSolutions.length > 0) {
|
||||
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 (
|
||||
<>
|
||||
<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-col gap-8 items-center">
|
||||
<div className="flex gap-8 items-center justify-center py-8">
|
||||
<Waveform audio={x} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||
</div>
|
||||
</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) => (
|
||||
<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>
|
||||
{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.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!.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!.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!.replaceAll(/\s{2,}/g, "\n\n")}
|
||||
</span>
|
||||
</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: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
||||
type,
|
||||
})
|
||||
}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,12 +6,33 @@ import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import {Fragment} from "react";
|
||||
import Button from "../Low/Button";
|
||||
import Xarrow from "react-xarrows";
|
||||
|
||||
export default function MatchSentencesSolutions({
|
||||
id,
|
||||
type,
|
||||
options,
|
||||
prompt,
|
||||
sentences,
|
||||
userSolutions,
|
||||
onNext,
|
||||
onBack,
|
||||
}: 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;
|
||||
|
||||
return {total, correct, missing};
|
||||
};
|
||||
|
||||
export default function MatchSentencesSolutions({options, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-8">
|
||||
<span className="text-base md:text-lg font-medium text-center px-2 md:px-4 lg:px-48">
|
||||
<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}>
|
||||
{line}
|
||||
@@ -19,63 +40,85 @@ export default function MatchSentencesSolutions({options, prompt, sentences, use
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
|
||||
<div className="grid grid-cols-2 gap-16 place-items-center">
|
||||
<div className="flex flex-col gap-1">
|
||||
{sentences.map(({sentence, id, color, solution}) => (
|
||||
<div
|
||||
key={`question_${id}`}
|
||||
className={clsx(
|
||||
"flex items-center justify-end gap-2 cursor-pointer",
|
||||
userSolutions.find((x) => x.question === id)?.option === solution ? "text-green-500" : "text-red-500",
|
||||
)}>
|
||||
<span>
|
||||
<span className="font-semibold">{id}.</span> {sentence}{" "}
|
||||
</span>
|
||||
<div style={{borderColor: color}} className={clsx("border-2 border-blue-500 w-4 h-4 rounded-full", id)} />
|
||||
<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, 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.toString() === id.toString()) && "!bg-mti-red",
|
||||
userSolutions.find((x) => x.question.toString() === id.toString())?.option === solution && "bg-mti-purple",
|
||||
userSolutions.find((x) => x.question.toString() === id.toString())?.option !== solution && "bg-mti-rose",
|
||||
)}>
|
||||
{id}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<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")}>
|
||||
<div
|
||||
style={
|
||||
sentences.find((x) => x.solution === id)
|
||||
? {
|
||||
border: `2px solid ${sentences.find((x) => x.solution === id)!.color}`,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
className={clsx("border-2 border-green-500 bg-transparent w-4 h-4 rounded-full", id)}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-semibold">{id}.</span> {sentence}{" "}
|
||||
</span>
|
||||
<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) => (
|
||||
<div key={`solution_${index}`} className="absolute">
|
||||
<LineTo className="rounded-full" from={sentence.id} to={sentence.solution} borderColor={sentence.color} borderWidth={5} />
|
||||
</div>
|
||||
))}
|
||||
{userSolutions &&
|
||||
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-red" /> Unanswered
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-4 h-4 rounded-full bg-mti-rose" /> Wrong
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
|
||||
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
|
||||
<div className="absolute left-4">
|
||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
||||
</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: calculateScore(), type})}
|
||||
className="max-w-[200px] w-full">
|
||||
Back
|
||||
</button>
|
||||
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={onNext}>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color="purple"
|
||||
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
<div className="absolute right-4">
|
||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
||||
</div>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
|
||||
import {mdiArrowLeft, mdiArrowRight, mdiCheck, mdiClose} from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import clsx from "clsx";
|
||||
import {useState} from "react";
|
||||
import {useEffect, useState} from "react";
|
||||
import {CommonProps} from ".";
|
||||
import Button from "../Low/Button";
|
||||
|
||||
function Question({
|
||||
variant,
|
||||
@@ -13,41 +11,17 @@ function Question({
|
||||
solution,
|
||||
options,
|
||||
userSolution,
|
||||
onSelectOption,
|
||||
showSolution = false,
|
||||
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
|
||||
const optionColor = (option: string) => {
|
||||
if (!showSolution) {
|
||||
return userSolution === option ? "border-blue-400" : "";
|
||||
if (option === solution && !userSolution) {
|
||||
return "!border-mti-red-light !text-mti-red-light";
|
||||
}
|
||||
|
||||
if (option === solution) {
|
||||
return "border-green-500 text-green-500";
|
||||
return "!border-mti-purple-light !text-mti-purple-light";
|
||||
}
|
||||
|
||||
return userSolution === option ? "border-red-500 text-red-500" : "";
|
||||
};
|
||||
|
||||
const optionBadge = (option: string) => {
|
||||
if (option === userSolution) {
|
||||
if (solution === option) {
|
||||
return (
|
||||
<div className="badge badge-lg bg-green-500 border-green-500 absolute -top-2 -right-4">
|
||||
<div className="tooltip" data-tip="You have correctly answered!">
|
||||
<Icon path={mdiCheck} color="white" size={0.8} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="badge badge-lg bg-red-500 border-red-500 absolute -top-2 -right-4">
|
||||
<div className="tooltip" data-tip="You have wrongly answered!">
|
||||
<Icon path={mdiClose} color="white" size={0.8} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return userSolution === option ? "!border-mti-rose-light !text-mti-rose-light" : "";
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -58,24 +32,20 @@ function Question({
|
||||
options.map((option) => (
|
||||
<div
|
||||
key={option.id}
|
||||
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
|
||||
className={clsx(
|
||||
"flex flex-col items-center border-2 p-4 rounded-xl gap-4 cursor-pointer bg-white relative",
|
||||
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative",
|
||||
optionColor(option.id),
|
||||
)}>
|
||||
{showSolution && optionBadge(option.id)}
|
||||
<span className={clsx("text-sm", solution !== option.id && userSolution !== option.id && "opacity-50")}>{option.id}</span>
|
||||
<img src={option.src!} alt={`Option ${option.id}`} />
|
||||
<span>{option.id}</span>
|
||||
</div>
|
||||
))}
|
||||
{variant === "text" &&
|
||||
options.map((option) => (
|
||||
<div
|
||||
key={option.id}
|
||||
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
|
||||
className={clsx("flex border-2 p-4 rounded-xl gap-2 cursor-pointer bg-white", optionColor(option.id))}>
|
||||
{showSolution && optionBadge(option.id)}
|
||||
<span className="font-bold">{option.id}.</span>
|
||||
className={clsx("flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm", optionColor(option.id))}>
|
||||
<span className="font-semibold">{option.id}.</span>
|
||||
<span>{option.text}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -84,12 +54,35 @@ function Question({
|
||||
);
|
||||
}
|
||||
|
||||
export default function MultipleChoice({prompt, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
|
||||
export default function MultipleChoice({
|
||||
id,
|
||||
type,
|
||||
prompt,
|
||||
questions,
|
||||
userSolutions,
|
||||
updateIndex,
|
||||
onNext,
|
||||
onBack,
|
||||
}: MultipleChoiceExercise & CommonProps) {
|
||||
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;
|
||||
|
||||
return {total, correct, missing};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (updateIndex) updateIndex(questionIndex);
|
||||
}, [questionIndex, updateIndex]);
|
||||
|
||||
const next = () => {
|
||||
if (questionIndex === questions.length - 1) {
|
||||
onNext();
|
||||
onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
|
||||
} else {
|
||||
setQuestionIndex((prev) => prev + 1);
|
||||
}
|
||||
@@ -97,7 +90,7 @@ export default function MultipleChoice({prompt, questions, userSolutions, onNext
|
||||
|
||||
const back = () => {
|
||||
if (questionIndex === 0) {
|
||||
onBack();
|
||||
onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type});
|
||||
} else {
|
||||
setQuestionIndex((prev) => prev - 1);
|
||||
}
|
||||
@@ -105,30 +98,40 @@ export default function MultipleChoice({prompt, questions, userSolutions, onNext
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<span className="text-base md:text-lg font-medium text-center px-2 md:px-4 lg:px-48">{prompt}</span>
|
||||
{questionIndex < questions.length && (
|
||||
<Question
|
||||
{...questions[questionIndex]}
|
||||
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option}
|
||||
showSolution
|
||||
/>
|
||||
)}
|
||||
<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 && (
|
||||
<Question
|
||||
{...questions[questionIndex]}
|
||||
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option}
|
||||
/>
|
||||
)}
|
||||
</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-red" />
|
||||
Unanswered
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-4 h-4 rounded-full bg-mti-rose" />
|
||||
Wrong
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
|
||||
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={back}>
|
||||
<div className="absolute left-4">
|
||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
||||
</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={back} className="max-w-[200px] w-full">
|
||||
Back
|
||||
</button>
|
||||
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={next}>
|
||||
</Button>
|
||||
|
||||
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
<div className="absolute right-4">
|
||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
||||
</div>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
161
src/components/Solutions/Speaking.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {SpeakingExercise} from "@/interfaces/exam";
|
||||
import {CommonProps} from ".";
|
||||
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";
|
||||
|
||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||
|
||||
export default function Speaking({id, type, title, video_url, text, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
|
||||
const [solutionURL, setSolutionURL] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (userSolutions && userSolutions.length > 0) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
}, [userSolutions]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<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 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>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<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" />}
|
||||
</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) => (
|
||||
<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>
|
||||
{userSolutions[0].evaluation && userSolutions[0].evaluation.perfect_answer ? (
|
||||
<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.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.replaceAll(/\s{2,}/g, "\n\n")}
|
||||
</span>
|
||||
</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: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
||||
type,
|
||||
})
|
||||
}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
136
src/components/Solutions/TrueFalse.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import {FillBlanksExercise, TrueFalseExercise} from "@/interfaces/exam";
|
||||
import clsx from "clsx";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import {CommonProps} from ".";
|
||||
import {Fragment} from "react";
|
||||
import Button from "../Low/Button";
|
||||
|
||||
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;
|
||||
|
||||
return {total, correct, missing};
|
||||
};
|
||||
|
||||
const getButtonColor = (buttonSolution: Solution, solution: Solution, userSolution: Solution | undefined) => {
|
||||
if (buttonSolution !== userSolution && buttonSolution !== solution) return "purple";
|
||||
|
||||
if (userSolution) {
|
||||
if (userSolution === buttonSolution && solution === buttonSolution) {
|
||||
return "purple";
|
||||
}
|
||||
|
||||
if (solution === buttonSolution) {
|
||||
return "purple";
|
||||
}
|
||||
|
||||
return "rose";
|
||||
}
|
||||
|
||||
return "red";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<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}>
|
||||
{line}
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
<div className="flex flex-col gap-6 mb-4">
|
||||
<p>For each of the questions below, select</p>
|
||||
<div className="pl-8 flex gap-8">
|
||||
<span className="flex flex-col gap-4">
|
||||
<span className="font-bold italic">TRUE</span>
|
||||
<span className="font-bold italic">FALSE</span>
|
||||
<span className="font-bold italic">NOT GIVEN</span>
|
||||
</span>
|
||||
<span className="flex flex-col gap-4">
|
||||
<span>if the statement agrees with the information</span>
|
||||
<span>if the statement contradicts with the information</span>
|
||||
<span>if there is no information on this</span>
|
||||
</span>
|
||||
</div>
|
||||
</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());
|
||||
|
||||
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={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">
|
||||
<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-red" />
|
||||
Unanswered
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-4 h-4 rounded-full bg-mti-rose" />
|
||||
Wrong
|
||||
</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: calculateScore(), type})}
|
||||
className="max-w-[200px] w-full">
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color="purple"
|
||||
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,11 @@ import {WriteBlanksExercise} from "@/interfaces/exam";
|
||||
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import clsx from "clsx";
|
||||
import {useEffect, useState} from "react";
|
||||
import {Fragment, useEffect, useState} from "react";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import {CommonProps} from ".";
|
||||
import {toast} from "react-toastify";
|
||||
import Button from "../Low/Button";
|
||||
|
||||
function Blank({
|
||||
id,
|
||||
@@ -17,7 +18,7 @@ function Blank({
|
||||
setUserSolution,
|
||||
}: {
|
||||
id: string;
|
||||
solutions?: string[];
|
||||
solutions: string[];
|
||||
userSolution?: string;
|
||||
maxWords: number;
|
||||
disabled?: boolean;
|
||||
@@ -28,33 +29,44 @@ 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());
|
||||
}
|
||||
}, [maxWords, userInput, setUserSolution]);
|
||||
|
||||
const isUserSolutionCorrect = () => userSolution && solutions.map((x) => x.trim().toLowerCase()).includes(userSolution.trim().toLowerCase());
|
||||
|
||||
const getSolutionStyling = () => {
|
||||
if (solutions && userSolution) {
|
||||
if (solutions.map((x) => x.trim().toLowerCase()).includes(userSolution.trim().toLowerCase())) return "text-green-500 border-green-500";
|
||||
if (!userSolution) {
|
||||
return "bg-mti-red-ultralight text-mti-red-light";
|
||||
}
|
||||
|
||||
return "text-red-500 border-red-500";
|
||||
return "bg-mti-purple-ultralight text-mti-purple-light";
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
className={clsx("input border rounded-xl px-2 py-1 bg-white my-2", !solutions && "text-blue-400 border-blue-400", getSolutionStyling())}
|
||||
placeholder={id}
|
||||
onChange={(e) => setUserInput(e.target.value)}
|
||||
value={!solutions ? userInput : solutions.join(" / ")}
|
||||
contentEditable={disabled}
|
||||
/>
|
||||
<span className="inline-flex gap-2 ml-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"
|
||||
placeholder={id}
|
||||
contentEditable={disabled}>
|
||||
{userSolution}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={clsx("py-2 px-3 rounded-2xl w-fit focus:outline-none my-2", getSolutionStyling())}
|
||||
placeholder={id}
|
||||
contentEditable={disabled}>
|
||||
{!solutions ? userInput : solutions.join(" / ")}
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WriteBlanksSolutions({
|
||||
id,
|
||||
type,
|
||||
prompt,
|
||||
maxWords,
|
||||
solutions,
|
||||
@@ -63,15 +75,31 @@ export default function WriteBlanksSolutions({
|
||||
onNext,
|
||||
onBack,
|
||||
}: WriteBlanksExercise & CommonProps) {
|
||||
const calculateScore = () => {
|
||||
const total = text.match(/({{\d+}})/g)?.length || 0;
|
||||
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,
|
||||
).length;
|
||||
const missing = total - userSolutions.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
|
||||
|
||||
return {total, correct, missing};
|
||||
};
|
||||
|
||||
const renderLines = (line: string) => {
|
||||
return (
|
||||
<span>
|
||||
<span className="text-base leading-5">
|
||||
{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)!;
|
||||
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())!;
|
||||
|
||||
return <Blank userSolution={userSolution?.solution} maxWords={maxWords} id={id} solutions={solution.solution} disabled />;
|
||||
return (
|
||||
<Blank userSolution={userSolution?.solution} maxWords={maxWords} id={id.toString()} solutions={solution.solution} disabled />
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
@@ -79,31 +107,55 @@ export default function WriteBlanksSolutions({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-lg font-medium text-center px-48">{prompt}</span>
|
||||
<span>
|
||||
{text.split("\\n").map((line) => (
|
||||
<>
|
||||
{renderLines(line)}
|
||||
<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}>
|
||||
{line}
|
||||
<br />
|
||||
</>
|
||||
</Fragment>
|
||||
))}
|
||||
</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>
|
||||
))}
|
||||
</span>
|
||||
<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-red" />
|
||||
Unanswered
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-4 h-4 rounded-full bg-mti-rose" />
|
||||
Wrong
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
|
||||
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
|
||||
<div className="absolute left-4">
|
||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
||||
</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: calculateScore(), type})}
|
||||
className="max-w-[200px] w-full">
|
||||
Back
|
||||
</button>
|
||||
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={onNext}>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color="purple"
|
||||
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
<div className="absolute right-4">
|
||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
||||
</div>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
165
src/components/Solutions/Writing.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {WritingExercise} from "@/interfaces/exam";
|
||||
import {CommonProps} from ".";
|
||||
import {Fragment, useState} from "react";
|
||||
import Button from "../Low/Button";
|
||||
import {Dialog, Tab, Transition} from "@headlessui/react";
|
||||
import {writingReverseMarking} from "@/utils/score";
|
||||
import clsx from "clsx";
|
||||
|
||||
export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{attachment && (
|
||||
<Transition show={isModalOpen} as={Fragment}>
|
||||
<Dialog onClose={() => setIsModalOpen(false)} 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-fit h-fit rounded-xl bg-white">
|
||||
<img src={attachment.url} alt={attachment.description} className="max-w-4xl w-full self-center rounded-xl p-4" />
|
||||
</Dialog.Panel>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
)}
|
||||
<div className="flex flex-col h-full w-full gap-8 mb-20">
|
||||
<div className="flex w-full gap-7 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
||||
<span className="font-semibold">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
<p>{line}</p>
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
{attachment && (
|
||||
<img
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
src={attachment.url}
|
||||
alt={attachment.description}
|
||||
className="max-w-[200px] self-center rounded-xl cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full h-full flex flex-col gap-8">
|
||||
{userSolutions && (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<span>Your answer:</span>
|
||||
<textarea
|
||||
className="w-full h-full min-h-[320px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
|
||||
contentEditable={false}
|
||||
readOnly
|
||||
value={userSolutions[0]!.solution}
|
||||
/>
|
||||
</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) => (
|
||||
<div className="bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2" key={key}>
|
||||
{key}: Level {userSolutions[0].evaluation!.task_response[key]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{userSolutions[0].evaluation && userSolutions[0].evaluation.perfect_answer ? (
|
||||
<Tab.Group>
|
||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1">
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
|
||||
)
|
||||
}>
|
||||
Evaluation
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
|
||||
)
|
||||
}>
|
||||
Recommended Answer
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<Tab.Panel className="w-full bg-ielts-writing/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-writing/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.replaceAll(/\s{2,}/g, "\n\n")}
|
||||
</span>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
) : (
|
||||
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-ielts-writing/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: writingReverseMarking[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: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
||||
type,
|
||||
})
|
||||
}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,49 @@
|
||||
import {Exercise, FillBlanksExercise, MatchSentencesExercise, MultipleChoiceExercise, WriteBlanksExercise} from "@/interfaces/exam";
|
||||
import {
|
||||
Exercise,
|
||||
FillBlanksExercise,
|
||||
InteractiveSpeakingExercise,
|
||||
MatchSentencesExercise,
|
||||
MultipleChoiceExercise,
|
||||
SpeakingExercise,
|
||||
TrueFalseExercise,
|
||||
UserSolution,
|
||||
WriteBlanksExercise,
|
||||
WritingExercise,
|
||||
} from "@/interfaces/exam";
|
||||
import dynamic from "next/dynamic";
|
||||
import FillBlanks from "./FillBlanks";
|
||||
import InteractiveSpeaking from "./InteractiveSpeaking";
|
||||
import MultipleChoice from "./MultipleChoice";
|
||||
import Speaking from "./Speaking";
|
||||
import TrueFalseSolution from "./TrueFalse";
|
||||
import WriteBlanks from "./WriteBlanks";
|
||||
import Writing from "./Writing";
|
||||
|
||||
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), {ssr: false});
|
||||
|
||||
export interface CommonProps {
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
updateIndex?: (internalIndex: number) => void;
|
||||
onNext: (userSolutions: UserSolution) => void;
|
||||
onBack: (userSolutions: UserSolution) => void;
|
||||
}
|
||||
|
||||
export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: () => void) => {
|
||||
export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: () => void, updateIndex?: (internalIndex: number) => void) => {
|
||||
switch (exercise.type) {
|
||||
case "fillBlanks":
|
||||
return <FillBlanks {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "trueFalse":
|
||||
return <TrueFalseSolution {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "matchSentences":
|
||||
return <MatchSentences {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "multipleChoice":
|
||||
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} updateIndex={updateIndex} onNext={onNext} onBack={onBack} />;
|
||||
case "writeBlanks":
|
||||
return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "writing":
|
||||
return <Writing {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "speaking":
|
||||
return <Speaking {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "interactiveSpeaking":
|
||||
return <InteractiveSpeaking {...(exercise as InteractiveSpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
}
|
||||
};
|
||||
|
||||
49
src/components/TimerEndedModal.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import {Dialog, Transition} from "@headlessui/react";
|
||||
import {Fragment} from "react";
|
||||
import Button from "./Low/Button";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function TimerEndedModal({isOpen, onClose}: Props) {
|
||||
return (
|
||||
<Transition show={isOpen} as={Fragment}>
|
||||
<Dialog onClose={onClose} 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">Time's up!</Dialog.Title>
|
||||
<span>
|
||||
The timer has ended! Your answers have been registered and saved, you will now move on to the next module (or to the
|
||||
finish screen, if this was the last one).
|
||||
</span>
|
||||
<Button color="purple" onClick={onClose} className="max-w-[200px] self-end w-full mt-8">
|
||||
Continue
|
||||
</Button>
|
||||
</Dialog.Panel>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
489
src/components/UserCard.tsx
Normal file
@@ -0,0 +1,489 @@
|
||||
import useStats from "@/hooks/useStats";
|
||||
import {EMPLOYMENT_STATUS, User} from "@/interfaces/user";
|
||||
import {groupBySession, averageScore} from "@/utils/stats";
|
||||
import {RadioGroup} from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import moment from "moment";
|
||||
import {Divider} from "primereact/divider";
|
||||
import {useEffect, useState} from "react";
|
||||
import ReactDatePicker from "react-datepicker";
|
||||
import {BsFileEarmarkText, BsPencil, BsStar} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import Button from "./Low/Button";
|
||||
import Checkbox from "./Low/Checkbox";
|
||||
import CountrySelect from "./Low/CountrySelect";
|
||||
import Input from "./Low/Input";
|
||||
import ProfileSummary from "./ProfileSummary";
|
||||
import Select from "react-select";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||
import {CURRENCIES} from "@/resources/paypal";
|
||||
|
||||
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";
|
||||
};
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
loggedInUser: User;
|
||||
onClose: (reload?: boolean) => void;
|
||||
onViewStudents?: () => void;
|
||||
onViewTeachers?: () => void;
|
||||
onViewCorporate?: () => void;
|
||||
}
|
||||
|
||||
const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers, onViewCorporate}: Props) => {
|
||||
const [expiryDate, setExpiryDate] = useState<Date | null | undefined>(user.subscriptionExpirationDate);
|
||||
const [type, setType] = useState(user.type);
|
||||
const [status, setStatus] = useState(user.status);
|
||||
const [referralAgentLabel, setReferralAgentLabel] = useState<string>();
|
||||
const [position, setPosition] = useState<string | undefined>(user.type === "corporate" ? user.demographicInformation?.position : undefined);
|
||||
|
||||
const [referralAgent, setReferralAgent] = useState(user.type === "corporate" ? user.corporateInformation?.referralAgent : undefined);
|
||||
const [companyName, setCompanyName] = useState(
|
||||
user.type === "corporate"
|
||||
? user.corporateInformation?.companyInformation.name
|
||||
: user.type === "agent"
|
||||
? user.agentInformation.companyName
|
||||
: undefined,
|
||||
);
|
||||
const [commercialRegistration, setCommercialRegistration] = useState(
|
||||
user.type === "agent" ? user.agentInformation.commercialRegistration : undefined,
|
||||
);
|
||||
const [userAmount, setUserAmount] = useState(user.type === "corporate" ? user.corporateInformation?.companyInformation.userAmount : undefined);
|
||||
const [paymentValue, setPaymentValue] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.value : undefined);
|
||||
const [paymentCurrency, setPaymentCurrency] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.currency : "EUR");
|
||||
const [monthlyDuration, setMonthlyDuration] = useState(user.type === "corporate" ? user.corporateInformation?.monthlyDuration : undefined);
|
||||
|
||||
const {stats} = useStats(user.id);
|
||||
const {users} = useUsers();
|
||||
|
||||
useEffect(() => {
|
||||
if (users && users.length > 0) {
|
||||
if (!referralAgent) {
|
||||
setReferralAgentLabel("No manager");
|
||||
return;
|
||||
}
|
||||
|
||||
const agent = users.find((x) => x.id === referralAgent);
|
||||
setReferralAgentLabel(`${agent?.name} - ${agent?.email}`);
|
||||
}
|
||||
}, [users, referralAgent]);
|
||||
|
||||
const updateUser = () => {
|
||||
if (user.type === "corporate" && (!paymentValue || paymentValue < 0))
|
||||
return toast.error("Please set a price for the user's package before updating!");
|
||||
if (!confirm(`Are you sure you want to update ${user.name}'s account?`)) return;
|
||||
|
||||
axios
|
||||
.post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {
|
||||
...user,
|
||||
subscriptionExpirationDate: expiryDate,
|
||||
type,
|
||||
status,
|
||||
agentInformation:
|
||||
type === "agent"
|
||||
? {
|
||||
companyName,
|
||||
commercialRegistration,
|
||||
}
|
||||
: undefined,
|
||||
corporateInformation:
|
||||
type === "corporate"
|
||||
? {
|
||||
referralAgent,
|
||||
monthlyDuration,
|
||||
companyInformation: {
|
||||
companyName,
|
||||
userAmount,
|
||||
},
|
||||
payment: {
|
||||
value: paymentValue,
|
||||
currency: paymentCurrency,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("User updated successfully!");
|
||||
onClose(true);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong!", {toastId: "update-error"});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProfileSummary
|
||||
user={user}
|
||||
items={[
|
||||
{
|
||||
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||
value: Object.keys(groupBySession(stats)).length,
|
||||
label: "Exams",
|
||||
},
|
||||
{
|
||||
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||
value: stats.length,
|
||||
label: "Exercises",
|
||||
},
|
||||
{
|
||||
icon: <BsStar className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
|
||||
label: "Average Score",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{user.type === "agent" && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full">
|
||||
<Input
|
||||
label="Corporate Name"
|
||||
type="text"
|
||||
name="companyName"
|
||||
onChange={setCompanyName}
|
||||
placeholder="Enter corporate name"
|
||||
defaultValue={companyName}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Commercial Registration"
|
||||
type="text"
|
||||
name="commercialRegistration"
|
||||
onChange={setCommercialRegistration}
|
||||
placeholder="Enter commercial registration"
|
||||
defaultValue={commercialRegistration}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Divider className="w-full !m-0" />
|
||||
</>
|
||||
)}
|
||||
{user.type === "corporate" && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 w-full">
|
||||
<Input
|
||||
label="Corporate Name"
|
||||
type="text"
|
||||
name="companyName"
|
||||
onChange={setCompanyName}
|
||||
placeholder="Enter corporate name"
|
||||
defaultValue={companyName}
|
||||
/>
|
||||
<Input
|
||||
label="Number of Users"
|
||||
type="number"
|
||||
name="userAmount"
|
||||
onChange={(e) => setUserAmount(e ? parseInt(e) : undefined)}
|
||||
placeholder="Enter number of users"
|
||||
defaultValue={userAmount}
|
||||
/>
|
||||
<Input
|
||||
label="Monthly Duration"
|
||||
type="number"
|
||||
name="monthlyDuration"
|
||||
onChange={(e) => setMonthlyDuration(e ? parseInt(e) : undefined)}
|
||||
placeholder="Enter monthly duration"
|
||||
defaultValue={monthlyDuration}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Country Manager</label>
|
||||
{referralAgentLabel && (
|
||||
<Select
|
||||
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||
options={[
|
||||
{value: "", label: "No referral"},
|
||||
...users.filter((u) => u.type === "agent").map((x) => ({value: x.id, label: `${x.name} - ${x.email}`})),
|
||||
]}
|
||||
defaultValue={{
|
||||
value: referralAgent,
|
||||
label: referralAgentLabel,
|
||||
}}
|
||||
onChange={(value) => setReferralAgent(value?.value)}
|
||||
styles={{
|
||||
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,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 w-full lg:col-span-2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Pricing</label>
|
||||
<div className="w-full grid grid-cols-5 gap-2">
|
||||
<Input
|
||||
name="paymentValue"
|
||||
onChange={(e) => setPaymentValue(e ? parseInt(e) : undefined)}
|
||||
type="number"
|
||||
defaultValue={paymentValue || 0}
|
||||
className="col-span-3"
|
||||
/>
|
||||
<select
|
||||
defaultValue={paymentCurrency}
|
||||
onChange={(e) => setPaymentCurrency(e.target.value)}
|
||||
className="p-6 col-span-2 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
||||
{CURRENCIES.map(({label, currency}) => (
|
||||
<option value={currency} key={currency}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Divider className="w-full !m-0" />
|
||||
</>
|
||||
)}
|
||||
<section className="flex flex-col gap-4 justify-between">
|
||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||
<Input
|
||||
label="Name"
|
||||
type="text"
|
||||
name="name"
|
||||
onChange={() => null}
|
||||
placeholder="Enter your name"
|
||||
defaultValue={user.name}
|
||||
disabled
|
||||
/>
|
||||
<Input
|
||||
label="E-mail Address"
|
||||
type="email"
|
||||
name="email"
|
||||
onChange={() => null}
|
||||
placeholder="Enter email address"
|
||||
defaultValue={user.email}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Country</label>
|
||||
<CountrySelect disabled value={user.demographicInformation?.country} />
|
||||
</div>
|
||||
<Input
|
||||
type="tel"
|
||||
name="phone"
|
||||
label="Phone number"
|
||||
onChange={() => null}
|
||||
placeholder="Enter phone number"
|
||||
defaultValue={user.demographicInformation?.phone}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||
{user.type !== "corporate" && (
|
||||
<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={user.demographicInformation?.employment}
|
||||
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>
|
||||
)}
|
||||
{user.type === "corporate" && (
|
||||
<Input
|
||||
name="position"
|
||||
onChange={setPosition}
|
||||
type="text"
|
||||
label="Position"
|
||||
defaultValue={position}
|
||||
placeholder="CEO, Head of Marketing..."
|
||||
disabled
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-8 w-full">
|
||||
<div className="relative flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Gender</label>
|
||||
<RadioGroup value={user.demographicInformation?.gender} 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>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
|
||||
<Checkbox
|
||||
isChecked={!!expiryDate}
|
||||
onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : null)}>
|
||||
Enabled
|
||||
</Checkbox>
|
||||
</div>
|
||||
{!expiryDate && (
|
||||
<div
|
||||
className={clsx(
|
||||
"p-6 w-full flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"transition duration-300 ease-in-out",
|
||||
!expiryDate ? "!bg-mti-green-ultralight !border-mti-green-light" : expirationDateColor(expiryDate),
|
||||
"bg-white border-mti-gray-platinum",
|
||||
)}>
|
||||
{!expiryDate && "Unlimited"}
|
||||
{expiryDate && moment(expiryDate).format("DD/MM/YYYY")}
|
||||
</div>
|
||||
)}
|
||||
{expiryDate && (
|
||||
<ReactDatePicker
|
||||
className={clsx(
|
||||
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"hover:border-mti-purple tooltip",
|
||||
expirationDateColor(expiryDate),
|
||||
"transition duration-300 ease-in-out",
|
||||
)}
|
||||
filterDate={(date) =>
|
||||
moment(date).isAfter(new Date()) &&
|
||||
(loggedInUser.subscriptionExpirationDate
|
||||
? moment(date).isBefore(moment(loggedInUser.subscriptionExpirationDate))
|
||||
: true)
|
||||
}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={moment(expiryDate).toDate()}
|
||||
onChange={(date) => setExpiryDate(date)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(loggedInUser.type === "developer" || loggedInUser.type === "admin") && (
|
||||
<>
|
||||
<Divider className="w-full !m-0" />
|
||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Status</label>
|
||||
<select
|
||||
defaultValue={user.status}
|
||||
onChange={(e) => setStatus(e.target.value as typeof user.status)}
|
||||
className="p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
||||
<option value="active">Active</option>
|
||||
<option value="disabled">Disabled</option>
|
||||
<option value="paymentDue">Payment Due</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Type</label>
|
||||
<select
|
||||
defaultValue={user.type}
|
||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||
className="p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
||||
{Object.keys(USER_TYPE_LABELS).map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="flex gap-4 justify-between mt-4 w-full">
|
||||
<div className="self-start flex gap-4 justify-start items-center w-full">
|
||||
{onViewCorporate && (
|
||||
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewCorporate}>
|
||||
View Corporate
|
||||
</Button>
|
||||
)}
|
||||
{onViewStudents && (
|
||||
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewStudents}>
|
||||
View Students
|
||||
</Button>
|
||||
)}
|
||||
{onViewTeachers && (
|
||||
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewTeachers}>
|
||||
View Teachers
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="self-end flex gap-4 w-full justify-end">
|
||||
<Button className="w-full max-w-[200px]" variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={updateUser} className="w-full max-w-[200px]">
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserCard;
|
||||
@@ -1,31 +0,0 @@
|
||||
import {SEMI_TRANSPARENT} from "@/resources/colors";
|
||||
import {Chart as ChartJS, RadialLinearScale, ArcElement, Tooltip, Legend} from "chart.js";
|
||||
import clsx from "clsx";
|
||||
import {PolarArea} from "react-chartjs-2";
|
||||
import {Chart} from "primereact/chart";
|
||||
|
||||
interface Props {
|
||||
data: {label: string; value: number}[];
|
||||
label?: string;
|
||||
title: string;
|
||||
type: string;
|
||||
colors?: string[];
|
||||
}
|
||||
|
||||
ChartJS.register(RadialLinearScale, ArcElement, Tooltip, Legend);
|
||||
|
||||
export default function SingleDatasetChart({data, type, label, title, colors = Object.values(SEMI_TRANSPARENT)}: Props) {
|
||||
const labels = data.map((x) => x.label);
|
||||
const chartData = {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label,
|
||||
data: data.map((x) => x.value),
|
||||
backgroundColor: colors,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return <Chart type={type} data={chartData} options={{plugins: {title: {text: title, display: true}}}} />;
|
||||
}
|
||||
72
src/components/Waveform.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React, {useEffect, useRef, useState} from "react";
|
||||
import {BsPauseFill, BsPlayFill} from "react-icons/bs";
|
||||
import WaveSurfer from "wavesurfer.js";
|
||||
|
||||
interface Props {
|
||||
audio: string;
|
||||
waveColor: string;
|
||||
progressColor: string;
|
||||
}
|
||||
|
||||
const Waveform = ({audio, waveColor, progressColor}: Props) => {
|
||||
const containerRef = useRef(null);
|
||||
const waveSurferRef = useRef<WaveSurfer | null>();
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const waveSurfer = WaveSurfer.create({
|
||||
container: containerRef?.current || "",
|
||||
responsive: true,
|
||||
cursorWidth: 0,
|
||||
height: 24,
|
||||
waveColor,
|
||||
progressColor,
|
||||
barGap: 5,
|
||||
barWidth: 8,
|
||||
barRadius: 4,
|
||||
fillParent: true,
|
||||
hideScrollbar: true,
|
||||
normalize: true,
|
||||
autoCenter: true,
|
||||
ignoreSilenceMode: true,
|
||||
barMinHeight: 4,
|
||||
});
|
||||
waveSurfer.load(audio);
|
||||
|
||||
waveSurfer.on("ready", () => {
|
||||
waveSurferRef.current = waveSurfer;
|
||||
});
|
||||
|
||||
waveSurfer.on("finish", () => setIsPlaying(false));
|
||||
|
||||
return () => {
|
||||
waveSurfer.destroy();
|
||||
};
|
||||
}, [audio, progressColor, waveColor]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isPlaying && (
|
||||
<BsPauseFill
|
||||
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||
onClick={() => {
|
||||
setIsPlaying((prev) => !prev);
|
||||
waveSurferRef.current?.playPause();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isPlaying && (
|
||||
<BsPlayFill
|
||||
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||
onClick={() => {
|
||||
setIsPlaying((prev) => !prev);
|
||||
waveSurferRef.current?.playPause();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="w-full max-w-4xl h-fit" ref={containerRef} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Waveform;
|
||||
10
src/constants/errors.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export type Error = "E001" | "E002";
|
||||
export interface ErrorMessage {
|
||||
error: Error;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const errorMessages: {[key in Error]: string} = {
|
||||
E001: "Wrong password!",
|
||||
E002: "Invalid e-mail",
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
import {Module} from "@/interfaces";
|
||||
|
||||
export const BAND_SCORES: {[key in Module]: number[]} = {
|
||||
reading: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
|
||||
listening: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
|
||||
writing: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
speaking: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
};
|
||||
147
src/constants/ielts.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import {Module} from "@/interfaces";
|
||||
|
||||
export const MODULES: Module[] = ["reading", "listening", "writing", "speaking"];
|
||||
|
||||
export const BAND_SCORES: {[key in Module]: number[]} = {
|
||||
reading: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
|
||||
listening: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
|
||||
writing: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
speaking: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
level: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
};
|
||||
|
||||
export const moduleResultText = (level: number) => {
|
||||
if (level === 9) {
|
||||
return (
|
||||
<>
|
||||
Congratulations on your exam performance! You achieved an impressive <span className="font-bold">level {level}</span>, demonstrating
|
||||
excellent mastery of the assessed knowledge.
|
||||
<br />
|
||||
<br />
|
||||
If you disagree with the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of
|
||||
the results.
|
||||
<br />
|
||||
<br />
|
||||
Please contact us for further information. Congratulations again on your outstanding achievement! We are here to support you on your
|
||||
academic journey.
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (level >= 6) {
|
||||
return (
|
||||
<>
|
||||
Congratulations on your exam performance! You achieved a commendable <span className="font-bold">level {level}</span>, demonstrating a
|
||||
good understanding of the assessed knowledge.
|
||||
<br />
|
||||
<br />
|
||||
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
|
||||
transparency of the results.
|
||||
<br />
|
||||
<br />
|
||||
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
|
||||
journey.
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (level >= 3) {
|
||||
return (
|
||||
<>
|
||||
Congratulations on your exam performance! You achieved a <span className="font-bold">level of {level}</span>, demonstrating a
|
||||
satisfactory understanding of the assessed knowledge.
|
||||
<br />
|
||||
<br />
|
||||
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
|
||||
transparency of the results.
|
||||
<br />
|
||||
<br />
|
||||
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
|
||||
journey.
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
Thank you for taking the exam. You achieved a <span className="font-bold">level {level}</span>, but unfortunately, it did not meet the
|
||||
required standards.
|
||||
<br />
|
||||
<br />
|
||||
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
|
||||
transparency of the results.
|
||||
<br />
|
||||
<br />
|
||||
Please contact us for further information. We encourage you to continue your studies and wish you the best of luck in your future
|
||||
endeavors.
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const levelResultText = (level: number) => {
|
||||
if (level === 9) {
|
||||
return (
|
||||
<>
|
||||
Congratulations on your exam performance! You achieved an impressive <span className="font-bold">level {level}</span>, demonstrating
|
||||
excellent mastery of the assessed knowledge.
|
||||
<br />
|
||||
<br />
|
||||
If you disagree with the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of
|
||||
the results.
|
||||
<br />
|
||||
<br />
|
||||
Please contact us for further information. Congratulations again on your outstanding achievement! We are here to support you on your
|
||||
academic journey.
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (level >= 6) {
|
||||
return (
|
||||
<>
|
||||
Congratulations on your exam performance! You achieved a commendable <span className="font-bold">level {level}</span>, demonstrating a
|
||||
good understanding of the assessed knowledge.
|
||||
<br />
|
||||
<br />
|
||||
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
|
||||
transparency of the results.
|
||||
<br />
|
||||
<br />
|
||||
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
|
||||
journey.
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (level >= 3) {
|
||||
return (
|
||||
<>
|
||||
Congratulations on your exam performance! You achieved a <span className="font-bold">level of {level}</span>, demonstrating a
|
||||
satisfactory understanding of the assessed knowledge.
|
||||
<br />
|
||||
<br />
|
||||
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
|
||||
transparency of the results.
|
||||
<br />
|
||||
<br />
|
||||
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
|
||||
journey.
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
Thank you for taking the exam. You achieved a <span className="font-bold">level {level}</span>, but unfortunately, it did not meet the
|
||||
required standards.
|
||||
<br />
|
||||
<br />
|
||||
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
|
||||
transparency of the results.
|
||||
<br />
|
||||
<br />
|
||||
Please contact us for further information. We encourage you to continue your studies and wish you the best of luck in your future
|
||||
endeavors.
|
||||
</>
|
||||
);
|
||||
};
|
||||
13
src/constants/serviceAccountKey.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"type": "service_account",
|
||||
"project_id": "mti-ielts",
|
||||
"private_key_id": "22b783a14c760d1215a8d1f5de0fa40a33a840e7",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDoNkd7s/izUBRb\nlmJYWl0xk4X9wEVJU4LKA4HPeha8RFDse4T4suVP08oCP9ODSXF5A83+IqXNMs/N\na7PtFABBAx433JrB7I4NsAUrDSjI4LeYEIqh6YzHsQvBU53HAmPChX525S4i0IBy\ncNnyXut0nmlHz5ZwCPXgqg4eN44C+m0f7sxzivcnPth/zLupnMiDAHFZrxQolWO2\n6JfozMWGw0TmCkUxngzeGBMVYmsGiKRIxEi3MWeuwjYjGO4nR1krEUlcpjCbx4UX\nxYXicJb17HOs9LTcSh9bpDWZPHKXR48hxd2cMLr+XQzw7Otwu2p8fEUOJ+CiTyNz\nlkN9p7OhAgMBAAECggEAB5DsMZdGu1X4wdazr+AK4RCG2UKkZ0wbqvgkCMX4O2xo\n7BmmtqFCmEAk+P+KJWEVW81wTu9jUl0tWOrBVzBThUrEF2seVkL+SmshsfpI6cmr\npb5lO/sTgZau1L7kGU3GQRpvKVHUl+EODFyJt2xZFOjL8qFsjAw4sbgsw1aJT6a4\nFilm6Gapi1qSKOPSlXVmi0NJ9DUtNbKaQK8/coqEJRizeXs9MORvzyKQaV8PBmWI\noEnkxahKOD48U2kmI7rT9/YsCuaP2BlGdLxvANXLjAKcrDccVZkYEH82tPtCicED\noow3i956HPdWSXQgUOU65MfGccjOmqGaGa4zUTICyQKBgQD6zLMwL9YS+n9EKZaK\nEbzRybN2d+eKbXyDJzkDi6FnSGVre2ndShsimoOtwZDLmOF/XhN79YOLJVbI124p\npAWO+WxAfe9Xy3iFEBmL4kSREA873Sd8EN5OfYS2DsN7IbjZkoaLuM8QlyXL9ZRS\nBJDVGjx+wFKRjnClcBNbVMMXiQKBgQDtBumKZS0ZCtJuBeuwLGJ1ZJtYECykIrsD\nUtQ7zxwXJzPGqZ2c5JLpHdDm/bb9nllpLsh4SpDRqxFa2H2FF8x5KWaS7JQUsS8e\ner6x5wUt6wAJqV/ZvttVrLZCa8VYn+K7bTANnkPNJZHTqBTJbxkXMDTtkwWXUN2z\nQP3N9lodWQKBgFBHiewYw9ubV3WIImnbt6cne0ymoPUMitioi3V5Epcu81fuTzrI\nZ9sxvoi19xVUwIm2oWICerLlptvvKZImsKjNajtSlHRz6wYc2zCNowkULOwqpGLw\nO1jAkOR94VDewH7UikDbTVywJSceWvXOBFZSaZ7hDQ0OnTw3ndqUTUaRAoGAd2BG\n2PPyDa28o7sJpBYGlJdSAb1LrnLre1YJHAJIZITS99hPUEhykUP6BYx80CkjYO01\n/BeZ7m9Y80cbmJ+O1Or8BT1vqyg90f0B8/mlSyYTQ8pxQupz7ydoN/WtU+BawgjQ\n7drqzPSCCHab2YPBwEMANTMZ2sbYkcJG0aekZSkCgYBbnFJm8kUy57isxHyvrci+\nR30KQl2Y9okPytF8PpLH+yNjLDoduTOHL/hZoFC0M4Gklx4wPKpsEhImIrWmG9VC\n0UrQC6TT1WoY6/S3YehVmTXo/nBPD1XTUcbF/xxUrWDjmMjnt1IlXBbIzUPD3U4P\niRXzHnXb7yi+/iRxSDts2w==\n-----END PRIVATE KEY-----\n",
|
||||
"client_email": "firebase-adminsdk-dyg6p@mti-ielts.iam.gserviceaccount.com",
|
||||
"client_id": "104980563453519094431",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-dyg6p%40mti-ielts.iam.gserviceaccount.com",
|
||||
"universe_domain": "googleapis.com"
|
||||
}
|
||||
39
src/constants/userPermissions.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {Type} from "@/interfaces/user";
|
||||
|
||||
export const PERMISSIONS = {
|
||||
generateCode: {
|
||||
student: ["corporate", "developer", "admin"],
|
||||
teacher: ["corporate", "developer", "admin"],
|
||||
corporate: ["admin", "developer"],
|
||||
admin: ["developer", "admin"],
|
||||
agent: ["developer", "admin"],
|
||||
developer: ["developer"],
|
||||
},
|
||||
deleteUser: {
|
||||
student: ["teacher", "corporate", "developer", "admin"],
|
||||
teacher: ["corporate", "developer", "admin"],
|
||||
corporate: ["admin", "developer"],
|
||||
admin: ["developer", "admin"],
|
||||
agent: ["developer", "admin"],
|
||||
developer: ["developer"],
|
||||
},
|
||||
updateUser: {
|
||||
student: ["teacher", "corporate", "developer", "admin"],
|
||||
teacher: ["corporate", "developer", "admin"],
|
||||
corporate: ["admin", "developer"],
|
||||
admin: ["developer", "admin"],
|
||||
agent: ["developer", "admin"],
|
||||
developer: ["developer"],
|
||||
},
|
||||
updateExpiryDate: {
|
||||
student: ["developer", "admin"],
|
||||
teacher: ["developer", "admin"],
|
||||
corporate: ["admin", "developer"],
|
||||
admin: ["developer", "admin"],
|
||||
agent: ["developer", "admin"],
|
||||
developer: ["developer"],
|
||||
},
|
||||
examManagement: {
|
||||
delete: ["developer", "admin"],
|
||||
},
|
||||
};
|
||||
488
src/dashboards/Admin.tsx
Normal file
@@ -0,0 +1,488 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Modal from "@/components/Modal";
|
||||
import useStats from "@/hooks/useStats";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {User} from "@/interfaces/user";
|
||||
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||
import {dateSorter} from "@/utils";
|
||||
import moment from "moment";
|
||||
import {useEffect, useState} from "react";
|
||||
import {
|
||||
BsArrowLeft,
|
||||
BsBriefcaseFill,
|
||||
BsGlobeCentralSouthAsia,
|
||||
BsPerson,
|
||||
BsPersonFill,
|
||||
BsPencilSquare,
|
||||
BsBank,
|
||||
} from "react-icons/bs";
|
||||
import UserCard from "@/components/UserCard";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import IconCard from "./IconCard";
|
||||
import useFilterStore from "@/stores/listFilterStore";
|
||||
import {useRouter} from "next/router";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export default function AdminDashboard({user}: Props) {
|
||||
const [page, setPage] = useState("");
|
||||
const [selectedUser, setSelectedUser] = useState<User>();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const {stats} = useStats(user.id);
|
||||
const {users, reload} = useUsers();
|
||||
const {groups} = useGroups();
|
||||
|
||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
setShowModal(!!selectedUser && page === "");
|
||||
}, [selectedUser, page]);
|
||||
|
||||
const inactiveCountryManagerFilter = (x: User) => x.type === "agent" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
||||
|
||||
const UserDisplay = (displayUser: User) => (
|
||||
<div
|
||||
onClick={() => setSelectedUser(displayUser)}
|
||||
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
||||
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<span>
|
||||
{displayUser.type === "corporate"
|
||||
? displayUser.corporateInformation?.companyInformation?.name || displayUser.name
|
||||
: displayUser.name}
|
||||
</span>
|
||||
<span className="text-sm opacity-75">{displayUser.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const StudentsList = () => {
|
||||
const filter = (x: User) =>
|
||||
x.type === "student" &&
|
||||
(!!selectedUser
|
||||
? groups
|
||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(x.id)
|
||||
: true);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
|
||||
</div>
|
||||
|
||||
<UserList user={user} filters={[filter]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const TeachersList = () => {
|
||||
const filter = (x: User) =>
|
||||
x.type === "teacher" &&
|
||||
(!!selectedUser
|
||||
? groups
|
||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(x.id) || false
|
||||
: true);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">Teachers ({users.filter(filter).length})</h2>
|
||||
</div>
|
||||
|
||||
<UserList user={user} filters={[filter]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AgentsList = () => {
|
||||
const filter = (x: User) => x.type === "agent";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">Country Managers ({users.filter(filter).length})</h2>
|
||||
</div>
|
||||
|
||||
<UserList user={user} filters={[filter]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const CorporateList = () => (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">Corporate ({users.filter((x) => x.type === "corporate").length})</h2>
|
||||
</div>
|
||||
|
||||
<UserList user={user} filters={[(x) => x.type === "corporate"]} />
|
||||
</>
|
||||
);
|
||||
|
||||
const InactiveCountryManagerList = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">Inactive Country Managers ({users.filter(inactiveCountryManagerFilter).length})</h2>
|
||||
</div>
|
||||
|
||||
<UserList user={user} filters={[inactiveCountryManagerFilter]} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const InactiveStudentsList = () => {
|
||||
const filter = (x: User) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">Inactive Students ({users.filter(filter).length})</h2>
|
||||
</div>
|
||||
|
||||
<UserList user={user} filters={[filter]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const InactiveCorporateList = () => {
|
||||
const filter = (x: User) => x.type === "corporate" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">Inactive Corporate ({users.filter(filter).length})</h2>
|
||||
</div>
|
||||
|
||||
<UserList user={user} filters={[filter]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DefaultDashboard = () => (
|
||||
<>
|
||||
<section className="w-full flex flex-wrap gap-4 items-center justify-between">
|
||||
<IconCard
|
||||
Icon={BsPersonFill}
|
||||
label="Students"
|
||||
value={users.filter((x) => x.type === "student").length}
|
||||
onClick={() => setPage("students")}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPencilSquare}
|
||||
label="Teachers"
|
||||
value={users.filter((x) => x.type === "teacher").length}
|
||||
onClick={() => setPage("teachers")}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsBank}
|
||||
label="Corporate"
|
||||
value={users.filter((x) => x.type === "corporate").length}
|
||||
onClick={() => setPage("corporate")}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsBriefcaseFill}
|
||||
label="Country Managers"
|
||||
value={users.filter((x) => x.type === "agent").length}
|
||||
onClick={() => setPage("agents")}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsGlobeCentralSouthAsia}
|
||||
label="Countries"
|
||||
value={[...new Set(users.filter((x) => x.demographicInformation).map((x) => x.demographicInformation?.country))].length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("inactiveStudents")}
|
||||
Icon={BsPerson}
|
||||
label="Inactive Students"
|
||||
value={
|
||||
users.filter((x) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
|
||||
.length
|
||||
}
|
||||
color="rose"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("inactiveCountryManagers")}
|
||||
Icon={BsPerson}
|
||||
label="Inactive Country Managers"
|
||||
value={users.filter(inactiveCountryManagerFilter).length}
|
||||
color="rose"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("inactiveCorporate")}
|
||||
Icon={BsPerson}
|
||||
label="Inactive Corporate"
|
||||
value={
|
||||
users.filter((x) => x.type === "corporate" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
|
||||
.length
|
||||
}
|
||||
color="rose"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between">
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Latest students</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter((x) => x.type === "student")
|
||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Latest corporate</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter((x) => x.type === "corporate")
|
||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Unpaid Corporate</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter((x) => x.type === "corporate" && x.status === "paymentDue")
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Students expiring in 1 month</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(
|
||||
(x) =>
|
||||
x.type === "student" &&
|
||||
x.subscriptionExpirationDate &&
|
||||
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
|
||||
moment().isBefore(moment(x.subscriptionExpirationDate)),
|
||||
)
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Country Manager expiring in 1 month</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(
|
||||
(x) =>
|
||||
x.type === "teacher" &&
|
||||
x.subscriptionExpirationDate &&
|
||||
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
|
||||
moment().isBefore(moment(x.subscriptionExpirationDate)),
|
||||
)
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Corporate expiring in 1 month</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(
|
||||
(x) =>
|
||||
x.type === "corporate" &&
|
||||
x.subscriptionExpirationDate &&
|
||||
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
|
||||
moment().isBefore(moment(x.subscriptionExpirationDate)),
|
||||
)
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Expired Students</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(
|
||||
(x) => x.type === "student" && x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)),
|
||||
)
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Expired Country Manager</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(
|
||||
(x) => x.type === "teacher" && x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)),
|
||||
)
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Expired Corporate</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(
|
||||
(x) =>
|
||||
x.type === "corporate" && x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)),
|
||||
)
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
|
||||
<>
|
||||
{selectedUser && (
|
||||
<div className="w-full flex flex-col gap-8">
|
||||
<UserCard
|
||||
loggedInUser={user}
|
||||
onClose={(shouldReload) => {
|
||||
setSelectedUser(undefined);
|
||||
if (shouldReload) reload();
|
||||
}}
|
||||
onViewStudents={
|
||||
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
||||
? () => {
|
||||
appendUserFilters({
|
||||
id: "view-students",
|
||||
filter: (x: User) => x.type === "student",
|
||||
});
|
||||
appendUserFilters({
|
||||
id: "belongs-to-admin",
|
||||
filter: (x: User) =>
|
||||
groups
|
||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(x.id),
|
||||
});
|
||||
|
||||
router.push("/list/users");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onViewTeachers={
|
||||
selectedUser.type === "corporate" || selectedUser.type === "student"
|
||||
? () => {
|
||||
appendUserFilters({
|
||||
id: "view-teachers",
|
||||
filter: (x: User) => x.type === "teacher",
|
||||
});
|
||||
appendUserFilters({
|
||||
id: "belongs-to-admin",
|
||||
filter: (x: User) =>
|
||||
groups
|
||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(x.id),
|
||||
});
|
||||
|
||||
router.push("/list/users");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onViewCorporate={
|
||||
selectedUser.type === "teacher" || selectedUser.type === "student"
|
||||
? () => {
|
||||
appendUserFilters({
|
||||
id: "view-corporate",
|
||||
filter: (x: User) => x.type === "corporate",
|
||||
});
|
||||
appendUserFilters({
|
||||
id: "belongs-to-admin",
|
||||
filter: (x: User) =>
|
||||
groups
|
||||
.filter((g) => g.participants.includes(selectedUser.id))
|
||||
.flatMap((g) => [g.admin, ...g.participants])
|
||||
.includes(x.id),
|
||||
});
|
||||
|
||||
router.push("/list/users");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
user={selectedUser}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</Modal>
|
||||
{page === "students" && <StudentsList />}
|
||||
{page === "teachers" && <TeachersList />}
|
||||
{page === "corporate" && <CorporateList />}
|
||||
{page === "agents" && <AgentsList />}
|
||||
{page === "inactiveStudents" && <InactiveStudentsList />}
|
||||
{page === "inactiveCorporate" && <InactiveCorporateList />}
|
||||
{page === "inactiveCountryManagers" && <InactiveCountryManagerList />}
|
||||
{page === "" && <DefaultDashboard />}
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
214
src/dashboards/Agent.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Modal from "@/components/Modal";
|
||||
import useStats from "@/hooks/useStats";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {Group, Stat, User} from "@/interfaces/user";
|
||||
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||
import {dateSorter} from "@/utils";
|
||||
import moment from "moment";
|
||||
import {useEffect, useState} from "react";
|
||||
import {
|
||||
BsArrowLeft,
|
||||
BsPersonFill,
|
||||
BsBank
|
||||
} from "react-icons/bs";
|
||||
import UserCard from "@/components/UserCard";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
|
||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||
import {Module} from "@/interfaces";
|
||||
import {groupByExam} from "@/utils/stats";
|
||||
import IconCard from "./IconCard";
|
||||
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export default function AgentDashboard({user}: Props) {
|
||||
const [page, setPage] = useState("");
|
||||
const [selectedUser, setSelectedUser] = useState<User>();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const {stats} = useStats();
|
||||
const {users, reload} = useUsers();
|
||||
const {groups} = useGroups(user.id);
|
||||
|
||||
useEffect(() => {
|
||||
setShowModal(!!selectedUser && page === "");
|
||||
}, [selectedUser, page]);
|
||||
|
||||
const corporateFilter = (user: User) => user.type === "corporate";
|
||||
const referredCorporateFilter = (x: User) =>
|
||||
x.type === "corporate" && !!x.corporateInformation && x.corporateInformation.referralAgent === user.id;
|
||||
const inactiveReferredCorporateFilter = (x: User) => referredCorporateFilter(x) && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
||||
|
||||
const UserDisplay = (displayUser: User) => (
|
||||
<div
|
||||
onClick={() => setSelectedUser(displayUser)}
|
||||
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
||||
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<span>
|
||||
{displayUser.type === "corporate"
|
||||
? displayUser.corporateInformation?.companyInformation?.name || displayUser.name
|
||||
: displayUser.name}
|
||||
</span>
|
||||
<span className="text-sm opacity-75">{displayUser.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ReferredCorporateList = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">Referred Corporate ({users.filter(referredCorporateFilter).length})</h2>
|
||||
</div>
|
||||
|
||||
<UserList user={user} filters={[referredCorporateFilter]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const InactiveReferredCorporateList = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">Inactive Referred Corporate ({users.filter(inactiveReferredCorporateFilter).length})</h2>
|
||||
</div>
|
||||
|
||||
<UserList user={user} filters={[inactiveReferredCorporateFilter]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const CorporateList = () => {
|
||||
const filter = (x: User) => x.type === "corporate";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">Referred Corporate ({users.filter(filter).length})</h2>
|
||||
</div>
|
||||
|
||||
<UserList user={user} filters={[filter]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DefaultDashboard = () => (
|
||||
<>
|
||||
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:gap-4 text-center">
|
||||
<IconCard
|
||||
onClick={() => setPage("referredCorporate")}
|
||||
Icon={BsPersonFill}
|
||||
label="Referred Corporate"
|
||||
value={users.filter(referredCorporateFilter).length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("inactiveReferredCorporate")}
|
||||
Icon={BsPersonFill}
|
||||
label="Inactive Referred Corporate"
|
||||
value={users.filter(inactiveReferredCorporateFilter).length}
|
||||
color="rose"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("corporate")}
|
||||
Icon={BsBank}
|
||||
label="Corporate"
|
||||
value={users.filter(corporateFilter).length}
|
||||
color="purple"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Latest Referred Corporate</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(referredCorporateFilter)
|
||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Latest corporate</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(corporateFilter)
|
||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Referenced corporate expiring in 1 month</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(
|
||||
(x) =>
|
||||
referredCorporateFilter(x) &&
|
||||
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
|
||||
moment().isBefore(moment(x.subscriptionExpirationDate)),
|
||||
)
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
|
||||
<>
|
||||
{selectedUser && (
|
||||
<div className="w-full flex flex-col gap-8">
|
||||
<UserCard
|
||||
loggedInUser={user}
|
||||
onClose={(shouldReload) => {
|
||||
setSelectedUser(undefined);
|
||||
if (shouldReload) reload();
|
||||
}}
|
||||
onViewStudents={
|
||||
selectedUser.type === "corporate" || selectedUser.type === "teacher" ? () => setPage("students") : undefined
|
||||
}
|
||||
onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined}
|
||||
user={selectedUser}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</Modal>
|
||||
{page === "referredCorporate" && <ReferredCorporateList />}
|
||||
{page === "corporate" && <CorporateList />}
|
||||
{page === "inactiveReferredCorporate" && <InactiveReferredCorporateList />}
|
||||
{page === "" && <DefaultDashboard />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
75
src/dashboards/AssignmentCard.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {Module} from "@/interfaces";
|
||||
import {Assignment} from "@/interfaces/results";
|
||||
import {Stat} from "@/interfaces/user";
|
||||
import {calculateBandScore} from "@/utils/score";
|
||||
import clsx from "clsx";
|
||||
import moment from "moment";
|
||||
import {useState} from "react";
|
||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||
|
||||
interface Props {
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function AssignmentCard({id, name, assigner, startDate, endDate, assignees, results, exams, onClick}: Assignment & Props) {
|
||||
const {users} = useUsers();
|
||||
|
||||
const calculateAverageModuleScore = (module: Module) => {
|
||||
const resultModuleBandScores = results.map((r) => {
|
||||
const moduleStats = r.stats.filter((s) => s.module === module);
|
||||
|
||||
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
|
||||
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
|
||||
return calculateBandScore(correct, total, module, r.type);
|
||||
});
|
||||
|
||||
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / results.length;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="w-[350px] h-fit flex flex-col gap-6 bg-white border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="font-semibold text-xl">{name}</h3>
|
||||
<ProgressBar
|
||||
color={results.length / assignees.length < 0.5 ? "red" : "purple"}
|
||||
percentage={(results.length / assignees.length) * 100}
|
||||
label={`${results.length}/${assignees.length}`}
|
||||
className="h-5"
|
||||
textClassName={results.length / assignees.length < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"}
|
||||
/>
|
||||
</div>
|
||||
<span className="flex gap-1 justify-between">
|
||||
<span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
<span>-</span>
|
||||
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
</span>
|
||||
<div className="grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2">
|
||||
{exams.map(({module}) => (
|
||||
<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" />}
|
||||
{calculateAverageModuleScore(module) > -1 && (
|
||||
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
318
src/dashboards/AssignmentCreator.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import Input from "@/components/Low/Input";
|
||||
import Modal from "@/components/Modal";
|
||||
import {Module} from "@/interfaces";
|
||||
import clsx from "clsx";
|
||||
import {useState} from "react";
|
||||
import {BsBook, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
||||
import {generate} from "random-words";
|
||||
import {capitalize} from "lodash";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {Group, User} from "@/interfaces/user";
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import {calculateAverageLevel} from "@/utils/score";
|
||||
import Button from "@/components/Low/Button";
|
||||
import ReactDatePicker from "react-datepicker";
|
||||
import moment from "moment";
|
||||
import axios from "axios";
|
||||
import {getExam} from "@/utils/exams";
|
||||
import {toast} from "react-toastify";
|
||||
import {uuidv4} from "@firebase/util";
|
||||
import {Assignment} from "@/interfaces/results";
|
||||
import Checkbox from "@/components/Low/Checkbox";
|
||||
|
||||
interface Props {
|
||||
isCreating: boolean;
|
||||
assigner: string;
|
||||
users: User[];
|
||||
groups: Group[];
|
||||
assignment?: Assignment;
|
||||
cancelCreation: () => void;
|
||||
}
|
||||
|
||||
export default function AssignmentCreator({isCreating, assignment, assigner, groups, users, cancelCreation}: Props) {
|
||||
const [selectedModules, setSelectedModules] = useState<Module[]>(assignment?.exams.map((e) => e.module) || []);
|
||||
const [assignees, setAssignees] = useState<string[]>(assignment?.assignees || []);
|
||||
const [name, setName] = useState(assignment?.name || generate({minLength: 6, maxLength: 8, min: 2, max: 3, join: " ", formatter: capitalize}));
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [startDate, setStartDate] = useState<Date | null>(assignment ? moment(assignment.startDate).toDate() : moment().add(1, "day").toDate());
|
||||
const [endDate, setEndDate] = useState<Date | null>(assignment ? moment(assignment.endDate).toDate() : moment().add(8, "day").toDate());
|
||||
// creates a new exam for each assignee or just one exam for all assignees
|
||||
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
|
||||
|
||||
const toggleModule = (module: Module) => {
|
||||
const modules = selectedModules.filter((x) => x !== module);
|
||||
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module]));
|
||||
};
|
||||
|
||||
const toggleAssignee = (user: User) => {
|
||||
setAssignees((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id]));
|
||||
};
|
||||
|
||||
const createAssignment = () => {
|
||||
setIsLoading(true);
|
||||
|
||||
(assignment ? axios.patch : axios.post)(
|
||||
`/api/assignments${assignment ? `/${assignment.id}` : ""}`,
|
||||
{
|
||||
assignees,
|
||||
name,
|
||||
startDate,
|
||||
endDate,
|
||||
selectedModules,
|
||||
generateMultiple,
|
||||
}
|
||||
)
|
||||
.then(() => {
|
||||
toast.success(
|
||||
`The assignment "${name}" has been ${
|
||||
assignment ? "updated" : "created"
|
||||
} successfully!`
|
||||
);
|
||||
cancelCreation();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
const deleteAssignment = () => {
|
||||
if (assignment) {
|
||||
setIsLoading(true);
|
||||
|
||||
if (!confirm(`Are you sure you want to delete the "${assignment.name}" assignment?`)) return;
|
||||
axios
|
||||
.delete(`api/assignments/${assignment.id}`)
|
||||
.then(() => {
|
||||
toast.success(`The assignment "${name}" has been deleted successfully!`);
|
||||
cancelCreation();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isCreating} onClose={cancelCreation} title="New Assignment">
|
||||
<div className="w-full flex flex-col gap-4">
|
||||
<section className="w-full grid -md:grid-cols-1 md:grid-cols-2 place-items-center lg:grid-cols-6 -md:flex-col -md:items-center -md:gap-12 justify-between gap-8 mt-8 px-8">
|
||||
<div
|
||||
onClick={!selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
|
||||
className={clsx(
|
||||
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
||||
"lg:col-span-2",
|
||||
selectedModules.includes("reading") ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||
)}>
|
||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
||||
<BsBook className="text-white w-7 h-7" />
|
||||
</div>
|
||||
<span className="ml-8 font-semibold">Reading</span>
|
||||
{!selectedModules.includes("reading") && !selectedModules.includes("level") && (
|
||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
|
||||
{selectedModules.includes("reading") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
|
||||
</div>
|
||||
<div
|
||||
onClick={!selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
|
||||
className={clsx(
|
||||
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
||||
"lg:col-span-2",
|
||||
selectedModules.includes("listening") ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||
)}>
|
||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
||||
<BsHeadphones className="text-white w-7 h-7" />
|
||||
</div>
|
||||
<span className="ml-8 font-semibold">Listening</span>
|
||||
{!selectedModules.includes("listening") && !selectedModules.includes("level") && (
|
||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
|
||||
{selectedModules.includes("listening") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
|
||||
</div>
|
||||
<div
|
||||
onClick={!selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
|
||||
className={clsx(
|
||||
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
||||
"lg:col-span-2",
|
||||
selectedModules.includes("writing") ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||
)}>
|
||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
||||
<BsPen className="text-white w-7 h-7" />
|
||||
</div>
|
||||
<span className="ml-8 font-semibold">Writing</span>
|
||||
{!selectedModules.includes("writing") && !selectedModules.includes("level") && (
|
||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
|
||||
{selectedModules.includes("writing") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
|
||||
</div>
|
||||
<div
|
||||
onClick={!selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
|
||||
className={clsx(
|
||||
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
||||
"lg:col-span-3",
|
||||
selectedModules.includes("speaking") ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||
)}>
|
||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
||||
<BsMegaphone className="text-white w-7 h-7" />
|
||||
</div>
|
||||
<span className="ml-8 font-semibold">Speaking</span>
|
||||
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && (
|
||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
|
||||
{selectedModules.includes("speaking") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
|
||||
</div>
|
||||
<div
|
||||
onClick={
|
||||
(!selectedModules.includes("level") && selectedModules.length === 0) || selectedModules.includes("level")
|
||||
? () => toggleModule("level")
|
||||
: undefined
|
||||
}
|
||||
className={clsx(
|
||||
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
||||
"lg:col-span-3",
|
||||
selectedModules.includes("level") ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||
)}>
|
||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
||||
<BsClipboard className="text-white w-7 h-7" />
|
||||
</div>
|
||||
<span className="ml-8 font-semibold">Level</span>
|
||||
{!selectedModules.includes("level") && selectedModules.length === 0 && (
|
||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
||||
)}
|
||||
{!selectedModules.includes("level") && selectedModules.length > 0 && <BsXCircle className="text-mti-red-light w-8 h-8" />}
|
||||
{selectedModules.includes("level") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Input type="text" name="name" onChange={(e) => setName(e)} defaultValue={name} label="Assignment Name" required />
|
||||
|
||||
<div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Start Date *</label>
|
||||
<ReactDatePicker
|
||||
className={clsx(
|
||||
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"hover:border-mti-purple tooltip z-10",
|
||||
"transition duration-300 ease-in-out",
|
||||
)}
|
||||
popperClassName="!z-20"
|
||||
filterDate={(date) => moment(date).isAfter(new Date())}
|
||||
dateFormat="dd/MM/yyyy HH:mm"
|
||||
selected={startDate}
|
||||
showTimeSelect
|
||||
onChange={(date) => setStartDate(date)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">End Date *</label>
|
||||
<ReactDatePicker
|
||||
className={clsx(
|
||||
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"hover:border-mti-purple tooltip z-10",
|
||||
"transition duration-300 ease-in-out",
|
||||
)}
|
||||
popperClassName="!z-20"
|
||||
filterDate={(date) => moment(date).isAfter(startDate)}
|
||||
dateFormat="dd/MM/yyyy HH:mm"
|
||||
selected={endDate}
|
||||
showTimeSelect
|
||||
onChange={(date) => setEndDate(date)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="w-full flex flex-col gap-3">
|
||||
<span className="font-semibold">Assignees ({assignees.length} selected)</span>
|
||||
<div className="flex gap-4 overflow-x-scroll scrollbar-hide">
|
||||
{groups.map((g) => (
|
||||
<button
|
||||
key={g.id}
|
||||
onClick={() => {
|
||||
const groupStudentIds = users.filter((u) => g.participants.includes(u.id)).map((u) => u.id);
|
||||
if (groupStudentIds.every((u) => assignees.includes(u))) {
|
||||
setAssignees((prev) => prev.filter((a) => !groupStudentIds.includes(a)));
|
||||
} else {
|
||||
setAssignees((prev) => [...prev.filter((a) => !groupStudentIds.includes(a)), ...groupStudentIds]);
|
||||
}
|
||||
}}
|
||||
className={clsx(
|
||||
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
||||
"transition duration-300 ease-in-out",
|
||||
users.filter((u) => g.participants.includes(u.id)).every((u) => assignees.includes(u.id)) &&
|
||||
"!bg-mti-purple-light !text-white",
|
||||
)}>
|
||||
{g.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap -md:justify-center gap-4">
|
||||
{users.map((user) => (
|
||||
<div
|
||||
onClick={() => toggleAssignee(user)}
|
||||
className={clsx(
|
||||
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
|
||||
"transition ease-in-out duration-300",
|
||||
assignees.includes(user.id) ? "border-mti-purple" : "border-mti-gray-platinum",
|
||||
)}
|
||||
key={user.id}>
|
||||
<span className="flex flex-col gap-0 justify-center">
|
||||
<span className="font-semibold">{user.name}</span>
|
||||
<span className="text-sm opacity-80">{user.email}</span>
|
||||
</span>
|
||||
<ProgressBar
|
||||
color="purple"
|
||||
textClassName="!text-mti-black/80"
|
||||
label={`Level ${calculateAverageLevel(user.levels)}`}
|
||||
percentage={(calculateAverageLevel(user.levels) / 9) * 100}
|
||||
className="h-6"
|
||||
/>
|
||||
<span className="text-mti-black/80 text-sm whitespace-pre-wrap mt-2">
|
||||
Groups:{" "}
|
||||
{groups
|
||||
.filter((g) => g.participants.includes(user.id))
|
||||
.map((g) => g.name)
|
||||
.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<div className="flex gap-4 w-full justify-end">
|
||||
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple(d => !d)}>
|
||||
Generate different exams
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div className="flex gap-4 w-full justify-end">
|
||||
<Button className="w-full max-w-[200px]" variant="outline" onClick={cancelCreation} disabled={isLoading} isLoading={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
{assignment && (
|
||||
<Button
|
||||
className="w-full max-w-[200px]"
|
||||
color="red"
|
||||
variant="outline"
|
||||
onClick={deleteAssignment}
|
||||
disabled={isLoading}
|
||||
isLoading={isLoading}>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={selectedModules.length === 0 || !name || !startDate || !endDate || assignees.length === 0}
|
||||
className="w-full max-w-[200px]"
|
||||
onClick={createAssignment}
|
||||
isLoading={isLoading}>
|
||||
{assignment ? "Update" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
280
src/dashboards/AssignmentView.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import Modal from "@/components/Modal";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {Module} from "@/interfaces";
|
||||
import {Assignment} from "@/interfaces/results";
|
||||
import {Stat, User} from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {sortByModule} from "@/utils/moduleUtils";
|
||||
import {calculateBandScore} from "@/utils/score";
|
||||
import {convertToUserSolutions} from "@/utils/stats";
|
||||
import clsx from "clsx";
|
||||
import {capitalize, uniqBy} from "lodash";
|
||||
import moment from "moment";
|
||||
import {useRouter} from "next/router";
|
||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
assignment?: Assignment;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
||||
const {users} = useUsers();
|
||||
const router = useRouter();
|
||||
|
||||
const setExams = useExamStore((state) => state.setExams);
|
||||
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
|
||||
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
const date = moment(parseInt(timestamp));
|
||||
const formatter = "YYYY/MM/DD - HH:mm";
|
||||
|
||||
return date.format(formatter);
|
||||
};
|
||||
|
||||
const calculateAverageModuleScore = (module: Module) => {
|
||||
if (!assignment) return -1;
|
||||
|
||||
const resultModuleBandScores = assignment.results.map((r) => {
|
||||
const moduleStats = r.stats.filter((s) => s.module === module);
|
||||
|
||||
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
|
||||
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
|
||||
return calculateBandScore(correct, total, module, r.type);
|
||||
});
|
||||
|
||||
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length;
|
||||
};
|
||||
|
||||
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => {
|
||||
const scores: {[key in Module]: {total: number; missing: number; correct: number}} = {
|
||||
reading: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
listening: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
writing: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
speaking: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
level: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
};
|
||||
|
||||
stats.forEach((x) => {
|
||||
scores[x.module!] = {
|
||||
total: scores[x.module!].total + x.score.total,
|
||||
correct: scores[x.module!].correct + x.score.correct,
|
||||
missing: scores[x.module!].missing + x.score.missing,
|
||||
};
|
||||
});
|
||||
|
||||
return Object.keys(scores)
|
||||
.filter((x) => scores[x as Module].total > 0)
|
||||
.map((x) => ({module: x as Module, ...scores[x as Module]}));
|
||||
};
|
||||
|
||||
const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => {
|
||||
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
|
||||
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
|
||||
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
|
||||
|
||||
const aggregatedLevels = aggregatedScores.map((x) => ({
|
||||
module: x.module,
|
||||
level: calculateBandScore(x.correct, x.total, x.module, focus),
|
||||
}));
|
||||
|
||||
const timeSpent = stats[0].timeSpent;
|
||||
|
||||
const selectExam = () => {
|
||||
const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam));
|
||||
|
||||
Promise.all(examPromises).then((exams) => {
|
||||
if (exams.every((x) => !!x)) {
|
||||
setUserSolutions(convertToUserSolutions(stats));
|
||||
setShowSolutions(true);
|
||||
setExams(exams.map((x) => x!).sort(sortByModule));
|
||||
setSelectedModules(
|
||||
exams
|
||||
.map((x) => x!)
|
||||
.sort(sortByModule)
|
||||
.map((x) => x!.module),
|
||||
);
|
||||
router.push("/exercises");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<div className="w-full flex justify-between -md:items-center 2xl:items-center">
|
||||
<div className="flex md:flex-col 2xl:flex-row md:gap-1 -md:gap-2 2xl:gap-2 -md:items-center 2xl:items-center">
|
||||
<span className="font-medium">{formatTimestamp(stats[0].date.toString())}</span>
|
||||
{timeSpent && (
|
||||
<>
|
||||
<span className="md:hidden 2xl:flex">• </span>
|
||||
<span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={clsx(
|
||||
correct / total >= 0.7 && "text-mti-purple",
|
||||
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
||||
correct / total < 0.3 && "text-mti-rose",
|
||||
)}>
|
||||
Level{" "}
|
||||
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col gap-1">
|
||||
<div className="grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2">
|
||||
{aggregatedLevels.map(({module, level}) => (
|
||||
<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">{level.toFixed(1)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span>
|
||||
{(() => {
|
||||
const student = users.find((u) => u.id === user);
|
||||
return `${student?.name} (${student?.email})`;
|
||||
})()}
|
||||
</span>
|
||||
<div
|
||||
key={user}
|
||||
className={clsx(
|
||||
"flex flex-col gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:hidden",
|
||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
||||
correct / total < 0.3 && "hover:border-mti-rose",
|
||||
)}
|
||||
onClick={selectExam}
|
||||
role="button">
|
||||
{content}
|
||||
</div>
|
||||
<div
|
||||
key={user}
|
||||
className={clsx(
|
||||
"flex flex-col gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:tooltip md:hidden",
|
||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
||||
correct / total < 0.3 && "hover:border-mti-rose",
|
||||
)}
|
||||
data-tip="Your screen size is too small to view previous exams."
|
||||
role="button">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}>
|
||||
<div className="mt-4 flex flex-col w-full gap-4">
|
||||
<ProgressBar
|
||||
color="purple"
|
||||
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
|
||||
className="h-6"
|
||||
textClassName={
|
||||
(assignment?.results.length || 0) / (assignment?.assignees.length || 1) < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"
|
||||
}
|
||||
percentage={((assignment?.results.length || 0) / (assignment?.assignees.length || 1)) * 100}
|
||||
/>
|
||||
<div className="flex gap-8 items-start">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
<span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
</div>
|
||||
<span>
|
||||
Assignees:{" "}
|
||||
{users
|
||||
.filter((u) => assignment?.assignees.includes(u.id))
|
||||
.map((u) => `${u.name} (${u.email})`)
|
||||
.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xl font-bold">Average Scores</span>
|
||||
<div className="grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2">
|
||||
{assignment?.exams.map(({module}) => (
|
||||
<div
|
||||
data-tip={capitalize(module)}
|
||||
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 tooltip",
|
||||
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" />}
|
||||
{calculateAverageModuleScore(module) > -1 && (
|
||||
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xl font-bold">
|
||||
Results ({assignment?.results.length}/{assignment?.assignees.length})
|
||||
</span>
|
||||
<div>
|
||||
{assignment && assignment?.results.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 w-full gap-4 xl:gap-6">
|
||||
{assignment.results.map((r) => customContent(r.stats, r.user, r.type))}
|
||||
</div>
|
||||
)}
|
||||
{assignment && assignment?.results.length === 0 && <span className="font-semibold ml-1">No results yet...</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
315
src/dashboards/Corporate.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Modal from "@/components/Modal";
|
||||
import useStats from "@/hooks/useStats";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {Group, Stat, User} from "@/interfaces/user";
|
||||
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||
import {dateSorter} from "@/utils";
|
||||
import moment from "moment";
|
||||
import {useEffect, useState} from "react";
|
||||
import {
|
||||
BsArrowLeft,
|
||||
BsClipboard2Data,
|
||||
BsClipboard2DataFill,
|
||||
BsClock,
|
||||
BsGlobeCentralSouthAsia,
|
||||
BsPaperclip,
|
||||
BsPerson,
|
||||
BsPersonAdd,
|
||||
BsPersonFill,
|
||||
BsPersonFillGear,
|
||||
BsPersonGear,
|
||||
BsPencilSquare,
|
||||
} from "react-icons/bs";
|
||||
import UserCard from "@/components/UserCard";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
|
||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||
import {Module} from "@/interfaces";
|
||||
import {groupByExam} from "@/utils/stats";
|
||||
import IconCard from "./IconCard";
|
||||
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
||||
import useFilterStore from "@/stores/listFilterStore";
|
||||
import {useRouter} from "next/router";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export default function CorporateDashboard({user}: Props) {
|
||||
const [page, setPage] = useState("");
|
||||
const [selectedUser, setSelectedUser] = useState<User>();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const {stats} = useStats();
|
||||
const {users, reload} = useUsers();
|
||||
const {groups} = useGroups(user.id);
|
||||
|
||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
setShowModal(!!selectedUser && page === "");
|
||||
}, [selectedUser, page]);
|
||||
|
||||
const studentFilter = (user: User) => user.type === "student" && groups.flatMap((g) => g.participants).includes(user.id);
|
||||
const teacherFilter = (user: User) => user.type === "teacher" && groups.flatMap((g) => g.participants).includes(user.id);
|
||||
|
||||
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
|
||||
|
||||
const UserDisplay = (displayUser: User) => (
|
||||
<div
|
||||
onClick={() => setSelectedUser(displayUser)}
|
||||
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
||||
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<span>{displayUser.name}</span>
|
||||
<span className="text-sm opacity-75">{displayUser.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const StudentsList = () => {
|
||||
const filter = (x: User) =>
|
||||
x.type === "student" &&
|
||||
(!!selectedUser
|
||||
? groups
|
||||
.filter((g) => g.admin === selectedUser.id)
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(x.id) || false
|
||||
: groups.flatMap((g) => g.participants).includes(x.id));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
|
||||
</div>
|
||||
|
||||
<UserList user={user} filters={[filter]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const TeachersList = () => {
|
||||
const filter = (x: User) =>
|
||||
x.type === "teacher" &&
|
||||
(!!selectedUser
|
||||
? groups
|
||||
.filter((g) => g.admin === selectedUser.id)
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(x.id) || false
|
||||
: groups.flatMap((g) => g.participants).includes(x.id));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">Teachers ({users.filter(filter).length})</h2>
|
||||
</div>
|
||||
|
||||
<UserList user={user} filters={[filter]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const GroupsList = () => {
|
||||
const filter = (x: Group) => x.admin === user.id || x.participants.includes(user.id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">Groups ({groups.filter(filter).length})</h2>
|
||||
</div>
|
||||
|
||||
<GroupList user={user} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const averageLevelCalculator = (studentStats: Stat[]) => {
|
||||
const formattedStats = studentStats
|
||||
.map((s) => ({focus: users.find((u) => u.id === s.user)?.focus, score: s.score, module: s.module}))
|
||||
.filter((f) => !!f.focus);
|
||||
const bandScores = formattedStats.map((s) => ({
|
||||
module: s.module,
|
||||
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
|
||||
}));
|
||||
|
||||
const levels: {[key in Module]: number} = {reading: 0, listening: 0, writing: 0, speaking: 0, level: 0};
|
||||
bandScores.forEach((b) => (levels[b.module] += b.level));
|
||||
|
||||
return calculateAverageLevel(levels);
|
||||
};
|
||||
|
||||
const DefaultDashboard = () => (
|
||||
<>
|
||||
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:justify-between text-center">
|
||||
<IconCard
|
||||
onClick={() => setPage("students")}
|
||||
Icon={BsPersonFill}
|
||||
label="Students"
|
||||
value={users.filter(studentFilter).length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("teachers")}
|
||||
Icon={BsPencilSquare}
|
||||
label="Teachers"
|
||||
value={users.filter(teacherFilter).length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsClipboard2Data}
|
||||
label="Exams Performed"
|
||||
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPaperclip}
|
||||
label="Average Level"
|
||||
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard onClick={() => setPage("groups")} Icon={BsPersonAdd} label="Groups" value={groups.length} color="purple" />
|
||||
<IconCard
|
||||
Icon={BsClock}
|
||||
label="Expiration Date"
|
||||
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
|
||||
color="rose"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Latest students</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(studentFilter)
|
||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Latest teachers</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(teacherFilter)
|
||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Highest level students</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(studentFilter)
|
||||
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Highest exam count students</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(studentFilter)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
|
||||
)
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
|
||||
<>
|
||||
{selectedUser && (
|
||||
<div className="w-full flex flex-col gap-8">
|
||||
<UserCard
|
||||
loggedInUser={user}
|
||||
onClose={(shouldReload) => {
|
||||
setSelectedUser(undefined);
|
||||
if (shouldReload) reload();
|
||||
}}
|
||||
onViewStudents={
|
||||
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
||||
? () => {
|
||||
appendUserFilters({
|
||||
id: "view-students",
|
||||
filter: (x: User) => x.type === "student",
|
||||
});
|
||||
appendUserFilters({
|
||||
id: "belongs-to-admin",
|
||||
filter: (x: User) =>
|
||||
groups
|
||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(x.id),
|
||||
});
|
||||
|
||||
router.push("/list/users");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onViewTeachers={
|
||||
selectedUser.type === "corporate" || selectedUser.type === "student"
|
||||
? () => {
|
||||
appendUserFilters({
|
||||
id: "view-teachers",
|
||||
filter: (x: User) => x.type === "teacher",
|
||||
});
|
||||
appendUserFilters({
|
||||
id: "belongs-to-admin",
|
||||
filter: (x: User) =>
|
||||
groups
|
||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(x.id),
|
||||
});
|
||||
|
||||
router.push("/list/users");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
user={selectedUser}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</Modal>
|
||||
{page === "students" && <StudentsList />}
|
||||
{page === "teachers" && <TeachersList />}
|
||||
{page === "groups" && <GroupsList />}
|
||||
{page === "" && <DefaultDashboard />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
30
src/dashboards/IconCard.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import clsx from "clsx";
|
||||
import {IconType} from "react-icons";
|
||||
|
||||
interface Props {
|
||||
Icon: IconType;
|
||||
label: string;
|
||||
value: string | number;
|
||||
color: "purple" | "rose" | "red";
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function IconCard({Icon, label, value, color, onClick}: Props) {
|
||||
const colorClasses: {[key in typeof color]: string} = {
|
||||
purple: "text-mti-purple-light",
|
||||
red: "text-mti-red-light",
|
||||
rose: "text-mti-rose-light",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
||||
<Icon className={clsx("text-6xl", colorClasses[color])} />
|
||||
<span className="flex flex-col gap-1 items-center text-xl">
|
||||
<span className="text-lg">{label}</span>
|
||||
<span className={clsx("font-semibold", colorClasses[color])}>{value}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
216
src/dashboards/Student.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import PayPalPayment from "@/components/PayPalPayment";
|
||||
import ProfileSummary from "@/components/ProfileSummary";
|
||||
import useAssignments from "@/hooks/useAssignments";
|
||||
import useStats from "@/hooks/useStats";
|
||||
import {Assignment} from "@/interfaces/results";
|
||||
import {User} from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
|
||||
import {averageScore, groupBySession} from "@/utils/stats";
|
||||
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js";
|
||||
import {PayPalButtons} from "@paypal/react-paypal-js";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize} from "lodash";
|
||||
import moment from "moment";
|
||||
import Link from "next/link";
|
||||
import {useRouter} from "next/router";
|
||||
import {BsArrowRepeat, BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export default function StudentDashboard({user}: Props) {
|
||||
const {stats} = useStats(user.id);
|
||||
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const setExams = useExamStore((state) => state.setExams);
|
||||
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
|
||||
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||
const setAssignment = useExamStore((state) => state.setAssignment);
|
||||
|
||||
const startAssignment = (assignment: Assignment) => {
|
||||
const examPromises = assignment.exams.filter((e) => e.assignee === user.id).map((e) => getExamById(e.module, e.id));
|
||||
|
||||
Promise.all(examPromises).then((exams) => {
|
||||
if (exams.every((x) => !!x)) {
|
||||
setUserSolutions([]);
|
||||
setShowSolutions(false);
|
||||
setExams(exams.map((x) => x!).sort(sortByModule));
|
||||
setSelectedModules(
|
||||
exams
|
||||
.map((x) => x!)
|
||||
.sort(sortByModule)
|
||||
.map((x) => x!.module),
|
||||
);
|
||||
setAssignment(assignment);
|
||||
|
||||
router.push("/exercises");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProfileSummary
|
||||
user={user}
|
||||
items={[
|
||||
{
|
||||
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||
value: Object.keys(groupBySession(stats)).length,
|
||||
label: "Exams",
|
||||
},
|
||||
{
|
||||
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||
value: stats.length,
|
||||
label: "Exercises",
|
||||
},
|
||||
{
|
||||
icon: <BsStar className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
|
||||
label: "Average Score",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<section className="flex flex-col gap-1 md:gap-3">
|
||||
<span className="font-bold text-lg">Bio</span>
|
||||
<span className="text-mti-gray-taupe">
|
||||
{user.bio || "Your bio will appear here, you can change it by clicking on your name in the top right corner."}
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-1 md:gap-3">
|
||||
<div className="flex gap-4 items-center">
|
||||
<div
|
||||
onClick={reloadAssignments}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||
<span className="font-bold text-lg text-mti-black">Assignments</span>
|
||||
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-mti-gray-taupe flex gap-8 overflow-x-scroll scrollbar-hide">
|
||||
{assignments.filter((a) => moment(a.endDate).isSameOrAfter(moment())).length === 0 &&
|
||||
"Assignments will appear here. It seems that for now there are no assignments for you."}
|
||||
{assignments
|
||||
.filter((a) => moment(a.endDate).isSameOrAfter(moment()))
|
||||
.sort((a, b) => moment(a.startDate).diff(b.startDate))
|
||||
.map((assignment) => (
|
||||
<div
|
||||
className={clsx(
|
||||
"border border-mti-gray-anti-flash rounded-xl flex flex-col gap-6 p-4 min-w-[300px]",
|
||||
assignment.results.map((r) => r.user).includes(user.id) && "border-mti-green-light",
|
||||
)}
|
||||
key={assignment.id}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="font-semibold text-xl text-mti-black/90">{assignment.name}</h3>
|
||||
<span className="flex gap-1 justify-between">
|
||||
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
<span>-</span>
|
||||
<span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between w-full items-center">
|
||||
<div className="grid grid-cols-2 gap-2 place-items-center justify-center w-fit min-w-[104px] -md:mt-2">
|
||||
{assignment.exams
|
||||
.filter((e) => e.assignee === user.id)
|
||||
.map((e) => e.module)
|
||||
.sort(sortByModuleName)
|
||||
.map((module) => (
|
||||
<div
|
||||
key={module}
|
||||
data-tip={capitalize(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 tooltip",
|
||||
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" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!assignment.results.map((r) => r.user).includes(user.id) && (
|
||||
<>
|
||||
<div
|
||||
className="tooltip w-full md:hidden h-full flex items-center justify-end pl-8"
|
||||
data-tip="Your screen size is too small to perform an assignment">
|
||||
<Button
|
||||
disabled={moment(assignment.startDate).isAfter(moment())}
|
||||
className="w-full h-full !rounded-xl"
|
||||
variant="outline">
|
||||
Start
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
disabled={moment(assignment.startDate).isAfter(moment())}
|
||||
className="w-full max-w-[50%] h-full !rounded-xl -md:hidden"
|
||||
onClick={() => startAssignment(assignment)}
|
||||
variant="outline">
|
||||
Start
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{assignment.results.map((r) => r.user).includes(user.id) && (
|
||||
<Button
|
||||
onClick={() => router.push("/record")}
|
||||
color="green"
|
||||
className="w-full max-w-[50%] h-full !rounded-xl -md:hidden"
|
||||
variant="outline">
|
||||
Submitted
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<span className="font-bold text-lg">Score History</span>
|
||||
<div className="grid -md:grid-rows-4 md:grid-cols-2 gap-6">
|
||||
{MODULE_ARRAY.map((module) => (
|
||||
<div className="border border-mti-gray-anti-flash rounded-xl flex flex-col gap-2 p-4" key={module}>
|
||||
<div className="flex gap-2 md:gap-3 items-center">
|
||||
<div className="w-8 h-8 md:w-12 md:h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg md:rounded-xl">
|
||||
{module === "reading" && <BsBook className="text-ielts-reading w-4 h-4 md:w-5 md:h-5" />}
|
||||
{module === "listening" && <BsHeadphones className="text-ielts-listening w-4 h-4 md:w-5 md:h-5" />}
|
||||
{module === "writing" && <BsPen className="text-ielts-writing w-4 h-4 md:w-5 md:h-5" />}
|
||||
{module === "speaking" && <BsMegaphone className="text-ielts-speaking w-4 h-4 md:w-5 md:h-5" />}
|
||||
</div>
|
||||
<div className="flex justify-between w-full">
|
||||
<span className="font-bold md:font-extrabold text-sm">{capitalize(module)}</span>
|
||||
<span className="text-sm font-normal text-mti-gray-dim">
|
||||
Level {user.levels[module]} / Level {user.desiredLevels[module]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:pl-14">
|
||||
<ProgressBar
|
||||
color={module}
|
||||
label=""
|
||||
percentage={Math.round((user.levels[module] * 100) / user.desiredLevels[module])}
|
||||
className="w-full h-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
340
src/dashboards/Teacher.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Modal from "@/components/Modal";
|
||||
import useStats from "@/hooks/useStats";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {Group, Stat, User} from "@/interfaces/user";
|
||||
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||
import {dateSorter} from "@/utils";
|
||||
import moment from "moment";
|
||||
import {useEffect, useState} from "react";
|
||||
import {
|
||||
BsArrowLeft,
|
||||
BsArrowRepeat,
|
||||
BsClipboard2Data,
|
||||
BsClipboard2DataFill,
|
||||
BsClipboard2Heart,
|
||||
BsClipboard2X,
|
||||
BsClipboardPulse,
|
||||
BsClock,
|
||||
BsEnvelopePaper,
|
||||
BsGlobeCentralSouthAsia,
|
||||
BsPaperclip,
|
||||
BsPerson,
|
||||
BsPersonAdd,
|
||||
BsPersonFill,
|
||||
BsPersonFillGear,
|
||||
BsPersonGear,
|
||||
BsPlus,
|
||||
BsRepeat,
|
||||
BsRepeat1,
|
||||
} from "react-icons/bs";
|
||||
import UserCard from "@/components/UserCard";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
|
||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||
import {Module} from "@/interfaces";
|
||||
import {groupByExam} from "@/utils/stats";
|
||||
import IconCard from "./IconCard";
|
||||
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
||||
import useAssignments from "@/hooks/useAssignments";
|
||||
import {Assignment} from "@/interfaces/results";
|
||||
import AssignmentCard from "./AssignmentCard";
|
||||
import Button from "@/components/Low/Button";
|
||||
import clsx from "clsx";
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import AssignmentCreator from "./AssignmentCreator";
|
||||
import AssignmentView from "./AssignmentView";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export default function TeacherDashboard({user}: Props) {
|
||||
const [page, setPage] = useState("");
|
||||
const [selectedUser, setSelectedUser] = useState<User>();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
|
||||
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
|
||||
|
||||
const {stats} = useStats();
|
||||
const {users, reload} = useUsers();
|
||||
const {groups} = useGroups(user.id);
|
||||
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assigner: user.id});
|
||||
|
||||
useEffect(() => {
|
||||
setShowModal(!!selectedUser && page === "");
|
||||
}, [selectedUser, page]);
|
||||
|
||||
const studentFilter = (user: User) => user.type === "student" && groups.flatMap((g) => g.participants).includes(user.id);
|
||||
|
||||
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
|
||||
|
||||
const UserDisplay = (displayUser: User) => (
|
||||
<div
|
||||
onClick={() => setSelectedUser(displayUser)}
|
||||
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
||||
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<span>{displayUser.name}</span>
|
||||
<span className="text-sm opacity-75">{displayUser.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const StudentsList = () => {
|
||||
const filter = (x: User) =>
|
||||
x.type === "student" &&
|
||||
(!!selectedUser
|
||||
? groups
|
||||
.filter((g) => g.admin === selectedUser.id)
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(x.id) || false
|
||||
: groups.flatMap((g) => g.participants).includes(x.id));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
|
||||
</div>
|
||||
|
||||
<UserList user={user} filters={[filter]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const GroupsList = () => {
|
||||
const filter = (x: Group) => x.admin === user.id || x.participants.includes(user.id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">Groups ({groups.filter(filter).length})</h2>
|
||||
</div>
|
||||
|
||||
<GroupList user={user} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const averageLevelCalculator = (studentStats: Stat[]) => {
|
||||
const formattedStats = studentStats
|
||||
.map((s) => ({focus: users.find((u) => u.id === s.user)?.focus, score: s.score, module: s.module}))
|
||||
.filter((f) => !!f.focus);
|
||||
const bandScores = formattedStats.map((s) => ({
|
||||
module: s.module,
|
||||
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
|
||||
}));
|
||||
|
||||
const levels: {[key in Module]: number} = {reading: 0, listening: 0, writing: 0, speaking: 0, level: 0};
|
||||
bandScores.forEach((b) => (levels[b.module] += b.level));
|
||||
|
||||
return calculateAverageLevel(levels);
|
||||
};
|
||||
|
||||
const AssignmentsPage = () => {
|
||||
const activeFilter = (a: Assignment) => moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment());
|
||||
const pastFilter = (a: Assignment) => moment(a.endDate).isBefore(moment());
|
||||
const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
|
||||
|
||||
return (
|
||||
<>
|
||||
<AssignmentView
|
||||
isOpen={!!selectedAssignment && !isCreatingAssignment}
|
||||
onClose={() => {
|
||||
setSelectedAssignment(undefined);
|
||||
setIsCreatingAssignment(false);
|
||||
}}
|
||||
assignment={selectedAssignment}
|
||||
/>
|
||||
<AssignmentCreator
|
||||
assignment={selectedAssignment}
|
||||
groups={groups.filter((x) => x.admin === user.id || x.participants.includes(user.id))}
|
||||
users={users.filter(
|
||||
(x) =>
|
||||
x.type === "student" &&
|
||||
(!!selectedUser
|
||||
? groups
|
||||
.filter((g) => g.admin === selectedUser.id)
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(x.id) || false
|
||||
: groups.flatMap((g) => g.participants).includes(x.id)),
|
||||
)}
|
||||
assigner={user.id}
|
||||
isCreating={isCreatingAssignment}
|
||||
cancelCreation={() => {
|
||||
setIsCreatingAssignment(false);
|
||||
setSelectedAssignment(undefined);
|
||||
reloadAssignments();
|
||||
}}
|
||||
/>
|
||||
<div className="w-full flex justify-between items-center">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<div
|
||||
onClick={reloadAssignments}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||
<span>Reload</span>
|
||||
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
|
||||
</div>
|
||||
</div>
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeFilter).length})</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{assignments.filter(activeFilter).map((a) => (
|
||||
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-2xl font-semibold">Planned Assignments ({assignments.filter(futureFilter).length})</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div
|
||||
onClick={() => setIsCreatingAssignment(true)}
|
||||
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
|
||||
<BsPlus className="text-6xl" />
|
||||
<span className="text-lg">New Assignment</span>
|
||||
</div>
|
||||
{assignments.filter(futureFilter).map((a) => (
|
||||
<AssignmentCard
|
||||
{...a}
|
||||
onClick={() => {
|
||||
setSelectedAssignment(a);
|
||||
setIsCreatingAssignment(true);
|
||||
}}
|
||||
key={a.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{assignments.filter(pastFilter).map((a) => (
|
||||
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DefaultDashboard = () => (
|
||||
<>
|
||||
<section className="flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center">
|
||||
<IconCard
|
||||
onClick={() => setPage("students")}
|
||||
Icon={BsPersonFill}
|
||||
label="Students"
|
||||
value={users.filter(studentFilter).length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsClipboard2Data}
|
||||
label="Exams Performed"
|
||||
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPaperclip}
|
||||
label="Average Level"
|
||||
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard Icon={BsPersonAdd} label="Groups" value={groups.length} color="purple" onClick={() => setPage("groups")} />
|
||||
<div
|
||||
onClick={() => setPage("assignments")}
|
||||
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
||||
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
||||
<span className="flex flex-col gap-1 items-center text-xl">
|
||||
<span className="text-lg">Assignments</span>
|
||||
<span className="font-semibold text-mti-purple-light">{assignments.length}</span>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between">
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Latest students</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(studentFilter)
|
||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Highest level students</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(studentFilter)
|
||||
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Highest exam count students</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(studentFilter)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
|
||||
)
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
|
||||
<>
|
||||
{selectedUser && (
|
||||
<div className="w-full flex flex-col gap-8">
|
||||
<UserCard
|
||||
loggedInUser={user}
|
||||
onClose={(shouldReload) => {
|
||||
setSelectedUser(undefined);
|
||||
if (shouldReload) reload();
|
||||
}}
|
||||
onViewStudents={
|
||||
selectedUser.type === "corporate" || selectedUser.type === "teacher" ? () => setPage("students") : undefined
|
||||
}
|
||||
onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined}
|
||||
user={selectedUser}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</Modal>
|
||||
{page === "students" && <StudentsList />}
|
||||
{page === "groups" && <GroupsList />}
|
||||
{page === "assignments" && <AssignmentsPage />}
|
||||
{page === "" && <DefaultDashboard />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
42
src/email/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import hbs from "nodemailer-express-handlebars";
|
||||
import path from "path";
|
||||
|
||||
interface MailOptions {
|
||||
from: string;
|
||||
to: string[];
|
||||
subject: string;
|
||||
template: string;
|
||||
context: object;
|
||||
}
|
||||
|
||||
export function prepareMailer(template?: string): nodemailer.Transporter {
|
||||
const transport = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
auth: {
|
||||
user: process.env.MAIL_USER!,
|
||||
pass: process.env.MAIL_PASS!,
|
||||
},
|
||||
});
|
||||
|
||||
const handlebarOptions: hbs.NodemailerExpressHandlebarsOptions = {
|
||||
viewEngine: {
|
||||
partialsDir: path.resolve("src/email/templates"),
|
||||
defaultLayout: `src/email/templates/${template || "main"}`,
|
||||
},
|
||||
viewPath: path.resolve("src/email/templates"),
|
||||
};
|
||||
|
||||
transport.use("compile", hbs(handlebarOptions));
|
||||
return transport;
|
||||
}
|
||||
|
||||
export function prepareMailOptions(context: object, to: string[], subject: string, template: string): MailOptions {
|
||||
return {
|
||||
from: process.env.MAIL_USER!,
|
||||
to,
|
||||
subject,
|
||||
template,
|
||||
context,
|
||||
};
|
||||
}
|
||||
BIN
src/email/templates/logo.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
src/email/templates/logo_title.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
34
src/email/templates/main.handlebars
Normal file
@@ -0,0 +1,34 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<div style="background-color: #ffffff; color: #353338;"
|
||||
class="h-full min-h-screen w-full flex flex-col p-8 gap-16 text-base">
|
||||
<img src="/logo_title.png" class="w-48 h-48 self-center" />
|
||||
<div>
|
||||
<span>Hello future {{type}} of <b>EnCoach</b>,</span><br />
|
||||
<span>You have been invited to register at <a href="https://platform.encoach.com/register?code={{code}}">EnCoach</a>
|
||||
to
|
||||
become a
|
||||
{{type}}!</span><br />
|
||||
<span>Please use the following code when registering:</span>
|
||||
</div>
|
||||
<br />
|
||||
<br />
|
||||
<a href="https://platform.encoach.com/register?code={{code}}"></a>
|
||||
<span class="self-center p-4 px-12 text-lg text-[#]" style="background-color: #D5D9F0; color: #353338">
|
||||
<b>{{code}}</b>
|
||||
</span>
|
||||
</a>
|
||||
<br />
|
||||
<br />
|
||||
<div>
|
||||
<span>Thanks, <br /> Your EnCoach team</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</html>
|
||||
4
src/email/templates/main.handlebars.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"type": "student",
|
||||
"code": "123a"
|
||||
}
|
||||
22
src/email/templates/verification.handlebars
Normal file
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<div>
|
||||
<p>Hello {{name}},</p>
|
||||
<br />
|
||||
<p>Follow this link to verify your email address.</p>
|
||||
<a href="https://platform.encoach.com/action?mode=signIn&continueUrl={{email}}&oobCode={{code}}">Verify account</a>
|
||||
<br />
|
||||
<br />
|
||||
<p>If you didn’t ask to verify this address, you can ignore this email.</p>
|
||||
<br />
|
||||
<p>Thanks,</p>
|
||||
<p>Your EnCoach team</p>
|
||||
</div>
|
||||
|
||||
</html>
|
||||
5
src/email/templates/verification.handlebars.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Tiago Ribeiro",
|
||||
"email": "tiago.ribeiro@ecrop.dev",
|
||||
"code": "123"
|
||||
}
|
||||
@@ -1,92 +1,226 @@
|
||||
import ProfileLevel from "@/components/ProfileLevel";
|
||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import Button from "@/components/Low/Button";
|
||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||
import {moduleResultText} from "@/constants/ielts";
|
||||
import {Module} from "@/interfaces";
|
||||
import {User} from "@/interfaces/user";
|
||||
import {ICONS} from "@/resources/modules";
|
||||
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {calculateBandScore} from "@/utils/score";
|
||||
import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
import router from "next/router";
|
||||
import {useRouter} from "next/router";
|
||||
import {Fragment, useEffect, useState} from "react";
|
||||
import {BsArrowCounterclockwise, BsBook, BsClipboard, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs";
|
||||
|
||||
interface Score {
|
||||
module: Module;
|
||||
correct: number;
|
||||
total: number;
|
||||
missing: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
modules: Module[];
|
||||
scores: {
|
||||
module?: Module;
|
||||
correct: number;
|
||||
total: number;
|
||||
}[];
|
||||
scores: Score[];
|
||||
isLoading: boolean;
|
||||
onViewResults: () => void;
|
||||
}
|
||||
|
||||
export default function Finish({user, scores, modules, onViewResults}: Props) {
|
||||
const renderModuleScore = (module: Module) => {
|
||||
const moduleScores = scores.filter((x) => x.module === module);
|
||||
if (moduleScores.length === 0) {
|
||||
return <>0</>;
|
||||
}
|
||||
export default function Finish({user, scores, modules, isLoading, onViewResults}: Props) {
|
||||
const [selectedModule, setSelectedModule] = useState(modules[0]);
|
||||
const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!);
|
||||
|
||||
return moduleScores.map((x, index) => (
|
||||
<span key={index}>
|
||||
{x.correct} / {x.total}
|
||||
</span>
|
||||
));
|
||||
const exams = useExamStore((state) => state.exams);
|
||||
|
||||
useEffect(() => setSelectedScore(scores.find((x) => x.module === selectedModule)!), [scores, selectedModule]);
|
||||
|
||||
const moduleColors: {[key in Module]: {progress: string; inner: string}} = {
|
||||
reading: {
|
||||
progress: "text-ielts-reading",
|
||||
inner: "bg-ielts-reading-light",
|
||||
},
|
||||
listening: {
|
||||
progress: "text-ielts-listening",
|
||||
inner: "bg-ielts-listening-light",
|
||||
},
|
||||
writing: {
|
||||
progress: "text-ielts-writing",
|
||||
inner: "bg-ielts-writing-light",
|
||||
},
|
||||
speaking: {
|
||||
progress: "text-ielts-speaking",
|
||||
inner: "bg-ielts-speaking-light",
|
||||
},
|
||||
level: {
|
||||
progress: "text-ielts-level",
|
||||
inner: "bg-ielts-level-light",
|
||||
},
|
||||
};
|
||||
|
||||
const renderModuleTotal = (module: Module) => {
|
||||
const moduleScores = scores.filter((x) => x.module === module);
|
||||
if (moduleScores.length === 0) {
|
||||
return <>0</>;
|
||||
const getTotalExercises = () => {
|
||||
const exam = exams.find((x) => x.module === selectedModule)!;
|
||||
if (exam.module === "reading" || exam.module === "listening") {
|
||||
return exam.parts.flatMap((x) => x.exercises).length;
|
||||
}
|
||||
|
||||
const total = moduleScores.reduce((accumulator, current) => accumulator + current.total, 0);
|
||||
const correct = moduleScores.reduce((accumulator, current) => accumulator + current.correct, 0);
|
||||
|
||||
return (
|
||||
<span>
|
||||
{correct} / {total} | {Math.floor((correct / total) * 100)}%
|
||||
</span>
|
||||
);
|
||||
return exam.exercises.length;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative">
|
||||
<section className="h-full w-full flex flex-col items-center justify-center gap-4">
|
||||
<ProfileLevel user={user} className="h-1/2" />
|
||||
<div className="h-2/3 w-2/3 flex flex-col items-center gap-4">
|
||||
<div className="rounded-xl p-2 md:p-4 items-center flex lg:justify-center gap-2 md:gap-4 max-w-[100%] overflow-auto">
|
||||
{modules.map((module) => (
|
||||
<div
|
||||
className={`flex flex-col gap-12 min-w-[176px] items-center justify-center border-2 border-ielts-${module} bg-ielts-${module}-transparent py-4 px-2 rounded-xl text-white font-semibold`}
|
||||
key={module}>
|
||||
<div className="flex flex-col items-center">
|
||||
<Icon path={ICONS[module]} color="white" size={2} />
|
||||
<span>{module.toUpperCase()}</span>
|
||||
</div>
|
||||
<div className="flex flex-col">{renderModuleScore(module)}</div>
|
||||
<div>TOTAL: {renderModuleTotal(module)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-4 lg:gap-0 md:flex-row justify-center items-center lg:justify-between">
|
||||
<Link href="/">
|
||||
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)}>
|
||||
<div className="absolute left-4">
|
||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
||||
</div>
|
||||
Go Home
|
||||
</button>
|
||||
</Link>
|
||||
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={onViewResults}>
|
||||
View Solutions
|
||||
<div className="absolute right-4">
|
||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<>
|
||||
<div className="w-full min-h-full h-fit flex flex-col items-center justify-between gap-8">
|
||||
<ModuleTitle
|
||||
module={selectedModule}
|
||||
totalExercises={getTotalExercises()}
|
||||
exerciseIndex={getTotalExercises()}
|
||||
minTimer={exams.find((x) => x.module === selectedModule)!.minTimer}
|
||||
disableTimer
|
||||
/>
|
||||
<div className="flex gap-4 self-start">
|
||||
{modules.includes("reading") && (
|
||||
<div
|
||||
onClick={() => setSelectedModule("reading")}
|
||||
className={clsx(
|
||||
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-reading hover:text-white",
|
||||
selectedModule === "reading" ? "bg-ielts-reading text-white" : "bg-mti-gray-smoke text-ielts-reading",
|
||||
)}>
|
||||
<BsBook className="w-6 h-6" />
|
||||
<span className="font-semibold">Reading</span>
|
||||
</div>
|
||||
)}
|
||||
{modules.includes("listening") && (
|
||||
<div
|
||||
onClick={() => setSelectedModule("listening")}
|
||||
className={clsx(
|
||||
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-listening hover:text-white",
|
||||
selectedModule === "listening" ? "bg-ielts-listening text-white" : "bg-mti-gray-smoke text-ielts-listening",
|
||||
)}>
|
||||
<BsHeadphones className="w-6 h-6" />
|
||||
<span className="font-semibold">Listening</span>
|
||||
</div>
|
||||
)}
|
||||
{modules.includes("writing") && (
|
||||
<div
|
||||
onClick={() => setSelectedModule("writing")}
|
||||
className={clsx(
|
||||
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-writing hover:text-white",
|
||||
selectedModule === "writing" ? "bg-ielts-writing text-white" : "bg-mti-gray-smoke text-ielts-writing",
|
||||
)}>
|
||||
<BsPen className="w-6 h-6" />
|
||||
<span className="font-semibold">Writing</span>
|
||||
</div>
|
||||
)}
|
||||
{modules.includes("speaking") && (
|
||||
<div
|
||||
onClick={() => setSelectedModule("speaking")}
|
||||
className={clsx(
|
||||
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-speaking hover:text-white",
|
||||
selectedModule === "speaking" ? "bg-ielts-speaking text-white" : "bg-mti-gray-smoke text-ielts-speaking",
|
||||
)}>
|
||||
<BsMegaphone className="w-6 h-6" />
|
||||
<span className="font-semibold">Speaking</span>
|
||||
</div>
|
||||
)}
|
||||
{modules.includes("level") && (
|
||||
<div
|
||||
onClick={() => setSelectedModule("level")}
|
||||
className={clsx(
|
||||
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-level hover:text-white",
|
||||
selectedModule === "level" ? "bg-ielts-level text-white" : "bg-mti-gray-smoke text-ielts-level",
|
||||
)}>
|
||||
<BsClipboard className="w-6 h-6" />
|
||||
<span className="font-semibold">Level</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className="w-fit h-fit absolute top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 animate-pulse flex flex-col gap-12 items-center">
|
||||
<span className={clsx("loading loading-infinity w-32", moduleColors[selectedModule].progress)} />
|
||||
<span className={clsx("font-bold text-2xl", moduleColors[selectedModule].progress)}>Evaluating your answers...</span>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && (
|
||||
<div className="w-full flex gap-9 mt-32 items-center justify-between mb-20">
|
||||
<span className="max-w-3xl">
|
||||
{moduleResultText(calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus))}
|
||||
</span>
|
||||
<div className="flex gap-9 px-16">
|
||||
<div
|
||||
className={clsx("radial-progress overflow-hidden", moduleColors[selectedModule].progress)}
|
||||
style={
|
||||
{"--value": (selectedScore.correct / selectedScore.total) * 100, "--thickness": "12px", "--size": "13rem"} as any
|
||||
}>
|
||||
<div
|
||||
className={clsx(
|
||||
"w-48 h-48 rounded-full flex flex-col items-center justify-center",
|
||||
moduleColors[selectedModule].inner,
|
||||
)}>
|
||||
<span className="text-xl">Level</span>
|
||||
<span className="text-3xl font-bold">
|
||||
{calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex gap-2">
|
||||
<div className="w-3 h-3 bg-mti-red-light rounded-full mt-1" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-mti-red-light">
|
||||
{(((selectedScore.total - selectedScore.missing) / selectedScore.total) * 100).toFixed(0)}%
|
||||
</span>
|
||||
<span className="text-lg">Completion</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="w-3 h-3 bg-mti-purple-light rounded-full mt-1" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-mti-purple-light">{selectedScore.correct.toString().padStart(2, "0")}</span>
|
||||
<span className="text-lg">Correct</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="w-3 h-3 bg-mti-rose-light rounded-full mt-1" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-mti-rose-light">
|
||||
{(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")}
|
||||
</span>
|
||||
<span className="text-lg">Wrong</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isLoading && (
|
||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||
<div className="flex gap-8">
|
||||
<div className="w-fit flex flex-col items-center gap-1 cursor-pointer">
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="w-11 h-11 rounded-full bg-mti-purple-light hover:bg-mti-purple flex items-center justify-center transition duration-300 ease-in-out">
|
||||
<BsArrowCounterclockwise className="text-white w-7 h-7" />
|
||||
</button>
|
||||
<span>Play Again</span>
|
||||
</div>
|
||||
<div className="w-fit flex flex-col items-center gap-1 cursor-pointer">
|
||||
<button
|
||||
onClick={onViewResults}
|
||||
className="w-11 h-11 rounded-full bg-mti-purple-light hover:bg-mti-purple flex items-center justify-center transition duration-300 ease-in-out">
|
||||
<BsEyeFill className="text-white w-7 h-7" />
|
||||
</button>
|
||||
<span>Review Answers</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link href="/" className="max-w-[200px] w-full self-end">
|
||||
<Button color="purple" className="max-w-[200px] self-end w-full">
|
||||
Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
102
src/exams/Level.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import {renderExercise} from "@/components/Exercises";
|
||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||
import {renderSolution} from "@/components/Solutions";
|
||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import {LevelExam, UserSolution, WritingExam} from "@/interfaces/exam";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {defaultUserSolutions} from "@/utils/exams";
|
||||
import {countExercises} from "@/utils/moduleUtils";
|
||||
import {mdiArrowRight} from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import clsx from "clsx";
|
||||
import {Fragment, useEffect, useState} from "react";
|
||||
import {toast} from "react-toastify";
|
||||
|
||||
interface Props {
|
||||
exam: LevelExam;
|
||||
showSolutions?: boolean;
|
||||
onFinish: (userSolutions: UserSolution[]) => void;
|
||||
}
|
||||
|
||||
export default function Level({exam, showSolutions = false, onFinish}: Props) {
|
||||
const [questionIndex, setQuestionIndex] = useState(0);
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [exerciseIndex, setExerciseIndex] = useState(0);
|
||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
|
||||
|
||||
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentQuestionIndex(0);
|
||||
}, [questionIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded && exerciseIndex === -1) {
|
||||
setExerciseIndex((prev) => prev + 1);
|
||||
}
|
||||
}, [hasExamEnded, exerciseIndex]);
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
}
|
||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
||||
|
||||
if (exerciseIndex + 1 < exam.exercises.length) {
|
||||
setExerciseIndex((prev) => prev + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (exerciseIndex >= exam.exercises.length) return;
|
||||
|
||||
setHasExamEnded(false);
|
||||
|
||||
if (solution) {
|
||||
onFinish(
|
||||
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "level", exam: exam.id})),
|
||||
);
|
||||
} else {
|
||||
onFinish(userSolutions.map((x) => ({...x, module: "level", exam: exam.id})));
|
||||
}
|
||||
};
|
||||
|
||||
const previousExercise = (solution?: UserSolution) => {
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
}
|
||||
|
||||
if (exerciseIndex > 0) {
|
||||
setExerciseIndex((prev) => prev - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const getExercise = () => {
|
||||
const exercise = exam.exercises[exerciseIndex];
|
||||
return {
|
||||
...exercise,
|
||||
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col h-full w-full gap-8 items-center">
|
||||
<ModuleTitle
|
||||
minTimer={exam.minTimer}
|
||||
exerciseIndex={exerciseIndex + 1 + questionIndex + currentQuestionIndex}
|
||||
module="level"
|
||||
totalExercises={countExercises(exam.exercises)}
|
||||
disableTimer={showSolutions}
|
||||
/>
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
showSolutions &&
|
||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import {ListeningExam, UserSolution} from "@/interfaces/exam";
|
||||
import {useState} from "react";
|
||||
import Icon from "@mdi/react";
|
||||
import {mdiArrowRight} from "@mdi/js";
|
||||
import clsx from "clsx";
|
||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import {useEffect, useState} from "react";
|
||||
import {renderExercise} from "@/components/Exercises";
|
||||
import {renderSolution} from "@/components/Solutions";
|
||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||
import AudioPlayer from "@/components/Low/AudioPlayer";
|
||||
import Button from "@/components/Low/Button";
|
||||
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {defaultUserSolutions} from "@/utils/exams";
|
||||
import {countExercises} from "@/utils/moduleUtils";
|
||||
|
||||
interface Props {
|
||||
exam: ListeningExam;
|
||||
@@ -14,20 +17,68 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function Listening({exam, showSolutions = false, onFinish}: Props) {
|
||||
const [exerciseIndex, setExerciseIndex] = useState(-1);
|
||||
const [questionIndex, setQuestionIndex] = useState(0);
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [exerciseIndex, setExerciseIndex] = useState(showSolutions ? 0 : -1);
|
||||
const [partIndex, setPartIndex] = useState(0);
|
||||
const [timesListened, setTimesListened] = useState(0);
|
||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>([]);
|
||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(
|
||||
exam.parts.flatMap((x) => x.exercises).map((x) => defaultUserSolutions(x, exam)),
|
||||
);
|
||||
const [showBlankModal, setShowBlankModal] = useState(false);
|
||||
|
||||
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded && exerciseIndex === -1) {
|
||||
setExerciseIndex((prev) => prev + 1);
|
||||
}
|
||||
}, [hasExamEnded, exerciseIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentQuestionIndex(0);
|
||||
}, [questionIndex]);
|
||||
|
||||
const confirmFinishModule = (keepGoing?: boolean) => {
|
||||
if (!keepGoing) {
|
||||
setShowBlankModal(false);
|
||||
return;
|
||||
}
|
||||
|
||||
onFinish(userSolutions.map((x) => ({...x, module: "listening", exam: exam.id})));
|
||||
};
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
}
|
||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
||||
|
||||
if (exerciseIndex + 1 < exam.exercises.length) {
|
||||
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
||||
setExerciseIndex((prev) => prev + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
|
||||
setPartIndex((prev) => prev + 1);
|
||||
setExerciseIndex(showSolutions ? 0 : -1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
solution &&
|
||||
![...userSolutions.filter((x) => x.exercise !== solution?.exercise).map((x) => x.score.missing), solution?.score.missing].every(
|
||||
(x) => x === 0,
|
||||
) &&
|
||||
!showSolutions &&
|
||||
!hasExamEnded
|
||||
) {
|
||||
setShowBlankModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setHasExamEnded(false);
|
||||
|
||||
if (solution) {
|
||||
onFinish(
|
||||
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "listening", exam: exam.id})),
|
||||
@@ -37,61 +88,98 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
}
|
||||
};
|
||||
|
||||
const previousExercise = () => {
|
||||
const previousExercise = (solution?: UserSolution) => {
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
}
|
||||
|
||||
setExerciseIndex((prev) => prev - 1);
|
||||
};
|
||||
|
||||
const getExercise = () => {
|
||||
const exercise = exam.parts[partIndex].exercises[exerciseIndex];
|
||||
return {
|
||||
...exercise,
|
||||
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
||||
};
|
||||
};
|
||||
|
||||
const renderAudioPlayer = () => (
|
||||
<>
|
||||
{exerciseIndex === -1 && (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-lg font-semibold">Please listen to the following audio attentively.</span>
|
||||
{exam.audio.repeatableTimes > 0 ? (
|
||||
<span className="self-center text-sm">
|
||||
You will only be allowed to listen to the audio {exam.audio.repeatableTimes} time(s).
|
||||
</span>
|
||||
) : (
|
||||
<span className="self-center text-sm">You may listen to the audio as many times as you would like.</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-xl flex flex-col gap-4 items-center w-full overflow-auto">
|
||||
{exam.audio.repeatableTimes > 0 && (
|
||||
<>{exam.audio.repeatableTimes <= timesListened && <span>You are no longer allowed to listen to the audio again.</span>}</>
|
||||
)}
|
||||
{exam.audio.repeatableTimes > 0 && timesListened < exam.audio.repeatableTimes && (
|
||||
<audio preload="auto" controls autoPlay onPlay={() => setTimesListened((prev) => prev + 1)}>
|
||||
<source src={exam.audio.source} type="audio/mpeg" />
|
||||
Your browser does not support the audio element
|
||||
</audio>
|
||||
)}
|
||||
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
|
||||
<div className="flex flex-col w-full gap-2">
|
||||
<h4 className="text-xl font-semibold">Please listen to the following audio attentively.</h4>
|
||||
<span className="text-base">
|
||||
{exam.parts[partIndex].audio.repeatableTimes > 0
|
||||
? `You will only be allowed to listen to the audio ${exam.parts[partIndex].audio.repeatableTimes - timesListened} time(s).`
|
||||
: "You may listen to the audio as many times as you would like."}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
|
||||
<AudioPlayer
|
||||
key={partIndex}
|
||||
src={exam.parts[partIndex].audio.source}
|
||||
color="listening"
|
||||
onEnd={() => setTimesListened((prev) => prev + 1)}
|
||||
disabled={timesListened === exam.parts[partIndex].audio.repeatableTimes}
|
||||
disablePause
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full relative flex flex-col gap-8 items-center justify-center p-8 px-16 overflow-hidden">
|
||||
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
||||
<div className="flex flex-col h-full w-full gap-8 justify-between">
|
||||
<ModuleTitle
|
||||
exerciseIndex={
|
||||
(exam.parts
|
||||
.flatMap((x) => x.exercises)
|
||||
.findIndex(
|
||||
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
|
||||
) || 0) +
|
||||
(exerciseIndex === -1 ? 0 : 1) +
|
||||
questionIndex +
|
||||
currentQuestionIndex
|
||||
}
|
||||
minTimer={exam.minTimer}
|
||||
module="listening"
|
||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||
disableTimer={showSolutions}
|
||||
/>
|
||||
{renderAudioPlayer()}
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||
showSolutions &&
|
||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||
{exerciseIndex === -1 && (
|
||||
<button
|
||||
className={clsx("btn md:btn-wide w-full gap-4 relative text-white self-end", infoButtonStyle)}
|
||||
onClick={() => nextExercise()}>
|
||||
Next
|
||||
<div className="absolute right-4">
|
||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
</div>
|
||||
{exerciseIndex === -1 && partIndex > 0 && (
|
||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
|
||||
setPartIndex((prev) => prev - 1);
|
||||
}}
|
||||
className="max-w-[200px] w-full">
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{exerciseIndex === -1 && (
|
||||
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end">
|
||||
Start now
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,21 @@ import Icon from "@mdi/react";
|
||||
import {mdiArrowRight, mdiNotebook} from "@mdi/js";
|
||||
import clsx from "clsx";
|
||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import {convertCamelCaseToReadable} from "@/utils/string";
|
||||
import {Dialog, Transition} from "@headlessui/react";
|
||||
import {renderExercise} from "@/components/Exercises";
|
||||
import {renderSolution} from "@/components/Solutions";
|
||||
import {Panel} from "primereact/panel";
|
||||
import {Steps} from "primereact/steps";
|
||||
import {BsAlarm, BsBook, BsClock, BsStopwatch} from "react-icons/bs";
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||
import {Divider} from "primereact/divider";
|
||||
import Button from "@/components/Low/Button";
|
||||
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {defaultUserSolutions} from "@/utils/exams";
|
||||
import {countExercises} from "@/utils/moduleUtils";
|
||||
|
||||
interface Props {
|
||||
exam: ReadingExam;
|
||||
@@ -41,12 +51,12 @@ function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: s
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95">
|
||||
<Dialog.Panel className="w-full max-w-4xl transform rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
|
||||
<Dialog.Panel className="w-full relative max-w-4xl transform rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
<div className="mt-2 overflow-auto">
|
||||
<p className="text-sm text-gray-500">
|
||||
<div className="mt-2 overflow-auto mb-28">
|
||||
<p className="text-sm">
|
||||
{content.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
{line}
|
||||
@@ -56,13 +66,10 @@ function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: s
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex justify-center rounded-md border border-transparent bg-blue-100 px-4 py-2 text-sm font-medium text-blue-900 hover:bg-blue-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
|
||||
onClick={onClose}>
|
||||
Got it, thanks!
|
||||
</button>
|
||||
<div className="absolute bottom-8 right-8 max-w-[200px] self-end w-full">
|
||||
<Button color="purple" variant="outline" className="max-w-[200px] self-end w-full" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
@@ -74,20 +81,82 @@ function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: s
|
||||
}
|
||||
|
||||
export default function Reading({exam, showSolutions = false, onFinish}: Props) {
|
||||
const [exerciseIndex, setExerciseIndex] = useState(-1);
|
||||
const [questionIndex, setQuestionIndex] = useState(0);
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [exerciseIndex, setExerciseIndex] = useState(showSolutions ? 0 : -1);
|
||||
const [partIndex, setPartIndex] = useState(0);
|
||||
const [showTextModal, setShowTextModal] = useState(false);
|
||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>([]);
|
||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(
|
||||
exam.parts.flatMap((x) => x.exercises).map((x) => defaultUserSolutions(x, exam)),
|
||||
);
|
||||
const [showBlankModal, setShowBlankModal] = useState(false);
|
||||
|
||||
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (e: KeyboardEvent) => {
|
||||
if (e.key === "F3" || ((e.ctrlKey || e.metaKey) && e.key === "f")) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", listener);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", listener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentQuestionIndex(0);
|
||||
}, [questionIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded && exerciseIndex === -1) {
|
||||
setExerciseIndex((prev) => prev + 1);
|
||||
}
|
||||
}, [hasExamEnded, exerciseIndex]);
|
||||
|
||||
const confirmFinishModule = (keepGoing?: boolean) => {
|
||||
if (!keepGoing) {
|
||||
setShowBlankModal(false);
|
||||
return;
|
||||
}
|
||||
|
||||
onFinish(userSolutions.map((x) => ({...x, module: "reading", exam: exam.id})));
|
||||
};
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
}
|
||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
||||
|
||||
if (exerciseIndex + 1 < exam.exercises.length) {
|
||||
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
||||
setExerciseIndex((prev) => prev + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
|
||||
setPartIndex((prev) => prev + 1);
|
||||
setExerciseIndex(showSolutions ? 0 : -1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
solution &&
|
||||
![...userSolutions.filter((x) => x.exercise !== solution?.exercise).map((x) => x.score.missing), solution?.score.missing].every(
|
||||
(x) => x === 0,
|
||||
) &&
|
||||
!showSolutions &&
|
||||
!hasExamEnded
|
||||
) {
|
||||
setShowBlankModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setHasExamEnded(false);
|
||||
|
||||
if (solution) {
|
||||
onFinish(
|
||||
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "reading", exam: exam.id})),
|
||||
@@ -97,68 +166,108 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
||||
}
|
||||
};
|
||||
|
||||
const previousExercise = () => {
|
||||
const previousExercise = (solution?: UserSolution) => {
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
}
|
||||
|
||||
setExerciseIndex((prev) => prev - 1);
|
||||
};
|
||||
|
||||
const getExercise = () => {
|
||||
const exercise = exam.parts[partIndex].exercises[exerciseIndex];
|
||||
return {
|
||||
...exercise,
|
||||
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
||||
};
|
||||
};
|
||||
|
||||
const renderText = () => (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16 mt-4">
|
||||
<div className="flex flex-col w-full gap-2">
|
||||
<span className="text-base md:text-lg font-semibold">
|
||||
<h4 className="text-xl font-semibold">
|
||||
Please read the following excerpt attentively, you will then be asked questions about the text you've read.
|
||||
</span>
|
||||
<span className="self-end text-sm">You will be allowed to read the text while doing the exercises</span>
|
||||
</h4>
|
||||
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
|
||||
</div>
|
||||
<Panel header={exam.text.title}>
|
||||
<p className="overflow-auto">
|
||||
{exam.text.content.split("\\n").map((line, index) => (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<h3 className="text-xl font-semibold">{exam.parts[partIndex].text.title}</h3>
|
||||
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
|
||||
<span className="overflow-auto">
|
||||
{exam.parts[partIndex].text.content.split("\\n").map((line, index) => (
|
||||
<p key={index}>{line}</p>
|
||||
))}
|
||||
</p>
|
||||
</Panel>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextModal {...exam.text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />
|
||||
<div className="w-full h-full relative flex flex-col gap-8 items-center justify-center p-2 md:p-8 px-4 md:px-16 overflow-hidden">
|
||||
{exerciseIndex === -1 && renderText()}
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
showSolutions &&
|
||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||
<div
|
||||
className={clsx("flex gap-8", exerciseIndex > -1 ? "w-full justify-center md:justify-between" : "self-end w-full md:w-fit flex")}>
|
||||
{exerciseIndex > -1 && (
|
||||
<button
|
||||
className={clsx(
|
||||
"btn btn-wide gap-4 relative text-white",
|
||||
"border-2 border-ielts-reading hover:bg-ielts-reading hover:border-ielts-reading bg-ielts-reading-transparent",
|
||||
)}
|
||||
onClick={() => setShowTextModal(true)}>
|
||||
Read Text
|
||||
<div className="absolute right-4">
|
||||
<Icon path={mdiNotebook} color="white" size={1} />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
{exerciseIndex === -1 && (
|
||||
<button
|
||||
className={clsx("btn w-full md:btn-wide gap-4 relative text-white self-end", infoButtonStyle)}
|
||||
onClick={() => nextExercise()}>
|
||||
Next
|
||||
<div className="absolute right-4">
|
||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<div className="flex flex-col h-full w-full gap-8">
|
||||
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
||||
<TextModal {...exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />
|
||||
<ModuleTitle
|
||||
minTimer={exam.minTimer}
|
||||
exerciseIndex={
|
||||
(exam.parts
|
||||
.flatMap((x) => x.exercises)
|
||||
.findIndex(
|
||||
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
|
||||
) || 0) +
|
||||
(exerciseIndex === -1 ? 0 : 1) +
|
||||
questionIndex +
|
||||
currentQuestionIndex
|
||||
}
|
||||
module="reading"
|
||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||
disableTimer={showSolutions}
|
||||
label={exerciseIndex === -1 ? undefined : convertCamelCaseToReadable(exam.parts[partIndex].exercises[exerciseIndex].type)}
|
||||
/>
|
||||
<div className={clsx("mb-20 w-full", exerciseIndex > -1 && "grid grid-cols-2 gap-4")}>
|
||||
{renderText()}
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||
showSolutions &&
|
||||
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
</div>
|
||||
{exerciseIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={() => setShowTextModal(true)}
|
||||
className="max-w-[200px] self-end w-full absolute bottom-[31px] right-64">
|
||||
Read text
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{exerciseIndex === -1 && partIndex > 0 && (
|
||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
|
||||
setPartIndex((prev) => prev - 1);
|
||||
}}
|
||||
className="max-w-[200px] w-full">
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{exerciseIndex === -1 && partIndex === 0 && (
|
||||
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
|
||||
Start now
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Icon from "@mdi/react";
|
||||
import {mdiAccountVoice, mdiArrowLeft, mdiArrowRight, mdiBookOpen, mdiHeadphones, mdiPen} from "@mdi/js";
|
||||
import {useEffect, useState} from "react";
|
||||
import {useState} from "react";
|
||||
import {Module} from "@/interfaces";
|
||||
import clsx from "clsx";
|
||||
import {useRouter} from "next/router";
|
||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import ProfileLevel from "@/components/ProfileLevel";
|
||||
import {User} from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import {BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
||||
import {totalExamsByModule} from "@/utils/stats";
|
||||
import useStats from "@/hooks/useStats";
|
||||
import Button from "@/components/Low/Button";
|
||||
import {calculateAverageLevel} from "@/utils/score";
|
||||
import {sortByModuleName} from "@/utils/moduleUtils";
|
||||
import {capitalize} from "lodash";
|
||||
import ProfileSummary from "@/components/ProfileSummary";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
onStart: (modules: Module[]) => void;
|
||||
page: "exercises" | "exams";
|
||||
onStart: (modules: Module[], avoidRepeated: boolean) => void;
|
||||
disableSelection?: boolean;
|
||||
}
|
||||
|
||||
export default function Selection({user, onStart}: Props) {
|
||||
export default function Selection({user, page, onStart, disableSelection = false}: Props) {
|
||||
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
|
||||
|
||||
const router = useRouter();
|
||||
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
|
||||
const {stats} = useStats(user?.id);
|
||||
|
||||
const toggleModule = (module: Module) => {
|
||||
const modules = selectedModules.filter((x) => x !== module);
|
||||
@@ -27,82 +32,209 @@ export default function Selection({user, onStart}: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full h-full relative">
|
||||
<section className="h-full w-full flex flex-col items-center justify-center gap-8">
|
||||
<ProfileLevel user={user} className="h-1/2" />
|
||||
<div className="h-1/2 flex flex-col gap-8">
|
||||
<div className="h-1/2 items-center flex flex-col lg:flex-row gap-8">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => toggleModule("reading")}
|
||||
className={clsx(
|
||||
"flex flex-col gap-2 items-center justify-center",
|
||||
"border-ielts-reading hover:bg-ielts-reading text-white",
|
||||
"border-2 rounded-xl p-4 h-fit w-48 cursor-pointer",
|
||||
selectedModules.includes("reading") ? "bg-ielts-reading " : "bg-ielts-reading-transparent ",
|
||||
)}>
|
||||
<Icon path={mdiBookOpen} color="white" size={3} />
|
||||
<span>Reading</span>
|
||||
</div>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => toggleModule("listening")}
|
||||
className={clsx(
|
||||
"flex flex-col gap-2 items-center justify-center",
|
||||
"border-ielts-listening hover:bg-ielts-listening text-white",
|
||||
"border-2 rounded-xl p-4 h-fit w-48 cursor-pointer",
|
||||
selectedModules.includes("listening") ? "bg-ielts-listening " : "bg-ielts-listening-transparent ",
|
||||
)}>
|
||||
<Icon path={mdiHeadphones} color="white" size={3} />
|
||||
<span>Listening</span>
|
||||
</div>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => toggleModule("writing")}
|
||||
className={clsx(
|
||||
"flex flex-col gap-2 items-center justify-center",
|
||||
"border-ielts-writing hover:bg-ielts-writing text-white",
|
||||
"border-2 rounded-xl p-4 h-fit w-48 cursor-pointer",
|
||||
selectedModules.includes("writing") ? "bg-ielts-writing " : "bg-ielts-writing-transparent ",
|
||||
)}>
|
||||
<Icon path={mdiPen} color="white" size={3} />
|
||||
<span>Writing</span>
|
||||
</div>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => toggleModule("speaking")}
|
||||
className={clsx(
|
||||
"flex flex-col gap-2 items-center justify-center",
|
||||
"border-ielts-speaking hover:bg-ielts-speaking text-white",
|
||||
"border-2 rounded-xl p-4 h-fit w-48 cursor-pointer",
|
||||
selectedModules.includes("speaking") ? "bg-ielts-speaking " : "bg-ielts-speaking-transparent ",
|
||||
)}>
|
||||
<Icon path={mdiAccountVoice} color="white" size={3} />
|
||||
<span>Speaking</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-4 md:flex-row justify-between">
|
||||
<button onClick={() => router.push("/")} className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)}>
|
||||
<div className="absolute left-4">
|
||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
||||
</div>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
|
||||
onClick={() => onStart(selectedModules)}>
|
||||
Start
|
||||
<div className="absolute right-4">
|
||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-full relative flex flex-col gap-8 md:gap-16">
|
||||
{user && (
|
||||
<ProfileSummary
|
||||
user={user}
|
||||
items={[
|
||||
{
|
||||
icon: <BsBook className="text-ielts-reading w-6 h-6 md:w-8 md:h-8" />,
|
||||
label: "Reading",
|
||||
value: totalExamsByModule(stats, "reading"),
|
||||
},
|
||||
{
|
||||
icon: <BsHeadphones className="text-ielts-listening w-6 h-6 md:w-8 md:h-8" />,
|
||||
label: "Listening",
|
||||
value: totalExamsByModule(stats, "listening"),
|
||||
},
|
||||
{
|
||||
icon: <BsPen className="text-ielts-writing w-6 h-6 md:w-8 md:h-8" />,
|
||||
label: "Writing",
|
||||
value: totalExamsByModule(stats, "writing"),
|
||||
},
|
||||
{
|
||||
icon: <BsMegaphone className="text-ielts-speaking w-6 h-6 md:w-8 md:h-8" />,
|
||||
label: "Speaking",
|
||||
value: totalExamsByModule(stats, "speaking"),
|
||||
},
|
||||
{
|
||||
icon: <BsClipboard className="text-ielts-level w-6 h-6 md:w-8 md:h-8" />,
|
||||
label: "Level",
|
||||
value: totalExamsByModule(stats, "level"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<span className="font-bold text-lg">About {capitalize(page)}</span>
|
||||
<span className="text-mti-gray-taupe">
|
||||
{page === "exercises" && (
|
||||
<>
|
||||
In the realm of language acquisition, practice makes perfect, and our exercises are the key to unlocking your full
|
||||
potential. Dive into a world of interactive and engaging exercises that cater to diverse learning styles. From grammar
|
||||
drills that build a strong foundation to vocabulary challenges that broaden your lexicon, our exercises are carefully
|
||||
designed to make learning English both enjoyable and effective. Whether you're looking to reinforce specific
|
||||
skills or embark on a holistic language journey, our exercises are your companions in the pursuit of excellence.
|
||||
Embrace the joy of learning as you navigate through a variety of activities that cater to every facet of language
|
||||
acquisition. Your linguistic adventure starts here!
|
||||
</>
|
||||
)}
|
||||
{page === "exams" && (
|
||||
<>
|
||||
Welcome to the heart of success on your English language journey! Our exams are crafted with precision to assess and
|
||||
enhance your language skills. Each test is a passport to your linguistic prowess, designed to challenge and elevate
|
||||
your abilities. Whether you're a beginner or a seasoned learner, our exams cater to all levels, providing a
|
||||
comprehensive evaluation of your reading, writing, speaking, and listening skills. Prepare to embark on a journey of
|
||||
self-discovery and language mastery as you navigate through our thoughtfully curated exams. Your success is not just a
|
||||
destination; it's a testament to your dedication and our commitment to empowering you with the English language.
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</section>
|
||||
<section className="w-full flex -lg:flex-col -lg:items-center -lg:gap-12 justify-between gap-8 mt-8">
|
||||
<div
|
||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
|
||||
className={clsx(
|
||||
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
||||
selectedModules.includes("reading") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||
)}>
|
||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-0 -translate-y-1/2">
|
||||
<BsBook className="text-white w-7 h-7" />
|
||||
</div>
|
||||
<span className="font-semibold">Reading:</span>
|
||||
<p className="text-center text-xs">
|
||||
Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
|
||||
</p>
|
||||
{!selectedModules.includes("reading") && !selectedModules.includes("level") && !disableSelection && (
|
||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
||||
)}
|
||||
{(selectedModules.includes("reading") || disableSelection) && (
|
||||
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
|
||||
</div>
|
||||
<div
|
||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
|
||||
className={clsx(
|
||||
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
||||
selectedModules.includes("listening") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||
)}>
|
||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-0 -translate-y-1/2">
|
||||
<BsHeadphones className="text-white w-7 h-7" />
|
||||
</div>
|
||||
<span className="font-semibold">Listening:</span>
|
||||
<p className="text-center text-xs">
|
||||
Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
|
||||
</p>
|
||||
{!selectedModules.includes("listening") && !selectedModules.includes("level") && !disableSelection && (
|
||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
||||
)}
|
||||
{(selectedModules.includes("listening") || disableSelection) && (
|
||||
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
|
||||
</div>
|
||||
<div
|
||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
|
||||
className={clsx(
|
||||
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
||||
selectedModules.includes("writing") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||
)}>
|
||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-0 -translate-y-1/2">
|
||||
<BsPen className="text-white w-7 h-7" />
|
||||
</div>
|
||||
<span className="font-semibold">Writing:</span>
|
||||
<p className="text-center text-xs">
|
||||
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
|
||||
</p>
|
||||
{!selectedModules.includes("writing") && !selectedModules.includes("level") && !disableSelection && (
|
||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
||||
)}
|
||||
{(selectedModules.includes("writing") || disableSelection) && (
|
||||
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
|
||||
</div>
|
||||
<div
|
||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
|
||||
className={clsx(
|
||||
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
||||
selectedModules.includes("speaking") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||
)}>
|
||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-0 -translate-y-1/2">
|
||||
<BsMegaphone className="text-white w-7 h-7" />
|
||||
</div>
|
||||
<span className="font-semibold">Speaking:</span>
|
||||
<p className="text-center text-xs">
|
||||
You'll have access to interactive dialogs, pronunciation exercises and speech recordings.
|
||||
</p>
|
||||
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && !disableSelection && (
|
||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
||||
)}
|
||||
{(selectedModules.includes("speaking") || disableSelection) && (
|
||||
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
|
||||
</div>
|
||||
{!disableSelection && (
|
||||
<div
|
||||
onClick={selectedModules.length === 0 || selectedModules.includes("level") ? () => toggleModule("level") : undefined}
|
||||
className={clsx(
|
||||
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
||||
selectedModules.includes("level") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||
)}>
|
||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-0 -translate-y-1/2">
|
||||
<BsClipboard className="text-white w-7 h-7" />
|
||||
</div>
|
||||
<span className="font-semibold">Level:</span>
|
||||
<p className="text-center text-xs">You'll be able to test your english level with multiple choice questions.</p>
|
||||
{!selectedModules.includes("level") && selectedModules.length === 0 && !disableSelection && (
|
||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
||||
)}
|
||||
{(selectedModules.includes("level") || disableSelection) && (
|
||||
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
|
||||
)}
|
||||
{!selectedModules.includes("level") && selectedModules.length > 0 && (
|
||||
<BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<div className="flex w-full -md:flex-col -md:gap-4 -md:justify-center md:justify-between items-center">
|
||||
<div
|
||||
className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer tooltip w-full -md:justify-center"
|
||||
data-tip="If possible, the platform will choose exams not yet done"
|
||||
onClick={() => setAvoidRepeatedExams((prev) => !prev)}>
|
||||
<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",
|
||||
avoidRepeatedExams && "!bg-mti-purple-light ",
|
||||
)}>
|
||||
<BsCheck color="white" className="w-full h-full" />
|
||||
</div>
|
||||
<span>Avoid Repeated Questions</span>
|
||||
</div>
|
||||
<div className="tooltip w-full" data-tip={`Your screen size is too small to do ${page}`}>
|
||||
<Button color="purple" className="px-12 w-full max-w-xs md:hidden" disabled>
|
||||
Start Exam
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() =>
|
||||
onStart(
|
||||
!disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"],
|
||||
avoidRepeatedExams,
|
||||
)
|
||||
}
|
||||
color="purple"
|
||||
className="px-12 w-full max-w-xs md:self-end -md:hidden"
|
||||
disabled={selectedModules.length === 0 && !disableSelection}>
|
||||
Start Exam
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import {renderExercise} from "@/components/Exercises";
|
||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||
import {renderSolution} from "@/components/Solutions";
|
||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import {UserSolution, SpeakingExam} from "@/interfaces/exam";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {defaultUserSolutions} from "@/utils/exams";
|
||||
import {countExercises} from "@/utils/moduleUtils";
|
||||
import {convertCamelCaseToReadable} from "@/utils/string";
|
||||
import {mdiArrowRight} from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import clsx from "clsx";
|
||||
@@ -15,19 +20,31 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function Speaking({exam, showSolutions = false, onFinish}: Props) {
|
||||
const [questionIndex, setQuestionIndex] = useState(0);
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [exerciseIndex, setExerciseIndex] = useState(0);
|
||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>([]);
|
||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
|
||||
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentQuestionIndex(0);
|
||||
}, [questionIndex]);
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
}
|
||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
||||
|
||||
if (exerciseIndex + 1 < exam.exercises.length) {
|
||||
setExerciseIndex((prev) => prev + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (exerciseIndex >= exam.exercises.length) return;
|
||||
|
||||
setHasExamEnded(false);
|
||||
|
||||
if (solution) {
|
||||
onFinish(
|
||||
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "speaking", exam: exam.id})),
|
||||
@@ -37,28 +54,44 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
||||
}
|
||||
};
|
||||
|
||||
const previousExercise = () => {
|
||||
setExerciseIndex((prev) => prev - 1);
|
||||
const previousExercise = (solution?: UserSolution) => {
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
}
|
||||
|
||||
if (exerciseIndex > 0) {
|
||||
setExerciseIndex((prev) => prev - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const getExercise = () => {
|
||||
const exercise = exam.exercises[exerciseIndex];
|
||||
return {
|
||||
...exercise,
|
||||
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative flex flex-col gap-8 items-center justify-center p-8 px-16 overflow-hidden">
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
showSolutions &&
|
||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||
{exerciseIndex === -1 && (
|
||||
<button className={clsx("btn btn-wide gap-4 relative text-white self-end", infoButtonStyle)} onClick={() => nextExercise()}>
|
||||
Next
|
||||
<div className="absolute right-4">
|
||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
<div className="flex flex-col h-full w-full gap-8 items-center">
|
||||
<ModuleTitle
|
||||
label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
|
||||
minTimer={exam.minTimer}
|
||||
exerciseIndex={exerciseIndex + 1 + questionIndex + currentQuestionIndex}
|
||||
module="speaking"
|
||||
totalExercises={countExercises(exam.exercises)}
|
||||
disableTimer={showSolutions}
|
||||
/>
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
showSolutions &&
|
||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import {renderExercise} from "@/components/Exercises";
|
||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||
import {renderSolution} from "@/components/Solutions";
|
||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import {UserSolution, WritingExam} from "@/interfaces/exam";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {defaultUserSolutions} from "@/utils/exams";
|
||||
import {countExercises} from "@/utils/moduleUtils";
|
||||
import {mdiArrowRight} from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import clsx from "clsx";
|
||||
@@ -16,7 +20,9 @@ interface Props {
|
||||
|
||||
export default function Writing({exam, showSolutions = false, onFinish}: Props) {
|
||||
const [exerciseIndex, setExerciseIndex] = useState(0);
|
||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>([]);
|
||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
|
||||
|
||||
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
if (solution) {
|
||||
@@ -28,6 +34,10 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
|
||||
return;
|
||||
}
|
||||
|
||||
if (exerciseIndex >= exam.exercises.length) return;
|
||||
|
||||
setHasExamEnded(false);
|
||||
|
||||
if (solution) {
|
||||
onFinish(
|
||||
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "writing", exam: exam.id})),
|
||||
@@ -37,28 +47,43 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
|
||||
}
|
||||
};
|
||||
|
||||
const previousExercise = () => {
|
||||
setExerciseIndex((prev) => prev - 1);
|
||||
const previousExercise = (solution?: UserSolution) => {
|
||||
if (solution) {
|
||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||
}
|
||||
|
||||
if (exerciseIndex > 0) {
|
||||
setExerciseIndex((prev) => prev - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const getExercise = () => {
|
||||
const exercise = exam.exercises[exerciseIndex];
|
||||
return {
|
||||
...exercise,
|
||||
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative flex flex-col gap-8 items-center justify-center p-8 px-16 overflow-hidden">
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
showSolutions &&
|
||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||
{exerciseIndex === -1 && (
|
||||
<button className={clsx("btn btn-wide gap-4 relative text-white self-end", infoButtonStyle)} onClick={() => nextExercise()}>
|
||||
Next
|
||||
<div className="absolute right-4">
|
||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
<div className="flex flex-col h-full w-full gap-8 items-center">
|
||||
<ModuleTitle
|
||||
minTimer={exam.minTimer}
|
||||
exerciseIndex={exerciseIndex + 1}
|
||||
module="writing"
|
||||
totalExercises={countExercises(exam.exercises)}
|
||||
disableTimer={showSolutions}
|
||||
/>
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), nextExercise, previousExercise)}
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
showSolutions &&
|
||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {initializeApp} from "firebase/app";
|
||||
import {getFirestore} from "firebase/firestore";
|
||||
import * as admin from "firebase-admin/app";
|
||||
|
||||
const serviceAccount = require("@/constants/serviceAccountKey.json");
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: process.env.FIREBASE_PUBLIC_API_KEY || "",
|
||||
@@ -8,7 +10,12 @@ const firebaseConfig = {
|
||||
storageBucket: process.env.FIREBASE_STORAGE_BUCKET || "",
|
||||
messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID || "",
|
||||
appId: process.env.FIREBASE_APP_ID || "",
|
||||
measurementId: process.env.FIREBASE_MEASUREMENT_ID || "",
|
||||
};
|
||||
|
||||
export const app = initializeApp(firebaseConfig);
|
||||
export const app = initializeApp(firebaseConfig, Math.random().toString());
|
||||
export const adminApp = admin.initializeApp(
|
||||
{
|
||||
credential: admin.cert(serviceAccount),
|
||||
},
|
||||
Math.random().toString(),
|
||||
);
|
||||
|
||||
32
src/hooks/useAssignments.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import {Assignment} from "@/interfaces/results";
|
||||
import axios from "axios";
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
export default function useAssignments({assigner, assignees}: {assigner?: string; assignees?: string}) {
|
||||
const [assignments, setAssignments] = useState<Assignment[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const getData = () => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<Assignment[]>("/api/assignments")
|
||||
.then((response) => {
|
||||
if (assigner) {
|
||||
setAssignments(response.data.filter((a) => a.assigner === assigner));
|
||||
return;
|
||||
}
|
||||
if (assignees) {
|
||||
setAssignments(response.data.filter((a) => a.assignees.filter((x) => assignees.includes(x)).length > 0));
|
||||
return;
|
||||
}
|
||||
|
||||
setAssignments(response.data);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
useEffect(getData, [assignees, assigner]);
|
||||
|
||||
return {assignments, isLoading, isError, reload: getData};
|
||||
}
|
||||
21
src/hooks/useExams.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import {Exam} from "@/interfaces/exam";
|
||||
import axios from "axios";
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
export default function useExams() {
|
||||
const [exams, setExams] = useState<Exam[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const getData = () => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<Exam[]>("/api/exam")
|
||||
.then((response) => setExams(response.data))
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
useEffect(getData, []);
|
||||
|
||||
return {exams, isLoading, isError, reload: getData};
|
||||
}
|
||||
26
src/hooks/useGroups.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import {Group, User} from "@/interfaces/user";
|
||||
import axios from "axios";
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
export default function useGroups(admin?: string) {
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const getData = () => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<Group[]>("/api/groups")
|
||||
.then((response) => {
|
||||
const filter = (g: Group) => g.admin === admin || g.participants.includes(admin || "");
|
||||
|
||||
const filteredGroups = admin ? response.data.filter(filter) : response.data;
|
||||
return setGroups(admin ? filteredGroups.map((g) => ({...g, disableEditing: g.disableEditing || g.admin !== admin})) : filteredGroups);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
useEffect(getData, [admin]);
|
||||
|
||||
return {groups, isLoading, isError, reload: getData};
|
||||
}
|
||||
22
src/hooks/usePackages.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import {Exam} from "@/interfaces/exam";
|
||||
import {Package} from "@/interfaces/paypal";
|
||||
import axios from "axios";
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
export default function usePackages() {
|
||||
const [packages, setPackages] = useState<Package[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const getData = () => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<Package[]>("/api/packages")
|
||||
.then((response) => setPackages(response.data))
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
useEffect(getData, []);
|
||||
|
||||
return {packages, isLoading, isError, reload: getData};
|
||||
}
|
||||
24
src/hooks/usePayments.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import {Payment} from "@/interfaces/paypal";
|
||||
import {Group, User} from "@/interfaces/user";
|
||||
import axios from "axios";
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
export default function usePayments() {
|
||||
const [payments, setPayments] = useState<Payment[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const getData = () => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<Payment[]>("/api/payments")
|
||||
.then((response) => {
|
||||
return setPayments(response.data);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
useEffect(getData, []);
|
||||
|
||||
return {payments, isLoading, isError, reload: getData};
|
||||
}
|
||||
@@ -16,9 +16,9 @@ export default function useUser({redirectTo = "", redirectIfFound = false} = {})
|
||||
|
||||
if (
|
||||
// If redirectTo is set, redirect if the user was not found.
|
||||
(redirectTo && !redirectIfFound && !user) ||
|
||||
(redirectTo && !redirectIfFound && (!user || (user && !user.isVerified))) ||
|
||||
// If redirectIfFound is also set, redirect if the user was found
|
||||
(redirectIfFound && user)
|
||||
(redirectIfFound && user && user.isVerified)
|
||||
) {
|
||||
Router.push(redirectTo);
|
||||
}
|
||||
|
||||
@@ -7,13 +7,15 @@ export default function useUsers() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const getData = () => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<User[]>("/api/users/list")
|
||||
.get<User[]>("/api/users/list", {headers: {page: "register"}})
|
||||
.then((response) => setUsers(response.data))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
};
|
||||
|
||||
return {users, isLoading, isError};
|
||||
useEffect(getData, []);
|
||||
|
||||
return {users, isLoading, isError, reload: getData};
|
||||
}
|
||||
|
||||
@@ -1,30 +1,46 @@
|
||||
import {Module} from ".";
|
||||
|
||||
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam;
|
||||
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
|
||||
|
||||
export interface ReadingExam {
|
||||
text: {
|
||||
title: string;
|
||||
content: string;
|
||||
};
|
||||
parts: ReadingPart[];
|
||||
id: string;
|
||||
exercises: Exercise[];
|
||||
module: "reading";
|
||||
minTimer: number;
|
||||
type: "academic" | "general";
|
||||
isDiagnostic: boolean;
|
||||
}
|
||||
|
||||
export interface ReadingPart {
|
||||
text: {
|
||||
title: string;
|
||||
content: string;
|
||||
};
|
||||
exercises: Exercise[];
|
||||
}
|
||||
|
||||
export interface LevelExam {
|
||||
module: "level";
|
||||
id: string;
|
||||
exercises: Exercise[];
|
||||
minTimer: number;
|
||||
isDiagnostic: boolean;
|
||||
}
|
||||
|
||||
export interface ListeningExam {
|
||||
parts: ListeningPart[];
|
||||
id: string;
|
||||
module: "listening";
|
||||
minTimer: number;
|
||||
isDiagnostic: boolean;
|
||||
}
|
||||
|
||||
export interface ListeningPart {
|
||||
audio: {
|
||||
source: string;
|
||||
repeatableTimes: number; // *The amount of times the user is allowed to repeat the audio, 0 for unlimited
|
||||
};
|
||||
id: string;
|
||||
exercises: Exercise[];
|
||||
module: "listening";
|
||||
minTimer: number;
|
||||
isDiagnostic: boolean;
|
||||
}
|
||||
|
||||
export interface UserSolution {
|
||||
@@ -35,6 +51,7 @@ export interface UserSolution {
|
||||
score: {
|
||||
correct: number;
|
||||
total: number;
|
||||
missing: number;
|
||||
};
|
||||
exercise: string;
|
||||
}
|
||||
@@ -62,22 +79,45 @@ export interface SpeakingExam {
|
||||
|
||||
export type Exercise =
|
||||
| FillBlanksExercise
|
||||
| TrueFalseExercise
|
||||
| MatchSentencesExercise
|
||||
| MultipleChoiceExercise
|
||||
| WriteBlanksExercise
|
||||
| WritingExercise
|
||||
| SpeakingExercise;
|
||||
| SpeakingExercise
|
||||
| InteractiveSpeakingExercise;
|
||||
|
||||
export interface Evaluation {
|
||||
comment: string;
|
||||
overall: number;
|
||||
task_response: {[key: string]: number};
|
||||
}
|
||||
|
||||
interface InteractiveSpeakingEvaluation extends Evaluation {
|
||||
perfect_answer_1?: string;
|
||||
perfect_answer_2?: string;
|
||||
perfect_answer_3?: string;
|
||||
}
|
||||
|
||||
interface CommonEvaluation extends Evaluation {
|
||||
perfect_answer?: string;
|
||||
}
|
||||
|
||||
export interface WritingExercise {
|
||||
id: string;
|
||||
type: "writing";
|
||||
info: string; //* The information about the task, like the amount of time they should spend on it
|
||||
prefix: string; //* The information about the task, like the amount of time they should spend on it
|
||||
suffix: string;
|
||||
prompt: string; //* The context given to the user containing what they should write about
|
||||
wordCounter: WordCounter; //* The minimum or maximum amount of words that should be written
|
||||
attachment?: string; //* The url for an image to work as an attachment to show the user
|
||||
attachment?: {
|
||||
url: string;
|
||||
description: string;
|
||||
}; //* The url for an image to work as an attachment to show the user
|
||||
userSolutions: {
|
||||
id: string;
|
||||
solution: string;
|
||||
evaluation?: CommonEvaluation;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -87,9 +127,24 @@ export interface SpeakingExercise {
|
||||
title: string;
|
||||
text: string;
|
||||
prompts: string[];
|
||||
video_url: string;
|
||||
userSolutions: {
|
||||
id: string;
|
||||
solution: string;
|
||||
evaluation?: CommonEvaluation;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface InteractiveSpeakingExercise {
|
||||
id: string;
|
||||
type: "interactiveSpeaking";
|
||||
title: string;
|
||||
text: string;
|
||||
prompts: {text: string; video_url: string}[];
|
||||
userSolutions: {
|
||||
id: string;
|
||||
solution: {question: string; answer: string}[];
|
||||
evaluation?: InteractiveSpeakingEvaluation;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -110,6 +165,20 @@ export interface FillBlanksExercise {
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface TrueFalseExercise {
|
||||
type: "trueFalse";
|
||||
id: string;
|
||||
prompt: string; // *EXAMPLE: "Select the appropriate option."
|
||||
questions: TrueFalseQuestion[];
|
||||
userSolutions: {id: string; solution: "true" | "false" | "not_given"}[];
|
||||
}
|
||||
|
||||
export interface TrueFalseQuestion {
|
||||
id: string; // *EXAMPLE: "1"
|
||||
prompt: string; // *EXAMPLE: "What does her briefcase look like?"
|
||||
solution: "true" | "false" | "not_given"; // *EXAMPLE: "True"
|
||||
}
|
||||
|
||||
export interface WriteBlanksExercise {
|
||||
prompt: string; // *EXAMPLE: "Complete the notes below by writing NO MORE THAN THREE WORDS in the spaces provided."
|
||||
maxWords: number; // *EXAMPLE: 3 - The maximum amount of words allowed per blank, 0 for unlimited
|
||||
|
||||
@@ -1 +1 @@
|
||||
export type Module = "reading" | "listening" | "writing" | "speaking";
|
||||
export type Module = "reading" | "listening" | "writing" | "speaking" | "level";
|
||||
|
||||
35
src/interfaces/paypal.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export interface TokenSuccess {
|
||||
scope: string;
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
app_id: string;
|
||||
expires_in: number;
|
||||
nonce: string;
|
||||
}
|
||||
|
||||
export interface TokenError {
|
||||
error: string;
|
||||
error_description: string;
|
||||
}
|
||||
|
||||
export interface Package {
|
||||
id: string;
|
||||
currency: string;
|
||||
duration: number;
|
||||
duration_unit: DurationUnit;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export type DurationUnit = "weeks" | "days" | "months" | "years";
|
||||
|
||||
export interface Payment {
|
||||
id: string;
|
||||
corporate: string;
|
||||
agent?: string;
|
||||
agentCommission: number;
|
||||
agentValue: number;
|
||||
currency: string;
|
||||
value: number;
|
||||
isPaid: boolean;
|
||||
date: Date;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {Module} from "@/interfaces";
|
||||
import {Stat} from "./user";
|
||||
|
||||
export type UserResults = {[key in Module]: ModuleResult};
|
||||
|
||||
@@ -7,3 +8,18 @@ interface ModuleResult {
|
||||
score: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface Assignment {
|
||||
id: string;
|
||||
name: string;
|
||||
assigner: string;
|
||||
assignees: string[];
|
||||
results: {
|
||||
user: string;
|
||||
type: "academic" | "general";
|
||||
stats: Stat[];
|
||||
}[];
|
||||
exams: {id: string; module: Module, assignee: string}[];
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||