diff --git a/next.config.js b/next.config.js index cdba31d8..2203b000 100644 --- a/next.config.js +++ b/next.config.js @@ -1,7 +1,7 @@ /** @type {import('next').NextConfig} */ const websiteUrl = process.env.NODE_ENV === 'production' ? "https://encoach.com" : "http://localhost:3000"; const nextConfig = { - reactStrictMode: true, + reactStrictMode: false, output: "standalone", async headers() { return [ diff --git a/package-lock.json b/package-lock.json index 6503e655..6fab7055 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "express-handlebars": "^7.1.2", "firebase": "9.19.1", "firebase-admin": "^11.10.1", + "firebase-scrypt": "^2.2.0", "formidable": "^3.5.0", "formidable-serverless": "^1.1.1", "framer-motion": "^9.0.2", @@ -4056,6 +4057,20 @@ "resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz", "integrity": "sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw==" }, + "node_modules/babel-runtime": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", + "integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==", + "dependencies": { + "core-js": "^2.4.0", + "regenerator-runtime": "^0.11.0" + } + }, + "node_modules/babel-runtime/node_modules/regenerator-runtime": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -4619,6 +4634,13 @@ "node": ">= 0.6" } }, + "node_modules/core-js": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", + "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", + "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", + "hasInstallScript": true + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -6224,6 +6246,17 @@ "@google-cloud/storage": "^6.9.5" } }, + "node_modules/firebase-scrypt": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/firebase-scrypt/-/firebase-scrypt-2.2.0.tgz", + "integrity": "sha512-36vJZVPFepErsNw+nBjb9cpM9wYPtcxk1bKN//vLdVkNPhaw1cogzwxtMs0s+dYg1gvBDakg2Q4ch8zAWAvnxA==", + "dependencies": { + "babel-runtime": "^6.26.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/firebase/node_modules/@firebase/util": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.9.3.tgz", @@ -14697,6 +14730,22 @@ "resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz", "integrity": "sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw==" }, + "babel-runtime": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", + "integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==", + "requires": { + "core-js": "^2.4.0", + "regenerator-runtime": "^0.11.0" + }, + "dependencies": { + "regenerator-runtime": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==" + } + } + }, "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -15083,6 +15132,11 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==" }, + "core-js": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", + "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==" + }, "core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -16352,6 +16406,14 @@ "uuid": "^9.0.0" } }, + "firebase-scrypt": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/firebase-scrypt/-/firebase-scrypt-2.2.0.tgz", + "integrity": "sha512-36vJZVPFepErsNw+nBjb9cpM9wYPtcxk1bKN//vLdVkNPhaw1cogzwxtMs0s+dYg1gvBDakg2Q4ch8zAWAvnxA==", + "requires": { + "babel-runtime": "^6.26.0" + } + }, "flat-cache": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", diff --git a/package.json b/package.json index c351d119..abc793ef 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@use-gesture/react": "^10.3.1", - "axios": "^1.3.5", + "axios": "^1", + "axios-cache-interceptor": "^1", "bcrypt": "^5.1.1", "chart.js": "^4.2.1", "class-variance-authority": "^0.7.0", @@ -43,6 +44,7 @@ "express-handlebars": "^7.1.2", "firebase": "9.19.1", "firebase-admin": "^11.10.1", + "firebase-scrypt": "^2.2.0", "formidable": "^3.5.0", "formidable-serverless": "^1.1.1", "framer-motion": "^9.0.2", @@ -79,7 +81,7 @@ "read-excel-file": "^5.7.1", "short-unique-id": "5.0.2", "stripe": "^13.10.0", - "swr": "^2.1.3", + "swr": "^2.2.5", "tailwind-merge": "^2.5.2", "tailwind-scrollbar-hide": "^1.1.7", "tailwindcss-animate": "^1.0.7", @@ -90,6 +92,7 @@ "zustand": "^4.3.6" }, "devDependencies": { + "@simbathesailor/use-what-changed": "^2.0.0", "@types/blob-stream": "^0.1.33", "@types/formidable": "^3.4.0", "@types/howler": "^2.2.11", diff --git a/src/components/DemographicInformationInput.tsx b/src/components/DemographicInformationInput.tsx index 2505b919..a042cf19 100644 --- a/src/components/DemographicInformationInput.tsx +++ b/src/components/DemographicInformationInput.tsx @@ -21,14 +21,18 @@ interface Props { } export default function DemographicInformationInput({user, mutateUser}: Props) { - const [country, setCountry] = useState(); - const [phone, setPhone] = useState(); + const [country, setCountry] = useState(user.demographicInformation?.country); + const [phone, setPhone] = useState(user.demographicInformation?.phone); const [passport_id, setPassportID] = useState(user.type === "student" ? user.demographicInformation?.passport_id : undefined); const [gender, setGender] = useState(); const [employment, setEmployment] = useState(); - const [position, setPosition] = useState(); const [timezone, setTimezone] = useState(moment.tz.guess()); const [isLoading, setIsLoading] = useState(false); + const [position, setPosition] = useState( + user.type === "corporate" || user.type === "mastercorporate" + ? user.demographicInformation?.position + : user.demographicInformation?.employment, + ); const [companyName, setCompanyName] = useState(); const [commercialRegistration, setCommercialRegistration] = useState(); @@ -85,7 +89,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) { - setPhone(e)} placeholder="Enter phone number" required /> + setPhone(e)} value={phone} placeholder="Enter phone number" required /> {user.type === "student" && ( void; + selectedValue?: string; + className?: string; + width: number; + isOpen: boolean; + onToggle: (id: string) => void; +} + +const MCDropdown: React.FC = ({ + id, + options, + onSelect, + selectedValue, + className = "relative", + width, + isOpen, + onToggle, +}) => { + const contentRef = useRef(null); + const [contentHeight, setContentHeight] = useState(0); + + useEffect(() => { + if (contentRef.current) { + setContentHeight(contentRef.current.scrollHeight); + } + }, [options]); + + const springProps = useSpring({ + height: isOpen ? contentHeight : 0, + opacity: isOpen ? 1 : 0, + config: { tension: 300, friction: 30 } + }); + + return ( +
+ + +
+ {Object.entries(options).sort((a, b) => a[0].localeCompare(b[0])).map(([key, value]) => ( +
{ + onSelect(value); + onToggle(id); + }} + className="p-4 hover:bg-mti-purple-ultralight cursor-pointer whitespace-nowrap" + > + {value} +
+ ))} +
+
+
+ ); +}; + +export default MCDropdown; \ No newline at end of file diff --git a/src/components/Exercises/FillBlanks/index.tsx b/src/components/Exercises/FillBlanks/index.tsx index 063ef0b1..db32c626 100644 --- a/src/components/Exercises/FillBlanks/index.tsx +++ b/src/components/Exercises/FillBlanks/index.tsx @@ -1,250 +1,239 @@ import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam"; import useExamStore from "@/stores/examStore"; import clsx from "clsx"; -import { Fragment, useCallback, useEffect, useMemo, useState } from "react"; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import reactStringReplace from "react-string-replace"; import { CommonProps } from ".."; import Button from "../../Low/Button"; import { v4 } from "uuid"; - +import MCDropdown from "./MCDropdown"; const FillBlanks: React.FC = ({ - id, - type, - prompt, - solutions, - text, - words, - userSolutions, - variant, - onNext, - onBack, + id, + type, + prompt, + solutions, + text, + words, + userSolutions, + variant, + onNext, + onBack, }) => { - const { shuffles, exam, partIndex, questionIndex, exerciseIndex } = useExamStore((state) => state); - const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions); - const hasExamEnded = useExamStore((state) => state.hasExamEnded); - const setCurrentSolution = useExamStore((state) => state.setCurrentSolution); - const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles; + const { shuffles, exam, partIndex, questionIndex, exerciseIndex } = useExamStore((state) => state); + const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions); + const hasExamEnded = useExamStore((state) => state.hasExamEnded); + const setCurrentSolution = useExamStore((state) => state.setCurrentSolution); + const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles; + const dropdownRef = useRef(null); - const [currentMCSelection, setCurrentMCSelection] = useState<{ id: string, selection: FillBlanksMCOption }>(); + const excludeWordMCType = (x: any) => { + return typeof x === "string" ? x : (x as { letter: string; word: string }); + }; - const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => { - return Array.isArray(words) && words.every( - word => word && typeof word === 'object' && 'id' in word && 'options' in word - ); - } + useEffect(() => { + if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hasExamEnded]); - const excludeWordMCType = (x: any) => { - return typeof x === "string" ? x : x as { letter: string; word: string }; - } + let correctWords: any; + if (exam && (exam.module === "level" || exam.module === "reading") && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") { + correctWords = (exam.parts[partIndex].exercises[exerciseIndex] as FillBlanksExercise).words; + } - useEffect(() => { - if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hasExamEnded]); + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setOpenDropdownId(null); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + const calculateScore = () => { + const total = text.match(/({{\d+}})/g)?.length || 0; + const correct = answers!.filter((x) => { + const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution; + if (!solution) return false; + const option = correctWords!.find((w: any) => { + if (typeof w === "string") { + return w.toLowerCase() === x.solution.toLowerCase(); + } else if ("letter" in w) { + return w.letter.toLowerCase() === x.solution.toLowerCase(); + } else { + return w.id.toString() === x.id.toString(); + } + }); + if (!option) return false; - let correctWords: any; - if (exam && (exam.module === "level" || exam.module === "reading") && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") { - correctWords = (exam.parts[partIndex].exercises[exerciseIndex] as FillBlanksExercise).words; - } + if (typeof option === "string") { + return solution.toLowerCase() === option.toLowerCase(); + } else if ("letter" in option) { + return solution.toLowerCase() === option.word.toLowerCase(); + } else if ("options" in option) { + return option.options[solution as keyof typeof option.options] == x.solution; + } + return false; + }).length; + const missing = total - answers!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length; + return { total, correct, missing }; + }; - const calculateScore = () => { - const total = text.match(/({{\d+}})/g)?.length || 0; - const correct = answers!.filter((x) => { - const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution; - if (!solution) return false; - const option = correctWords!.find((w: any) => { - if (typeof w === "string") { - return w.toLowerCase() === x.solution.toLowerCase(); - } else if ('letter' in w) { - return w.letter.toLowerCase() === x.solution.toLowerCase(); - } else { - return w.id.toString() === x.id.toString(); - } - }); - if (!option) return false; + const [openDropdownId, setOpenDropdownId] = useState(null); - if (typeof option === "string") { - return solution.toLowerCase() === option.toLowerCase(); - } else if ('letter' in option) { - return solution.toLowerCase() === option.word.toLowerCase(); - } else if ('options' in option) { - return option.options[solution as keyof typeof option.options] == x.solution; - } - return false; - }).length; - const missing = total - answers!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length; - return { total, correct, missing }; - }; - const renderLines = useCallback((line: string) => { - return ( -
- {reactStringReplace(line, /({{\d+}})/g, (match) => { - const id = match.replaceAll(/[\{\}]/g, ""); - const userSolution = answers.find((x) => x.id === id); - const styles = clsx( - "rounded-full hover:text-white transition duration-300 ease-in-out my-1 px-5 py-2 text-center", - currentMCSelection?.id == id && "!bg-mti-purple !text-white !outline-none !ring-0", - !userSolution && "text-center text-mti-purple-light bg-mti-purple-ultralight", - userSolution && "text-center text-mti-purple-dark bg-mti-purple-ultralight", - ) - return ( - variant === "mc" ? ( - <> - {/*{`(${id})`}*/} - - - ) : ( - setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution: e.target.value }])} - value={userSolution?.solution} /> - ) - ); - })} -
- ); - }, [variant, words, setCurrentMCSelection, answers, currentMCSelection]); + const renderLines = useCallback( + (line: string) => { + return ( +
+ {reactStringReplace(line, /({{\d+}})/g, (match) => { + const id = match.replaceAll(/[\{\}]/g, ""); + const userSolution = answers.find((x) => x.id === id); + const styles = clsx( + "rounded-full hover:text-white transition duration-300 ease-in-out my-1 px-5 py-2 text-center w-fit", + !userSolution && "text-center text-mti-purple-light bg-mti-purple-ultralight", + userSolution && "text-center text-mti-purple-dark bg-mti-purple-ultralight", + ); - const memoizedLines = useMemo(() => { - return text.split("\\n").map((line, index) => ( -

- {renderLines(line)} -
-

- )); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [text, variant, renderLines, currentMCSelection]); + const currentSelection = words.find((x) => { + if (typeof x !== "string" && "id" in x) { + return (x as FillBlanksMCOption).id.toString() == id.toString(); + } + return false; + }) as FillBlanksMCOption; + return variant === "mc" ? ( + onSelection(id, value)} + selectedValue={userSolution?.solution} + className="inline-block py-2 px-1" + width={220} + isOpen={openDropdownId === id} + onToggle={()=> setOpenDropdownId(prevId => prevId === id ? null : id)} + /> + ) : ( + setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution: e.target.value }])} + value={userSolution?.solution} + /> + ); + }) + } +
+ ); + }, + [variant, words, answers, openDropdownId], + ); - const onSelection = (questionID: string, value: string) => { - setAnswers((prev) => [...prev.filter((x) => x.id !== questionID), { id: questionID, solution: value }]); - } + const memoizedLines = useMemo(() => { + return text.split("\\n").map((line, index) => ( +

+ {renderLines(line)} +
+

+ )); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [text, variant, renderLines]); - useEffect(() => { - if (variant === "mc") { - setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [answers]) + const onSelection = (questionID: string, value: string) => { + setAnswers((prev) => [...prev.filter((x) => x.id !== questionID), { id: questionID, solution: value }]); + }; - return ( - <> -
- {variant !== "mc" && - {prompt.split("\\n").map((line, index) => ( - - {line} -
-
- ))} -
} - - {memoizedLines} - - {variant === "mc" && typeCheckWordsMC(words) ? ( - <> - {currentMCSelection && ( -
- {`${currentMCSelection.id} - Select the appropriate word.`} -
- {currentMCSelection.selection?.options && Object.entries(currentMCSelection.selection.options).sort((a, b) => a[0].localeCompare(b[0])).map(([key, value]) => { - return
onSelection(currentMCSelection.id, value)} - className={clsx( - "flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base", - !!answers.find((x) => x.solution.toLocaleLowerCase() === value.toLocaleLowerCase() && x.id === currentMCSelection.id) && - "border-mti-purple-light", - )}> - {key}. - {value} -
+ useEffect(() => { + if (variant === "mc") { + setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [answers]); - /*;*/ - })} -
-
- )} - - ) : ( -
- Options -
- {words.map((v) => { - v = excludeWordMCType(v); - const text = typeof v === "string" ? v : `${v.letter} - ${v.word}`; + return ( +
+
+ - return ( - x.solution.toLowerCase() === (typeof v === "string" ? v : ("letter" in v ? v.letter : "")).toLowerCase()) && - "bg-mti-purple-dark text-white", - )} - key={v4()} - > - {text} - - ) - })} -
-
- )} -
-
- + +
- -
- - ); -} +
+ {variant !== "mc" && ( + + {prompt.split("\\n").map((line, index) => ( + + {line} +
+
+ ))} +
+ )} + {memoizedLines} + {variant !== "mc" && ( +
+ Options +
+ {words.map((v) => { + v = excludeWordMCType(v); + const text = typeof v === "string" ? v : `${v.letter} - ${v.word}`; + + return ( + + x.solution.toLowerCase() === + (typeof v === "string" ? v : "letter" in v ? v.letter : "").toLowerCase(), + ) && "bg-mti-purple-dark text-white", + )} + key={v4()}> + {text} + + ); + })} +
+
+ )} +
+
+ + + +
+
+ ); +}; export default FillBlanks; diff --git a/src/components/Exercises/InteractiveSpeaking.tsx b/src/components/Exercises/InteractiveSpeaking.tsx index 7bad812c..08a1eef7 100644 --- a/src/components/Exercises/InteractiveSpeaking.tsx +++ b/src/components/Exercises/InteractiveSpeaking.tsx @@ -152,139 +152,8 @@ export default function InteractiveSpeaking({ }; return ( -
-
-
- {!!first_title && !!second_title ? `${first_title} & ${second_title}` : title} -
- {prompts && prompts.length > 0 && ( -
- -
- )} -
- - setMediaBlob(blob)} - render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => ( -
-

Record your answer:

-
- {status === "idle" && ( - <> -
- {status === "idle" && ( - { - setRecordingDuration(0); - startRecording(); - setIsRecording(true); - }} - className="h-5 w-5 text-mti-gray-cool cursor-pointer" - /> - )} - - )} - {status === "recording" && ( - <> -
- - {Math.floor(recordingDuration / 60) - .toString(10) - .padStart(2, "0")} - : - {Math.floor(recordingDuration % 60) - .toString(10) - .padStart(2, "0")} - -
-
-
- { - setIsRecording(false); - pauseRecording(); - }} - className="text-red-500 w-8 h-8 cursor-pointer" - /> - { - setIsRecording(false); - stopRecording(); - }} - className="text-mti-purple-light w-8 h-8 cursor-pointer" - /> -
- - )} - {status === "paused" && ( - <> -
- - {Math.floor(recordingDuration / 60) - .toString(10) - .padStart(2, "0")} - : - {Math.floor(recordingDuration % 60) - .toString(10) - .padStart(2, "0")} - -
-
-
- { - setIsRecording(true); - resumeRecording(); - }} - className="text-mti-purple-light w-8 h-8 cursor-pointer" - /> - { - setIsRecording(false); - stopRecording(); - }} - className="text-mti-purple-light w-8 h-8 cursor-pointer" - /> -
- - )} - {status === "stopped" && mediaBlobUrl && ( - <> - -
- { - setRecordingDuration(0); - clearBlobUrl(); - setMediaBlob(undefined); - }} - /> - - { - clearBlobUrl(); - setRecordingDuration(0); - startRecording(); - setIsRecording(true); - setMediaBlob(undefined); - }} - className="h-5 w-5 text-mti-gray-cool cursor-pointer" - /> -
- - )} -
-
- )} - /> - -
+
+
@@ -292,6 +161,148 @@ export default function InteractiveSpeaking({ {questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
+ +
+
+
+ {!!first_title && !!second_title ? `${first_title} & ${second_title}` : title} +
+ {prompts && prompts.length > 0 && ( +
+ +
+ )} +
+ + setMediaBlob(blob)} + render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => ( +
+

Record your answer:

+
+ {status === "idle" && ( + <> +
+ {status === "idle" && ( + { + setRecordingDuration(0); + startRecording(); + setIsRecording(true); + }} + className="h-5 w-5 text-mti-gray-cool cursor-pointer" + /> + )} + + )} + {status === "recording" && ( + <> +
+ + {Math.floor(recordingDuration / 60) + .toString(10) + .padStart(2, "0")} + : + {Math.floor(recordingDuration % 60) + .toString(10) + .padStart(2, "0")} + +
+
+
+ { + setIsRecording(false); + pauseRecording(); + }} + className="text-red-500 w-8 h-8 cursor-pointer" + /> + { + setIsRecording(false); + stopRecording(); + }} + className="text-mti-purple-light w-8 h-8 cursor-pointer" + /> +
+ + )} + {status === "paused" && ( + <> +
+ + {Math.floor(recordingDuration / 60) + .toString(10) + .padStart(2, "0")} + : + {Math.floor(recordingDuration % 60) + .toString(10) + .padStart(2, "0")} + +
+
+
+ { + setIsRecording(true); + resumeRecording(); + }} + className="text-mti-purple-light w-8 h-8 cursor-pointer" + /> + { + setIsRecording(false); + stopRecording(); + }} + className="text-mti-purple-light w-8 h-8 cursor-pointer" + /> +
+ + )} + {status === "stopped" && mediaBlobUrl && ( + <> + +
+ { + setRecordingDuration(0); + clearBlobUrl(); + setMediaBlob(undefined); + }} + /> + + { + clearBlobUrl(); + setRecordingDuration(0); + startRecording(); + setIsRecording(true); + setMediaBlob(undefined); + }} + className="h-5 w-5 text-mti-gray-cool cursor-pointer" + /> +
+ + )} +
+
+ )} + /> + +
+ + +
+
); } diff --git a/src/components/Exercises/MatchSentences.tsx b/src/components/Exercises/MatchSentences.tsx index 4f17c2c3..755f0292 100644 --- a/src/components/Exercises/MatchSentences.tsx +++ b/src/components/Exercises/MatchSentences.tsx @@ -68,6 +68,13 @@ export default function MatchSentences({id, options, type, prompt, sentences, us const hasExamEnded = useExamStore((state) => state.hasExamEnded); + const setCurrentSolution = useExamStore((state) => state.setCurrentSolution); + + useEffect(() => { + setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [answers, setAnswers]); + const handleDragEnd = (event: DragEndEvent) => { if (event.over && event.over.id.toString().startsWith("droppable")) { const optionID = event.active.id.toString().replace("draggable_option_", ""); @@ -93,7 +100,24 @@ export default function MatchSentences({id, options, type, prompt, sentences, us }, [hasExamEnded]); return ( - <> +
+
+ + + +
+
{prompt.split("\\n").map((line, index) => ( @@ -143,6 +167,6 @@ export default function MatchSentences({id, options, type, prompt, sentences, us Next
- +
); } diff --git a/src/components/Exercises/MultipleChoice.tsx b/src/components/Exercises/MultipleChoice.tsx index 8cdc4f72..1f7bf6e0 100644 --- a/src/components/Exercises/MultipleChoice.tsx +++ b/src/components/Exercises/MultipleChoice.tsx @@ -1,12 +1,12 @@ /* eslint-disable @next/next/no-img-element */ -import { MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap } from "@/interfaces/exam"; +import {MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap} from "@/interfaces/exam"; import useExamStore from "@/stores/examStore"; import clsx from "clsx"; -import { useEffect, useState } from "react"; +import {useEffect, useState} from "react"; import reactStringReplace from "react-string-replace"; -import { CommonProps } from "."; +import {CommonProps} from "."; import Button from "../Low/Button"; -import { v4 } from "uuid"; +import {v4} from "uuid"; function Question({ id, @@ -18,9 +18,8 @@ function Question({ }: MultipleChoiceQuestion & { userSolution: string | undefined; onSelectOption?: (option: string) => void; - showSolution?: boolean, + showSolution?: boolean; }) { - const renderPrompt = (prompt: string) => { return reactStringReplace(prompt, /(.*?<\/u>)/g, (match) => { const word = match.replaceAll("", "").replaceAll("", ""); @@ -49,7 +48,9 @@ function Question({ "flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative select-none", userSolution === option.id.toString() && "border-mti-purple-light", )}> - {option.id.toString()} + + {option.id.toString()} + {`Option
))} @@ -60,7 +61,7 @@ function Question({ onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)} className={clsx( "flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base select-none", - userSolution === option.id.toString() && "border-mti-purple-light", + userSolution === option.id.toString() && "!bg-mti-purple-light !text-white", )}> {option.id.toString()}. {option.text} @@ -71,38 +72,30 @@ function Question({ ); } -export default function MultipleChoice({ id, prompt, type, questions, userSolutions, onNext, onBack }: MultipleChoiceExercise & CommonProps) { - const [answers, setAnswers] = useState<{ question: string; option: string }[]>(userSolutions); +export default function MultipleChoice({id, prompt, type, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) { + const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions); - const { - questionIndex, - exerciseIndex, - exam, - shuffles, - hasExamEnded, - partIndex, - setQuestionIndex, - setCurrentSolution - } = useExamStore((state) => state); + const {questionIndex, exerciseIndex, exam, shuffles, hasExamEnded, partIndex, setQuestionIndex, setCurrentSolution} = useExamStore( + (state) => state, + ); const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles; const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); useEffect(() => { - if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type }); + if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type}); // eslint-disable-next-line react-hooks/exhaustive-deps }, [hasExamEnded]); - const onSelectOption = (option: string) => { - const question = questions[questionIndex]; - setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), { option, question: question.id }]); + const onSelectOption = (option: string, question: MultipleChoiceQuestion) => { + setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]); }; useEffect(() => { - setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps }); + setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps}); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [answers, setAnswers]) + }, [answers, setAnswers]); const getShuffledSolution = (originalSolution: string, questionShuffleMap: ShuffleMap) => { for (const [newPosition, originalPosition] of Object.entries(questionShuffleMap.map)) { @@ -111,8 +104,7 @@ export default function MultipleChoice({ id, prompt, type, questions, userSoluti } } return originalSolution; - } - + }; const calculateScore = () => { const total = questions.length; @@ -135,63 +127,95 @@ export default function MultipleChoice({ id, prompt, type, questions, userSoluti return isSolutionCorrect || false; }).length; const missing = total - answers!.filter((x) => questions.find((y) => x.question.toString() === y.id.toString())).length; - return { total, correct, missing }; + return {total, correct, missing}; }; const next = () => { - if (questionIndex === questions.length - 1) { - onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps }); + if (questionIndex + 1 >= questions.length - 1) { + onNext({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps}); } else { - setQuestionIndex(questionIndex + 1); + setQuestionIndex(questionIndex + 2); } scrollToTop(); }; const back = () => { if (questionIndex === 0) { - onBack({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps }); + onBack({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps}); } else { - if (exam?.module === "level" && typeof exam.parts[0].intro !== "undefined" && questionIndex === 0) return; - setQuestionIndex(questionIndex - 1); + if (exam?.module === "level" && typeof exam.parts[0].intro !== "undefined" && questionIndex === 0) return; + setQuestionIndex(questionIndex - 2); } scrollToTop(); }; return ( - <> -
- {/*{"Select the appropriate option."}*/} - {questionIndex < questions.length && ( - questions[questionIndex].id === x.question)?.option} - onSelectOption={onSelectOption} - /> - )} -
- -
-
- + +
+
+ {/*{"Select the appropriate option."}*/} + {questionIndex < questions.length && ( + questions[questionIndex].id === x.question)?.option} + onSelectOption={(option) => onSelectOption(option, questions[questionIndex])} + /> + )} +
+ + {questionIndex + 1 < questions.length && ( +
+ questions[questionIndex + 1].id === x.question)?.option} + onSelectOption={(option) => onSelectOption(option, questions[questionIndex + 1])} + /> +
+ )} +
+ +
+ + + +
+
); } diff --git a/src/components/Exercises/Speaking.tsx b/src/components/Exercises/Speaking.tsx index 106b76d4..12e942d0 100644 --- a/src/components/Exercises/Speaking.tsx +++ b/src/components/Exercises/Speaking.tsx @@ -1,20 +1,20 @@ -import { SpeakingExercise } from "@/interfaces/exam"; -import { CommonProps } from "."; -import { Fragment, useEffect, useState } from "react"; -import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs"; +import {SpeakingExercise} from "@/interfaces/exam"; +import {CommonProps} from "."; +import {Fragment, useEffect, useState} from "react"; +import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill} from "react-icons/bs"; import dynamic from "next/dynamic"; import Button from "../Low/Button"; import useExamStore from "@/stores/examStore"; -import { downloadBlob } from "@/utils/evaluation"; +import {downloadBlob} from "@/utils/evaluation"; import axios from "axios"; import Modal from "../Modal"; -const Waveform = dynamic(() => import("../Waveform"), { ssr: false }); +const Waveform = dynamic(() => import("../Waveform"), {ssr: false}); const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), { ssr: false, }); -export default function Speaking({ id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack }: SpeakingExercise & CommonProps) { +export default function Speaking({id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) { const [recordingDuration, setRecordingDuration] = useState(0); const [isRecording, setIsRecording] = useState(false); const [mediaBlob, setMediaBlob] = useState(); @@ -28,7 +28,7 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su const saveToStorage = async () => { if (mediaBlob && mediaBlob.startsWith("blob")) { const blobBuffer = await downloadBlob(mediaBlob); - const audioFile = new File([blobBuffer], "audio.wav", { type: "audio/wav" }); + const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"}); const seed = Math.random().toString().replace("0.", ""); @@ -42,8 +42,8 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su }, }; - const response = await axios.post<{ path: string }>("/api/storage/insert", formData, config); - if (audioURL) await axios.post("/api/storage/delete", { path: audioURL }); + const response = await axios.post<{path: string}>("/api/storage/insert", formData, config); + if (audioURL) await axios.post("/api/storage/delete", {path: audioURL}); return response.data.path; } @@ -52,7 +52,7 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su useEffect(() => { if (userSolutions.length > 0) { - const { solution } = userSolutions[0] as { solution?: string }; + const {solution} = userSolutions[0] as {solution?: string}; if (solution && !mediaBlob) setMediaBlob(solution); if (solution && !solution.startsWith("blob")) setAudioURL(solution); } @@ -79,8 +79,8 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su const next = async () => { onNext({ exercise: id, - solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [], - score: { correct: 0, total: 100, missing: 0 }, + solutions: mediaBlob ? [{id, solution: mediaBlob}] : [], + score: {correct: 0, total: 100, missing: 0}, type, }); }; @@ -88,8 +88,8 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su const back = async () => { onBack({ exercise: id, - solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [], - score: { correct: 0, total: 100, missing: 0 }, + solutions: mediaBlob ? [{id, solution: mediaBlob}] : [], + score: {correct: 0, total: 100, missing: 0}, type, }); }; @@ -98,7 +98,7 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su const newText = e.target.value; const words = newText.match(/\S+/g); const wordCount = words ? words.length : 0; - + if (wordCount <= 100) { setInputText(newText); } else { @@ -110,188 +110,14 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su if (count > 100) break; lastIndex = match.index! + match[0].length; } - + setInputText(newText.slice(0, lastIndex)); } }; return ( -
- setIsPromptsModalOpen(false)}> -
-
- {prompts.map((x, index) => ( -
  • - {x} -
  • - ))} -
    - {!!suffix && {suffix}} -
    -
    -
    -
    -
    - {title} - {prompts.length > 0 && ( - You should talk for at least 1 minute and 30 seconds for your answer to be valid. - )} -
    - {!video_url && ( - - {text.split("\\n").map((line, index) => ( - - {line} -
    -
    - ))} -
    - )} -
    -
    - {video_url && ( -
    - -
    - )} - {prompts && prompts.length > 0 && } -
    -
    - - {prompts && prompts.length > 0 && ( -
    -