@@ -30,7 +30,6 @@ const ListeningComponents: React.FC<Props> = ({ currentSection, localSettings, u
|
|||||||
const {
|
const {
|
||||||
focusedSection,
|
focusedSection,
|
||||||
difficulty,
|
difficulty,
|
||||||
sections
|
|
||||||
} = useExamEditorStore(state => state.modules[currentModule]);
|
} = useExamEditorStore(state => state.modules[currentModule]);
|
||||||
|
|
||||||
const [originalAudioUrl, setOriginalAudioUrl] = useState<string | undefined>();
|
const [originalAudioUrl, setOriginalAudioUrl] = useState<string | undefined>();
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const ListeningSettings: React.FC = () => {
|
|||||||
sections,
|
sections,
|
||||||
minTimer,
|
minTimer,
|
||||||
isPrivate,
|
isPrivate,
|
||||||
|
instructionsState
|
||||||
} = useExamEditorStore(state => state.modules[currentModule]);
|
} = useExamEditorStore(state => state.modules[currentModule]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -71,10 +72,33 @@ const ListeningSettings: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const sectionsWithAudio = sections.filter(s => (s.state as ListeningPart).audio?.source);
|
const sectionsWithAudio = sections.filter(s => (s.state as ListeningPart).audio?.source);
|
||||||
|
|
||||||
|
if (instructionsState.chosenOption.value === "Custom" && !instructionsState.currentInstructionsURL.startsWith("blob:")) {
|
||||||
|
toast.error("Generate the custom instructions audio first!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (sectionsWithAudio.length > 0) {
|
if (sectionsWithAudio.length > 0) {
|
||||||
|
let instructionsURL = instructionsState.currentInstructionsURL;
|
||||||
|
|
||||||
|
if (instructionsState.chosenOption.value === "Custom") {
|
||||||
|
const instructionsFormData = new FormData();
|
||||||
|
const instructionsResponse = await fetch(instructionsState.currentInstructionsURL);
|
||||||
|
const instructionsBlob = await instructionsResponse.blob();
|
||||||
|
instructionsFormData.append('file', instructionsBlob, 'audio.mp3');
|
||||||
|
|
||||||
|
const instructionsUploadResponse = await axios.post('/api/storage', instructionsFormData, {
|
||||||
|
params: {
|
||||||
|
directory: 'listening_instructions'
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
instructionsURL = instructionsUploadResponse.data.urls[0];
|
||||||
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
const sectionMap = new Map<number, string>();
|
const sectionMap = new Map<number, string>();
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
sectionsWithAudio.map(async (section) => {
|
sectionsWithAudio.map(async (section) => {
|
||||||
const listeningPart = section.state as ListeningPart;
|
const listeningPart = section.state as ListeningPart;
|
||||||
@@ -120,6 +144,7 @@ const ListeningSettings: React.FC = () => {
|
|||||||
variant: sections.length === 4 ? "full" : "partial",
|
variant: sections.length === 4 ? "full" : "partial",
|
||||||
difficulty,
|
difficulty,
|
||||||
private: isPrivate,
|
private: isPrivate,
|
||||||
|
instructions: instructionsURL
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await axios.post('/api/exam/listening', exam);
|
const result = await axios.post('/api/exam/listening', exam);
|
||||||
@@ -140,6 +165,11 @@ const ListeningSettings: React.FC = () => {
|
|||||||
|
|
||||||
|
|
||||||
const preview = () => {
|
const preview = () => {
|
||||||
|
if (instructionsState.chosenOption.value === "Custom" && !instructionsState.currentInstructionsURL.startsWith("blob:")) {
|
||||||
|
toast.error("Generate the custom instructions audio first!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setExam({
|
setExam({
|
||||||
parts: sections.map((s) => {
|
parts: sections.map((s) => {
|
||||||
const exercise = s.state as ListeningPart;
|
const exercise = s.state as ListeningPart;
|
||||||
@@ -156,6 +186,7 @@ const ListeningSettings: React.FC = () => {
|
|||||||
variant: sections.length === 4 ? "full" : "partial",
|
variant: sections.length === 4 ? "full" : "partial",
|
||||||
difficulty,
|
difficulty,
|
||||||
private: isPrivate,
|
private: isPrivate,
|
||||||
|
instructions: instructionsState.currentInstructionsURL
|
||||||
} as ListeningExam);
|
} as ListeningExam);
|
||||||
setExerciseIndex(0);
|
setExerciseIndex(0);
|
||||||
setQuestionIndex(0);
|
setQuestionIndex(0);
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
|
import { PRESETS, isValidPresetID } from "./presets";
|
||||||
|
import AudioPlayer from "@/components/Low/AudioPlayer";
|
||||||
|
import Select from "@/components/Low/Select";
|
||||||
|
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
||||||
|
import { ListeningInstructionsState } from "@/stores/examEditor/types";
|
||||||
|
import { debounce } from "lodash";
|
||||||
|
import axios from "axios";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { playSound } from "@/utils/sound";
|
||||||
|
import { BsArrowRepeat } from "react-icons/bs";
|
||||||
|
import { GiBrain } from "react-icons/gi";
|
||||||
|
|
||||||
|
const ListeningInstructions: React.FC = () => {
|
||||||
|
const { dispatch } = useExamEditorStore();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { instructionsState: globalInstructions, sections } = useExamEditorStore(s => s.modules["listening"]);
|
||||||
|
const [localInstructions, setLocalInstructions] = useState<ListeningInstructionsState>(globalInstructions);
|
||||||
|
const pendingUpdatesRef = useRef<Partial<ListeningInstructionsState>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (globalInstructions) {
|
||||||
|
setLocalInstructions(globalInstructions);
|
||||||
|
}
|
||||||
|
}, [globalInstructions]);
|
||||||
|
|
||||||
|
const debouncedUpdateGlobal = useMemo(() => {
|
||||||
|
return debounce(() => {
|
||||||
|
if (Object.keys(pendingUpdatesRef.current).length > 0) {
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_MODULE",
|
||||||
|
payload: {
|
||||||
|
module: "listening",
|
||||||
|
updates: {
|
||||||
|
instructionsState: {
|
||||||
|
...globalInstructions,
|
||||||
|
...pendingUpdatesRef.current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
pendingUpdatesRef.current = {};
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [dispatch, globalInstructions]);
|
||||||
|
|
||||||
|
const updateInstructionsAndSchedule = useCallback((
|
||||||
|
updates: Partial<ListeningInstructionsState> | ((prev: ListeningInstructionsState) => Partial<ListeningInstructionsState>),
|
||||||
|
schedule: boolean = true
|
||||||
|
) => {
|
||||||
|
const newUpdates = typeof updates === 'function' ? updates(localInstructions) : updates;
|
||||||
|
|
||||||
|
setLocalInstructions(prev => ({
|
||||||
|
...prev,
|
||||||
|
...newUpdates
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (schedule) {
|
||||||
|
pendingUpdatesRef.current = {
|
||||||
|
...pendingUpdatesRef.current,
|
||||||
|
...newUpdates
|
||||||
|
};
|
||||||
|
debouncedUpdateGlobal();
|
||||||
|
} else {
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_MODULE",
|
||||||
|
payload: {
|
||||||
|
module: "listening",
|
||||||
|
updates: {
|
||||||
|
instructionsState: {
|
||||||
|
...globalInstructions,
|
||||||
|
...newUpdates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [dispatch, debouncedUpdateGlobal]);
|
||||||
|
|
||||||
|
const setIsOpen = useCallback((isOpen: boolean) => {
|
||||||
|
updateInstructionsAndSchedule({ isInstructionsOpen: isOpen }, false);
|
||||||
|
}, [updateInstructionsAndSchedule]);
|
||||||
|
|
||||||
|
const onOptionChange = useCallback((option: { value: string, label: string }) => {
|
||||||
|
const sectionIds = sections.map(s => s.sectionId);
|
||||||
|
const presetID = [...sectionIds].sort((a, b) => a - b).join('_');
|
||||||
|
const preset = isValidPresetID(presetID) ? PRESETS[presetID] : null;
|
||||||
|
|
||||||
|
updateInstructionsAndSchedule(prev => {
|
||||||
|
const updates: Partial<ListeningInstructionsState> = {
|
||||||
|
chosenOption: option
|
||||||
|
};
|
||||||
|
|
||||||
|
if (option.value === "Automatic" && preset) {
|
||||||
|
updates.currentInstructions = preset.text;
|
||||||
|
updates.currentInstructionsURL = preset.url;
|
||||||
|
} else if (option.value === "Custom") {
|
||||||
|
updates.currentInstructions = prev.customInstructions || "";
|
||||||
|
updates.currentInstructionsURL = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return updates;
|
||||||
|
}, false);
|
||||||
|
}, [sections, updateInstructionsAndSchedule]);
|
||||||
|
|
||||||
|
const onCustomInstructionChange = useCallback((text: string) => {
|
||||||
|
updateInstructionsAndSchedule({
|
||||||
|
chosenOption: { value: 'Custom', label: 'Custom' },
|
||||||
|
customInstructions: text,
|
||||||
|
currentInstructions: text
|
||||||
|
});
|
||||||
|
}, [updateInstructionsAndSchedule]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sectionIds = sections.map(s => s.sectionId);
|
||||||
|
const presetID = [...sectionIds].sort((a, b) => a - b).join('_');
|
||||||
|
|
||||||
|
if (isValidPresetID(presetID)) {
|
||||||
|
const preset = PRESETS[presetID];
|
||||||
|
updateInstructionsAndSchedule(prev => {
|
||||||
|
const updates: Partial<ListeningInstructionsState> = {
|
||||||
|
presetInstructions: preset.text,
|
||||||
|
presetInstructionsURL: preset.url,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (prev.chosenOption?.value === "Automatic") {
|
||||||
|
updates.currentInstructions = preset.text;
|
||||||
|
updates.currentInstructionsURL = preset.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return updates;
|
||||||
|
}, false);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [sections.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (Object.keys(pendingUpdatesRef.current).length > 0) {
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_MODULE",
|
||||||
|
payload: {
|
||||||
|
module: "listening",
|
||||||
|
updates: {
|
||||||
|
instructionsState: {
|
||||||
|
...globalInstructions,
|
||||||
|
...pendingUpdatesRef.current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ value: 'Automatic', label: 'Automatic' },
|
||||||
|
{ value: 'Custom', label: 'Custom' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const generateInstructionsMP3 = useCallback(async () => {
|
||||||
|
if (!localInstructions.currentInstructions) {
|
||||||
|
toast.error('Please enter instructions text first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
'/api/exam/media/instructions',
|
||||||
|
{
|
||||||
|
text: localInstructions.currentInstructions
|
||||||
|
},
|
||||||
|
{
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'audio/mpeg'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (localInstructions.currentInstructionsURL?.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(localInstructions.currentInstructionsURL);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([response.data], { type: 'audio/mpeg' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
updateInstructionsAndSchedule({
|
||||||
|
customInstructionsURL: url,
|
||||||
|
currentInstructionsURL: url
|
||||||
|
}, false);
|
||||||
|
|
||||||
|
playSound("check");
|
||||||
|
toast.success('Audio generated successfully!');
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error('Failed to generate audio');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [localInstructions.currentInstructions, localInstructions.currentInstructionsURL, updateInstructionsAndSchedule]);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
isOpen={localInstructions.isInstructionsOpen}
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-6 p-6 w-full">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800">Listening Instructions</h2>
|
||||||
|
<p className="text-sm text-gray-500">Choose instruction type or customize your own</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-700">Instruction Type</label>
|
||||||
|
<Select
|
||||||
|
options={options}
|
||||||
|
onChange={(o) => onOptionChange({ value: o!.value || "", label: o!.label })}
|
||||||
|
value={localInstructions.chosenOption}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-col flex-grow gap-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-700">Instructions Text</label>
|
||||||
|
<div className="flex flex-row gap-2">
|
||||||
|
<div className="flex flex-grow bg-gray-50 rounded-lg p-4 border border-gray-200 items-center">
|
||||||
|
<AutoExpandingTextArea
|
||||||
|
value={localInstructions.currentInstructions || ''}
|
||||||
|
onChange={onCustomInstructionChange}
|
||||||
|
className="bg-transparent resize-none w-full focus:outline-none"
|
||||||
|
placeholder="Enter custom instructions here..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{localInstructions.chosenOption?.value === 'Custom' && (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Button
|
||||||
|
onClick={generateInstructionsMP3}
|
||||||
|
disabled={loading}
|
||||||
|
customColor="bg-ielts-listening/70 hover:bg-ielts-listening border-ielts-listening"
|
||||||
|
className="text-white rounded-md"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<GiBrain className="mr-2" size={24} />
|
||||||
|
<span>Generate</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(localInstructions.chosenOption?.value === 'Automatic' ||
|
||||||
|
(localInstructions.chosenOption?.value === 'Custom' && localInstructions.currentInstructionsURL.startsWith("blob:")) && localInstructions.currentInstructionsURL !== '') && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-700">Audio Preview</label>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||||
|
<AudioPlayer
|
||||||
|
src={localInstructions.currentInstructionsURL ?? ''}
|
||||||
|
color="listening"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
customColor="bg-ielts-listening/70 hover:bg-ielts-listening border-ielts-listening"
|
||||||
|
className="text-white self-end"
|
||||||
|
>
|
||||||
|
Audio Instructions
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListeningInstructions;
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
const PRESETS = {
|
||||||
|
"1": {
|
||||||
|
url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_1.mp3?alt=media&token=abf0dc1a-6d24-4d33-be0e-7e15f4e4bec2",
|
||||||
|
text: "You will hear one recording and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. The recording can be played three times. The recording consists of a conversation between two people in an everyday social context. Pay close attention to the audio recording and answer the questions accordingly.",
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_2.mp3?alt=media&token=a635f234-e470-4980-9690-e81544bbbe42",
|
||||||
|
text: "You will hear one recording and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. The recording can be played three times. The recording consists of a monologue set in a social context. Pay close attention to the audio recording and answer the questions accordingly.",
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_3.mp3?alt=media&token=9659155d-0167-4288-9ba7-4135e135151d",
|
||||||
|
text: "You will hear one recording and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. The recording can be played three times. The recording consists of a conversation between up to four individuals in an educational context. Pay close attention to the audio recording and answer the questions accordingly."
|
||||||
|
},
|
||||||
|
"4": {
|
||||||
|
url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_4.mp3?alt=media&token=ed50aae9-2bd7-4d09-a5c9-81cb55ec29fb",
|
||||||
|
text: "You will hear one recording and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. The recording can be played three times. The recording consists of a monologue about an academic subject. Pay close attention to the audio recording and answer the questions accordingly."
|
||||||
|
},
|
||||||
|
"1_2": {
|
||||||
|
url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_1_2.mp3?alt=media&token=16b1b6a8-6664-40fa-bb10-f8c89798d43d",
|
||||||
|
text: "You will hear two recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. The recordings can be played three times. The module is in 2 parts. In the first part you will hear a conversation between two people in an everyday social context. In the second part you will hear a monologue set in a social context. Pay close attention to the audio recordings and answer the questions accordingly."
|
||||||
|
},
|
||||||
|
"1_3": {
|
||||||
|
url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_1_3.mp3?alt=media&token=3c3264b9-d277-4e43-91f9-6fa77cfd701e",
|
||||||
|
text: "You will hear two recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. The recordings can be played three times. The module is in 2 parts. In the first part you will hear a conversation between two people in an everyday social context. In the second part you will hear a conversation between up to four individuals in an educational context. Pay close attention to the audio recordings and answer the questions accordingly.",
|
||||||
|
},
|
||||||
|
"1_4": {
|
||||||
|
url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_1_4.mp3?alt=media&token=350511e6-7010-43f7-a258-662e91ff7399",
|
||||||
|
text: "You will hear two recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. The recordings can be played three times. The module is in 2 parts. In the first part you will hear a conversation between two people in an everyday social context. In the second part you will hear a monologue about an academic subject. Pay close attention to the audio recordings and answer the questions accordingly."
|
||||||
|
},
|
||||||
|
"2_3": {
|
||||||
|
url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_2_3.mp3?alt=media&token=fd260687-35e9-4386-8843-b58c2146dd48",
|
||||||
|
text: "You will hear two recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. The recordings can be played three times. The module is in 2 parts. In the first part you will hear a monologue set in a social context. In the second part you will hear a conversation between up to four individuals in an educational context. Pay close attention to the audio recordings and answer the questions accordingly."
|
||||||
|
},
|
||||||
|
"2_4": {
|
||||||
|
url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_2_4.mp3?alt=media&token=0d85d499-5461-4d0f-8952-20aba319f783",
|
||||||
|
text: "You will hear two recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. The recordings can be played three times. The module is in 2 parts. In the first part you will hear a monologue set in a social context. In the second part you will hear a monologue about an academic subject. Pay close attention to the audio recordings and answer the questions accordingly."
|
||||||
|
},
|
||||||
|
"3_4": {
|
||||||
|
url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_3_4.mp3?alt=media&token=79bdabde-3d05-4234-bec7-5a8b385c2479",
|
||||||
|
text: "You will hear two recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. The recordings can be played three times. The module is in 2 parts. In the first part you will hear a conversation between up to four individuals in an educational context. In the second part you will hear a monologue about an academic subject. Pay close attention to the audio recordings and answer the questions accordingly."
|
||||||
|
},
|
||||||
|
"1_2_3": {
|
||||||
|
url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_1_2_3.mp3?alt=media&token=8bdb42dd-e3ed-446b-8760-281768c005e6",
|
||||||
|
text: "You will hear a number of different recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. All the recordings can be played three times. The module is in 3 parts. In the first part you will hear a conversation between two people in an everyday social context. In the second part you will hear a monologue set in a social context. In the third part you will hear a conversation between up to four individuals in an educational context. Pay close attention to the audio recordings and answer the questions accordingly."
|
||||||
|
},
|
||||||
|
"1_2_4": {
|
||||||
|
url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_1_2_4.mp3?alt=media&token=5458c3c1-d398-453f-be97-ef1785f9d7e3",
|
||||||
|
text: "You will hear a number of different recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. All the recordings can be played three times. The module is in 3 parts. In the first part you will hear a conversation between two people in an everyday social context. In the second part you will hear a monologue set in a social context. In the third part you will hear a monologue about an academic subject. Pay close attention to the audio recordings and answer the questions accordingly."
|
||||||
|
},
|
||||||
|
"1_3_4": {
|
||||||
|
url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_1_3_4.mp3?alt=media&token=0380653e-be5b-4c89-9814-a996ae77a74a",
|
||||||
|
text: "You will hear a number of different recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. All the recordings can be played three times. The module is in 3 parts. In the first part you will hear a conversation between two people in an everyday social context. In the second part you will hear a conversation between up to four individuals in an educational context. In the third part you will hear a monologue about an academic subject. Pay close attention to the audio recordings and answer the questions accordingly."
|
||||||
|
},
|
||||||
|
"2_3_4": {
|
||||||
|
url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_2_3_4.mp3?alt=media&token=74bf11d6-e3d4-4711-bdc6-b0adbcaf11d4",
|
||||||
|
text: "You will hear a number of different recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. All the recordings can be played three times. The module is in 3 parts. In the first part you will hear a monologue set in a social context. In the second part you will hear a conversation between up to four individuals in an educational context. In the third part you will hear a monologue about an academic subject. Pay close attention to the audio recordings and answer the questions accordingly."
|
||||||
|
},
|
||||||
|
"1_2_3_4": {
|
||||||
|
url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_1_2_3_4.mp3?alt=media&token=7a7ac516-221d-4e79-bd28-5d6bee9d79d8",
|
||||||
|
text: "You will hear a number of different recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. All the recordings can be played three times. The module is in 4 parts. In the first part you will hear a conversation between two people in an everyday social context. In the second part you will hear a monologue set in a social context. In the third part you will hear a conversation between up to four individuals in an educational context. In the fourth part you will hear a monologue about an academic subject. Pay close attention to the audio recordings and answer the questions accordingly."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type PresetID = keyof typeof PRESETS;
|
||||||
|
|
||||||
|
function isValidPresetID(id: string): id is PresetID {
|
||||||
|
return id in PRESETS;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export {
|
||||||
|
PRESETS,
|
||||||
|
isValidPresetID
|
||||||
|
};
|
||||||
@@ -18,7 +18,8 @@ import SpeakingSettings from "./SettingsEditor/speaking";
|
|||||||
import ImportOrStartFromScratch from "./ImportExam/ImportOrFromScratch";
|
import ImportOrStartFromScratch from "./ImportExam/ImportOrFromScratch";
|
||||||
import { defaultSectionSettings } from "@/stores/examEditor/defaults";
|
import { defaultSectionSettings } from "@/stores/examEditor/defaults";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import ResetModule from "./ResetModule";
|
import ResetModule from "./Standalone/ResetModule";
|
||||||
|
import ListeningInstructions from "./Standalone/ListeningInstructions";
|
||||||
|
|
||||||
const DIFFICULTIES: Difficulty[] = ["A1", "A2", "B1", "B2", "C1", "C2"];
|
const DIFFICULTIES: Difficulty[] = ["A1", "A2", "B1", "B2", "C1", "C2"];
|
||||||
|
|
||||||
@@ -200,6 +201,7 @@ const ExamEditor: React.FC<{ levelParts?: number }> = ({ levelParts = 0 }) => {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{currentModule === "listening" && <ListeningInstructions />}
|
||||||
{["reading", "listening", "level"].includes(currentModule) && <Button onClick={() => setIsResetModuleOpen(true)} customColor={`bg-ielts-${currentModule}/70 hover:bg-ielts-${currentModule} border-ielts-${currentModule}`} className={`text-white self-end`}>
|
{["reading", "listening", "level"].includes(currentModule) && <Button onClick={() => setIsResetModuleOpen(true)} customColor={`bg-ielts-${currentModule}/70 hover:bg-ielts-${currentModule} border-ielts-${currentModule}`} className={`text-white self-end`}>
|
||||||
Reset Module
|
Reset Module
|
||||||
</Button>}
|
</Button>}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export default function Layout({
|
|||||||
return (
|
return (
|
||||||
<main className={clsx("w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative")}>
|
<main className={clsx("w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative")}>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
{!hideSidebar && (
|
{!hideSidebar && user && (
|
||||||
<Navbar
|
<Navbar
|
||||||
path={router.pathname}
|
path={router.pathname}
|
||||||
user={user}
|
user={user}
|
||||||
@@ -45,7 +45,7 @@ export default function Layout({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className={clsx("h-full w-full flex gap-2")}>
|
<div className={clsx("h-full w-full flex gap-2")}>
|
||||||
{!hideSidebar && (
|
{!hideSidebar && user && (
|
||||||
<Sidebar
|
<Sidebar
|
||||||
path={router.pathname}
|
path={router.pathname}
|
||||||
navDisabled={navDisabled}
|
navDisabled={navDisabled}
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ const UserImportSummary: React.FC<Props> = ({ parsedExcel, newUsers, enlistedUse
|
|||||||
</>
|
</>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal isOpen={showEnlistedModal} onClose={() => setShowEnlistedModal(false)}>
|
<Modal isOpen={showEnlistedModal} onClose={() => setShowEnlistedModal(false)} maxWidth='max-w-[85%]'>
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2 mb-6">
|
<div className="flex items-center gap-2 mb-6">
|
||||||
<FaUsers className="w-5 h-5 text-blue-500" />
|
<FaUsers className="w-5 h-5 text-blue-500" />
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface Props {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
max?: number;
|
max?: number;
|
||||||
min?: number;
|
min?: number;
|
||||||
|
thin?: boolean
|
||||||
name: string;
|
name: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
@@ -29,6 +30,7 @@ export default function Input({
|
|||||||
className,
|
className,
|
||||||
roundness = "full",
|
roundness = "full",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
thin = false,
|
||||||
min,
|
min,
|
||||||
onChange,
|
onChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -95,9 +97,10 @@ export default function Input({
|
|||||||
min={type === "number" ? (min ?? 0) : undefined}
|
min={type === "number" ? (min ?? 0) : undefined}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"px-8 py-6 text-sm font-normal bg-white border border-mti-gray-platinum focus:outline-none",
|
"px-8 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",
|
"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",
|
roundness === "full" ? "rounded-full" : "rounded-xl",
|
||||||
|
thin ? 'py-4' : 'py-6'
|
||||||
)}
|
)}
|
||||||
required={required}
|
required={required}
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
|
|||||||
@@ -65,28 +65,28 @@ export default function Navbar({ user, path, navDisabled = false, focusMode = fa
|
|||||||
{
|
{
|
||||||
module: "reading",
|
module: "reading",
|
||||||
icon: () => <BsBook className="h-4 w-4 text-white" />,
|
icon: () => <BsBook className="h-4 w-4 text-white" />,
|
||||||
achieved: user.levels?.reading || 0 >= user.desiredLevels?.reading || 9,
|
achieved: user?.levels?.reading || 0 >= user?.desiredLevels?.reading || 9,
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
module: "listening",
|
module: "listening",
|
||||||
icon: () => <BsHeadphones className="h-4 w-4 text-white" />,
|
icon: () => <BsHeadphones className="h-4 w-4 text-white" />,
|
||||||
achieved: user.levels?.listening || 0 >= user.desiredLevels?.listening || 9,
|
achieved: user?.levels?.listening || 0 >= user?.desiredLevels?.listening || 9,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
module: "writing",
|
module: "writing",
|
||||||
icon: () => <BsPen className="h-4 w-4 text-white" />,
|
icon: () => <BsPen className="h-4 w-4 text-white" />,
|
||||||
achieved: user.levels?.writing || 0 >= user.desiredLevels?.writing || 9,
|
achieved: user?.levels?.writing || 0 >= user?.desiredLevels?.writing || 9,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
module: "speaking",
|
module: "speaking",
|
||||||
icon: () => <BsMegaphone className="h-4 w-4 text-white" />,
|
icon: () => <BsMegaphone className="h-4 w-4 text-white" />,
|
||||||
achieved: user.levels?.speaking || 0 >= user.desiredLevels?.speaking || 9,
|
achieved: user?.levels?.speaking || 0 >= user?.desiredLevels?.speaking || 9,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
module: "level",
|
module: "level",
|
||||||
icon: () => <BsClipboard className="h-4 w-4 text-white" />,
|
icon: () => <BsClipboard className="h-4 w-4 text-white" />,
|
||||||
achieved: user.levels?.level || 0 >= user.desiredLevels?.level || 9,
|
achieved: user?.levels?.level || 0 >= user?.desiredLevels?.level || 9,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Entity } from "@/interfaces/entity";
|
||||||
import { PaymentIntention } from "@/interfaces/paymob";
|
import { PaymentIntention } from "@/interfaces/paymob";
|
||||||
import { DurationUnit } from "@/interfaces/paypal";
|
import { DurationUnit } from "@/interfaces/paypal";
|
||||||
import { User } from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
@@ -10,6 +11,7 @@ import Modal from "./Modal";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
|
entity?: Entity
|
||||||
currency: string;
|
currency: string;
|
||||||
price: number;
|
price: number;
|
||||||
setIsPaymentLoading: (v: boolean) => void;
|
setIsPaymentLoading: (v: boolean) => void;
|
||||||
@@ -18,7 +20,7 @@ interface Props {
|
|||||||
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
|
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PaymobPayment({user, price, setIsPaymentLoading, currency, duration, duration_unit, onSuccess}: Props) {
|
export default function PaymobPayment({ user, entity, price, setIsPaymentLoading, currency, duration, duration_unit, onSuccess }: Props) {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -56,6 +58,7 @@ export default function PaymobPayment({user, price, setIsPaymentLoading, currenc
|
|||||||
userID: user.id,
|
userID: user.id,
|
||||||
duration,
|
duration,
|
||||||
duration_unit,
|
duration_unit,
|
||||||
|
entity: entity?.id
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export default function Sidebar({
|
|||||||
const { permissions } = usePermissions(user.id);
|
const { permissions } = usePermissions(user.id);
|
||||||
|
|
||||||
const entitiesAllowStatistics = useAllowedEntities(user, entities, "view_statistics")
|
const entitiesAllowStatistics = useAllowedEntities(user, entities, "view_statistics")
|
||||||
|
const entitiesAllowPaymentRecord = useAllowedEntities(user, entities, "view_payment_record")
|
||||||
|
|
||||||
const entitiesAllowGeneration = useAllowedEntitiesSomePermissions(user, entities, [
|
const entitiesAllowGeneration = useAllowedEntitiesSomePermissions(user, entities, [
|
||||||
"generate_reading", "generate_listening", "generate_writing", "generate_speaking", "generate_level"
|
"generate_reading", "generate_listening", "generate_writing", "generate_speaking", "generate_level"
|
||||||
@@ -140,7 +141,7 @@ export default function Sidebar({
|
|||||||
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
|
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
|
||||||
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={isMinimized} />
|
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={isMinimized} />
|
||||||
)}
|
)}
|
||||||
{checkAccess(user, ["admin", "developer", "agent", "corporate", "mastercorporate"], permissions, "viewPaymentRecords") && (
|
{checkAccess(user, ["admin", "developer", "agent", "corporate", "mastercorporate"]) && entitiesAllowPaymentRecord.length > 0 && (
|
||||||
<Nav
|
<Nav
|
||||||
disabled={disableNavigation}
|
disabled={disableNavigation}
|
||||||
Icon={BsCurrencyDollar}
|
Icon={BsCurrencyDollar}
|
||||||
|
|||||||
@@ -67,13 +67,6 @@ const Level: React.FC<ExamProps<LevelExam>> = ({ exam, showSolutions = false, pr
|
|||||||
const [continueAnyways, setContinueAnyways] = useState(false);
|
const [continueAnyways, setContinueAnyways] = useState(false);
|
||||||
const [textRender, setTextRender] = useState(false);
|
const [textRender, setTextRender] = useState(false);
|
||||||
|
|
||||||
const hasPractice = useMemo(() => {
|
|
||||||
if (partIndex > -1 && partIndex < exam.parts.length) {
|
|
||||||
return exam.parts[partIndex].exercises.some(e => e.isPractice)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}, [partIndex, exam.parts])
|
|
||||||
|
|
||||||
const [questionModalKwargs, setQuestionModalKwargs] = useState<{
|
const [questionModalKwargs, setQuestionModalKwargs] = useState<{
|
||||||
type?: "module" | "blankQuestions" | "submit"; unanswered?: boolean | undefined; onClose: (next?: boolean) => void | undefined;
|
type?: "module" | "blankQuestions" | "submit"; unanswered?: boolean | undefined; onClose: (next?: boolean) => void | undefined;
|
||||||
}>({
|
}>({
|
||||||
@@ -108,6 +101,14 @@ const Level: React.FC<ExamProps<LevelExam>> = ({ exam, showSolutions = false, pr
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasPractice = useMemo(() => {
|
||||||
|
if (partIndex > -1 && partIndex < exam.parts.length && !showPartDivider) {
|
||||||
|
console.log(exam.parts[partIndex].exercises.some(e => e.isPractice))
|
||||||
|
return exam.parts[partIndex].exercises.some(e => e.isPractice)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}, [partIndex, showPartDivider, exam.parts])
|
||||||
|
|
||||||
const registerSolution = useCallback((updateSolution: () => UserSolution) => {
|
const registerSolution = useCallback((updateSolution: () => UserSolution) => {
|
||||||
userSolutionRef.current = updateSolution;
|
userSolutionRef.current = updateSolution;
|
||||||
setSolutionWasUpdated(true);
|
setSolutionWasUpdated(true);
|
||||||
@@ -344,7 +345,7 @@ const Level: React.FC<ExamProps<LevelExam>> = ({ exam, showSolutions = false, pr
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={clsx("flex flex-col h-full w-full gap-8 items-center", showPartDivider && "justify-center")}>
|
<div className={clsx("flex flex-col h-full w-full gap-8 items-center", showPartDivider && "justify-center")}>
|
||||||
<PracticeModal key={partIndex} open={hasPractice} />
|
<PracticeModal key={`${partIndex}_${showPartDivider}`} open={hasPractice} />
|
||||||
<Modal
|
<Modal
|
||||||
className={"!w-2/6 !p-8"}
|
className={"!w-2/6 !p-8"}
|
||||||
titleClassName={"font-bold text-3xl text-mti-rose-light"}
|
titleClassName={"font-bold text-3xl text-mti-rose-light"}
|
||||||
|
|||||||
@@ -135,8 +135,8 @@ const Listening: React.FC<ExamProps<ListeningExam>> = ({ exam, showSolutions = f
|
|||||||
/>, [partIndex, assignment, timesListened, setShowTextModal, setTimesListened])
|
/>, [partIndex, assignment, timesListened, setShowTextModal, setTimesListened])
|
||||||
|
|
||||||
const memoizedInstructions = useMemo(() =>
|
const memoizedInstructions = useMemo(() =>
|
||||||
<RenderAudioInstructionsPlayer />
|
<RenderAudioInstructionsPlayer instructions={exam.instructions} />
|
||||||
, [])
|
, [exam.instructions])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -178,7 +178,7 @@ const Listening: React.FC<ExamProps<ListeningExam>> = ({ exam, showSolutions = f
|
|||||||
{startNow && memoizedInstructions}
|
{startNow && memoizedInstructions}
|
||||||
|
|
||||||
{/* Part's audio player */}
|
{/* Part's audio player */}
|
||||||
{(!startNow && isBetweenParts) && memoizedRenderAudioPlayer}
|
{!startNow && memoizedRenderAudioPlayer}
|
||||||
|
|
||||||
{/* Exercise renderer */}
|
{/* Exercise renderer */}
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -2,16 +2,23 @@ import AudioPlayer from "@/components/Low/AudioPlayer";
|
|||||||
import { v4 } from "uuid";
|
import { v4 } from "uuid";
|
||||||
|
|
||||||
|
|
||||||
const INSTRUCTIONS_AUDIO_SRC =
|
// Old instructions they were porbably taken from
|
||||||
|
// an heygen video since I couldn't find the Polly Voice
|
||||||
|
const OLD_INSTRUCTIONS_AUDIO_SRC =
|
||||||
"https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/generic_listening_intro_v2.mp3?alt=media&token=16769f5f-1e9b-4a72-86a9-45a6f0fa9f82";
|
"https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/generic_listening_intro_v2.mp3?alt=media&token=16769f5f-1e9b-4a72-86a9-45a6f0fa9f82";
|
||||||
|
|
||||||
const RenderAudioInstructionsPlayer: React.FC = () => (
|
// New full exam module audio with Polly Matthew voice
|
||||||
|
const NEW_INSTRUCTIONS_AUDIO_SRC =
|
||||||
|
"https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_1_2_3_4.mp3?alt=media&token=7a7ac516-221d-4e79-bd28-5d6bee9d79d8";
|
||||||
|
|
||||||
|
|
||||||
|
const RenderAudioInstructionsPlayer: React.FC<{instructions?: string}> = ({instructions}) => (
|
||||||
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
|
<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">
|
<div className="flex flex-col w-full gap-2">
|
||||||
<h4 className="text-xl font-semibold">Please listen to the instructions audio attentively.</h4>
|
<h4 className="text-xl font-semibold">Please listen to the instructions audio attentively.</h4>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
|
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
|
||||||
<AudioPlayer key={v4()} src={INSTRUCTIONS_AUDIO_SRC} color="listening" />
|
<AudioPlayer key={v4()} src={instructions ?? NEW_INSTRUCTIONS_AUDIO_SRC} color="listening" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ export interface Entity {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
licenses: number;
|
licenses: number;
|
||||||
|
expiryDate?: Date | null
|
||||||
|
payment?: {
|
||||||
|
currency: string
|
||||||
|
price: number
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Role {
|
export interface Role {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import instructions from "@/pages/api/exam/media/instructions";
|
||||||
import { Module } from ".";
|
import { Module } from ".";
|
||||||
|
|
||||||
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
|
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
|
||||||
@@ -19,6 +20,7 @@ export interface ExamBase {
|
|||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
difficulty?: Difficulty;
|
difficulty?: Difficulty;
|
||||||
owners?: string[];
|
owners?: string[];
|
||||||
|
entities?: string[]
|
||||||
shuffle?: boolean;
|
shuffle?: boolean;
|
||||||
createdBy?: string; // option as it has been added later
|
createdBy?: string; // option as it has been added later
|
||||||
createdAt?: string; // option as it has been added later
|
createdAt?: string; // option as it has been added later
|
||||||
@@ -68,6 +70,7 @@ export interface LevelPart extends Section {
|
|||||||
export interface ListeningExam extends ExamBase {
|
export interface ListeningExam extends ExamBase {
|
||||||
parts: ListeningPart[];
|
parts: ListeningPart[];
|
||||||
module: "listening";
|
module: "listening";
|
||||||
|
instructions?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Message = { name: string; gender: string; text: string; voice?: string; };
|
export type Message = { name: string; gender: string; text: string; voice?: string; };
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ interface Customer {
|
|||||||
extras: IntentionExtras;
|
extras: IntentionExtras;
|
||||||
}
|
}
|
||||||
|
|
||||||
type IntentionExtras = {[key: string]: string | number};
|
type IntentionExtras = { [key: string]: string | number | undefined };
|
||||||
|
|
||||||
export interface IntentionResult {
|
export interface IntentionResult {
|
||||||
payment_keys: PaymentKeysItem[];
|
payment_keys: PaymentKeysItem[];
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export type DurationUnit = "weeks" | "days" | "months" | "years";
|
|||||||
export interface Payment {
|
export interface Payment {
|
||||||
id: string;
|
id: string;
|
||||||
corporate: string;
|
corporate: string;
|
||||||
|
entity?: string
|
||||||
agent?: string;
|
agent?: string;
|
||||||
agentCommission: number;
|
agentCommission: number;
|
||||||
agentValue: number;
|
agentValue: number;
|
||||||
|
|||||||
@@ -87,7 +87,15 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi
|
|||||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||||
const [type, setType] = useState<Type>("student");
|
const [type, setType] = useState<Type>("student");
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined)
|
const [entity, setEntity] = useState<{id: string | null, label: string | null}| undefined>(() => {
|
||||||
|
if (!entities?.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: entities[0].id,
|
||||||
|
label: entities[0].label
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const { openFilePicker, filesContent, clear } = useFilePicker({
|
const { openFilePicker, filesContent, clear } = useFilePicker({
|
||||||
accept: ".xlsx",
|
accept: ".xlsx",
|
||||||
@@ -291,11 +299,28 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi
|
|||||||
if (!!crossRefEmails) {
|
if (!!crossRefEmails) {
|
||||||
const existingEmails = new Set(crossRefEmails.map((x: any) => x.email));
|
const existingEmails = new Set(crossRefEmails.map((x: any) => x.email));
|
||||||
const dupes = infos.filter(info => existingEmails.has(info.email));
|
const dupes = infos.filter(info => existingEmails.has(info.email));
|
||||||
const newUsersList = infos.filter(info => !existingEmails.has(info.email));
|
const newUsersList = infos
|
||||||
|
.filter(info => !existingEmails.has(info.email))
|
||||||
|
.map(info => ({
|
||||||
|
...info,
|
||||||
|
entityLabels: [entity!.label!]
|
||||||
|
}));
|
||||||
setNewUsers(newUsersList);
|
setNewUsers(newUsersList);
|
||||||
setDuplicatedUsers(dupes);
|
|
||||||
|
const {data: emailEntityMap} = await axios.post("/api/users/controller?op=getEntities", {
|
||||||
|
emails: dupes.map((x) => x.email)
|
||||||
|
});
|
||||||
|
const withLabels = dupes.map((u) => ({
|
||||||
|
...u,
|
||||||
|
entityLabels: emailEntityMap.find((e: any) => e.email === u.email)?.entityLabels || []
|
||||||
|
}))
|
||||||
|
setDuplicatedUsers(withLabels);
|
||||||
} else {
|
} else {
|
||||||
setNewUsers(infos);
|
const withLabel = infos.map(info => ({
|
||||||
|
...info,
|
||||||
|
entityLabels: [entity!.label!]
|
||||||
|
}));
|
||||||
|
setNewUsers(withLabel);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Something went wrong, please try again later!");
|
toast.error("Something went wrong, please try again later!");
|
||||||
@@ -305,7 +330,7 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi
|
|||||||
if (infos.length > 0) {
|
if (infos.length > 0) {
|
||||||
crossReferenceEmails();
|
crossReferenceEmails();
|
||||||
}
|
}
|
||||||
}, [infos]);
|
}, [infos, entity]);
|
||||||
|
|
||||||
const makeUsers = async () => {
|
const makeUsers = async () => {
|
||||||
const newUsersSentence = newUsers.length > 0 ? `create ${newUsers.length} user(s)` : undefined;
|
const newUsersSentence = newUsers.length > 0 ? `create ${newUsers.length} user(s)` : undefined;
|
||||||
@@ -459,7 +484,16 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi
|
|||||||
<Select
|
<Select
|
||||||
defaultValue={{ value: (entities || [])[0]?.id, label: (entities || [])[0]?.label }}
|
defaultValue={{ value: (entities || [])[0]?.id, label: (entities || [])[0]?.label }}
|
||||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||||
onChange={(e) => setEntity(e?.value || undefined)}
|
onChange={(e) => {
|
||||||
|
if (!e) {
|
||||||
|
setEntity(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEntity({
|
||||||
|
id: e?.value,
|
||||||
|
label: e?.label
|
||||||
|
});
|
||||||
|
}}
|
||||||
isClearable={checkAccess(user, ["admin", "developer"])}
|
isClearable={checkAccess(user, ["admin", "developer"])}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import ShortUniqueId from "short-unique-id";
|
|||||||
import { ExamProps } from "@/exams/types";
|
import { ExamProps } from "@/exams/types";
|
||||||
import useExamStore from "@/stores/exam";
|
import useExamStore from "@/stores/exam";
|
||||||
import useEvaluationPolling from "@/hooks/useEvaluationPolling";
|
import useEvaluationPolling from "@/hooks/useEvaluationPolling";
|
||||||
import PracticeModal from "@/components/PracticeModal";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
page: "exams" | "exercises";
|
page: "exams" | "exercises";
|
||||||
@@ -149,8 +148,10 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar =
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (flags.finalizeExam && moduleIndex !== -1) {
|
if (flags.finalizeExam && moduleIndex !== -1) {
|
||||||
setModuleIndex(-1);
|
setModuleIndex(-1);
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}, [flags, moduleIndex, setModuleIndex]);
|
}, [flags.finalizeExam, moduleIndex, setModuleIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (flags.finalizeExam && !flags.pendingEvaluation && pendingExercises.length === 0) {
|
if (flags.finalizeExam && !flags.pendingEvaluation && pendingExercises.length === 0) {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import usePackages from "@/hooks/usePackages";
|
|||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import { User } from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { capitalize } from "lodash";
|
import { capitalize, sortBy } from "lodash";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import useInvites from "@/hooks/useInvites";
|
import useInvites from "@/hooks/useInvites";
|
||||||
import { BsArrowRepeat } from "react-icons/bs";
|
import { BsArrowRepeat } from "react-icons/bs";
|
||||||
import InviteCard from "@/components/Medium/InviteCard";
|
import InviteCard from "@/components/Medium/InviteCard";
|
||||||
@@ -16,46 +16,50 @@ import useDiscounts from "@/hooks/useDiscounts";
|
|||||||
import PaymobPayment from "@/components/PaymobPayment";
|
import PaymobPayment from "@/components/PaymobPayment";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
|
import { Discount, Package } from "@/interfaces/paypal";
|
||||||
|
import { isAdmin } from "@/utils/users";
|
||||||
|
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
||||||
|
import Select from "@/components/Low/Select";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User
|
||||||
|
discounts: Discount[]
|
||||||
|
packages: Package[]
|
||||||
entities: EntityWithRoles[]
|
entities: EntityWithRoles[]
|
||||||
hasExpired?: boolean;
|
hasExpired?: boolean;
|
||||||
reload: () => void;
|
reload: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PaymentDue({ user, hasExpired = false, reload }: Props) {
|
export default function PaymentDue({ user, discounts = [], entities = [], packages = [], hasExpired = false, reload }: Props) {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [appliedDiscount, setAppliedDiscount] = useState(0);
|
const [entity, setEntity] = useState<EntityWithRoles>()
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { packages } = usePackages();
|
|
||||||
const { discounts } = useDiscounts();
|
|
||||||
const { users } = useUsers();
|
const { users } = useUsers();
|
||||||
const { groups } = useGroups({});
|
|
||||||
const { invites, isLoading: isInvitesLoading, reload: reloadInvites } = useInvites({ to: user?.id });
|
const { invites, isLoading: isInvitesLoading, reload: reloadInvites } = useInvites({ to: user?.id });
|
||||||
|
|
||||||
useEffect(() => {
|
const isIndividual = useMemo(() => {
|
||||||
const userDiscounts = discounts.filter((x) => user.email.endsWith(`@${x.domain}`));
|
if (isAdmin(user)) return false;
|
||||||
if (userDiscounts.length === 0) return;
|
|
||||||
|
|
||||||
const biggestDiscount = [...userDiscounts].sort((a, b) => b.percentage - a.percentage).shift();
|
|
||||||
if (!biggestDiscount || (biggestDiscount.validUntil && moment(biggestDiscount.validUntil).isBefore(moment()))) return;
|
|
||||||
|
|
||||||
setAppliedDiscount(biggestDiscount.percentage);
|
|
||||||
}, [discounts, user]);
|
|
||||||
|
|
||||||
const isIndividual = () => {
|
|
||||||
if (user?.type === "developer") return true;
|
|
||||||
if (user?.type !== "student") return false;
|
if (user?.type !== "student") return false;
|
||||||
const userGroups = groups.filter((g) => g.participants.includes(user?.id));
|
|
||||||
|
|
||||||
if (userGroups.length === 0) return true;
|
return user.entities.length === 0
|
||||||
|
}, [user])
|
||||||
|
|
||||||
const userGroupsAdminTypes = userGroups.map((g) => users?.find((u) => u.id === g.admin)?.type).filter((t) => !!t);
|
const appliedDiscount = useMemo(() => {
|
||||||
return userGroupsAdminTypes.every((t) => t !== "corporate");
|
const biggestDiscount = [...discounts].sort((a, b) => b.percentage - a.percentage).shift();
|
||||||
};
|
|
||||||
|
if (!biggestDiscount || (biggestDiscount.validUntil && moment(biggestDiscount.validUntil).isBefore(moment())))
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
return biggestDiscount.percentage
|
||||||
|
}, [discounts])
|
||||||
|
|
||||||
|
const entitiesThatCanBePaid = useAllowedEntities(user, entities, 'pay_entity')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (entitiesThatCanBePaid.length > 0) setEntity(entitiesThatCanBePaid[0])
|
||||||
|
}, [entitiesThatCanBePaid])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -74,7 +78,6 @@ export default function PaymentDue({ user, hasExpired = false, reload }: Props)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{user ? (
|
|
||||||
<Layout user={user} navDisabled={hasExpired}>
|
<Layout user={user} navDisabled={hasExpired}>
|
||||||
{invites.length > 0 && (
|
{invites.length > 0 && (
|
||||||
<section className="flex flex-col gap-1 md:gap-3">
|
<section className="flex flex-col gap-1 md:gap-3">
|
||||||
@@ -104,7 +107,7 @@ export default function PaymentDue({ user, hasExpired = false, reload }: Props)
|
|||||||
|
|
||||||
<div className="flex w-full flex-col items-center justify-center gap-4 text-center">
|
<div className="flex w-full flex-col items-center justify-center gap-4 text-center">
|
||||||
{hasExpired && <span className="text-lg font-bold">You do not have time credits for your account type!</span>}
|
{hasExpired && <span className="text-lg font-bold">You do not have time credits for your account type!</span>}
|
||||||
{isIndividual() && (
|
{isIndividual && (
|
||||||
<div className="scrollbar-hide flex w-full flex-col items-center gap-12 overflow-x-scroll">
|
<div className="scrollbar-hide flex w-full flex-col items-center gap-12 overflow-x-scroll">
|
||||||
<span className="max-w-lg">
|
<span className="max-w-lg">
|
||||||
To add to your use of EnCoach, please purchase one of the time packages available below:
|
To add to your use of EnCoach, please purchase one of the time packages available below:
|
||||||
@@ -162,10 +165,20 @@ export default function PaymentDue({ user, hasExpired = false, reload }: Props)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isIndividual() &&
|
|
||||||
(user?.type === "corporate" || user?.type === "mastercorporate") &&
|
{!isIndividual && entitiesThatCanBePaid.length > 0 &&
|
||||||
user?.corporateInformation.payment && (
|
entity?.payment && (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center gap-8">
|
||||||
|
<div className={clsx("flex flex-col items-center gap-4 w-full")}>
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
|
||||||
|
<Select
|
||||||
|
defaultValue={{ value: entity?.id, label: entity?.label }}
|
||||||
|
options={entitiesThatCanBePaid.map((e) => ({ value: e.id, label: e.label, entity: e }))}
|
||||||
|
onChange={(e) => e?.value ? setEntity(e?.entity) : null}
|
||||||
|
className="!w-full max-w-[400px] self-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<span className="max-w-lg">
|
<span className="max-w-lg">
|
||||||
To add to your use of EnCoach and that of your students and teachers, please pay your designated package
|
To add to your use of EnCoach and that of your students and teachers, please pay your designated package
|
||||||
below:
|
below:
|
||||||
@@ -179,13 +192,14 @@ export default function PaymentDue({ user, hasExpired = false, reload }: Props)
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-col items-start gap-2">
|
<div className="flex w-full flex-col items-start gap-2">
|
||||||
<span className="text-2xl">
|
<span className="text-2xl">
|
||||||
{user.corporateInformation.payment.value} {user.corporateInformation.payment.currency}
|
{entity.payment.price} {entity.payment.currency}
|
||||||
</span>
|
</span>
|
||||||
<PaymobPayment
|
<PaymobPayment
|
||||||
user={user}
|
user={user}
|
||||||
setIsPaymentLoading={setIsLoading}
|
setIsPaymentLoading={setIsLoading}
|
||||||
currency={user.corporateInformation.payment.currency}
|
entity={entity}
|
||||||
price={user.corporateInformation.payment.value}
|
currency={entity.payment.currency}
|
||||||
|
price={entity.payment.price}
|
||||||
duration={12}
|
duration={12}
|
||||||
duration_unit="months"
|
duration_unit="months"
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
@@ -198,7 +212,7 @@ export default function PaymentDue({ user, hasExpired = false, reload }: Props)
|
|||||||
<span>This includes:</span>
|
<span>This includes:</span>
|
||||||
<ul className="flex flex-col items-start text-sm">
|
<ul className="flex flex-col items-start text-sm">
|
||||||
<li>
|
<li>
|
||||||
- Allow a total of 0 students and teachers to use EnCoach
|
- Allow a total of {entity.licenses} students and teachers to use EnCoach
|
||||||
</li>
|
</li>
|
||||||
<li>- Train their abilities for the IELTS exam</li>
|
<li>- Train their abilities for the IELTS exam</li>
|
||||||
<li>- Gain insights into your students' weaknesses and strengths</li>
|
<li>- Gain insights into your students' weaknesses and strengths</li>
|
||||||
@@ -208,7 +222,7 @@ export default function PaymentDue({ user, hasExpired = false, reload }: Props)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isIndividual() && !(user?.type === "corporate" || user?.type === "mastercorporate") && (
|
{!isIndividual && entitiesThatCanBePaid.length === 0 && (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<span className="max-w-lg">
|
<span className="max-w-lg">
|
||||||
You are not the person in charge of your time credits, please contact your administrator about this situation.
|
You are not the person in charge of your time credits, please contact your administrator about this situation.
|
||||||
@@ -219,10 +233,19 @@ export default function PaymentDue({ user, hasExpired = false, reload }: Props)
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isIndividual() &&
|
{!isIndividual &&
|
||||||
(user?.type === "corporate" || user?.type === "mastercorporate") &&
|
entitiesThatCanBePaid.length > 0 &&
|
||||||
!user.corporateInformation.payment && (
|
!entity?.payment && (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center gap-8">
|
||||||
|
<div className={clsx("flex flex-col items-center gap-4 w-full")}>
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
|
||||||
|
<Select
|
||||||
|
defaultValue={{ value: entity?.id || "", label: entity?.label || "" }}
|
||||||
|
options={entitiesThatCanBePaid.map((e) => ({ value: e.id, label: e.label, entity: e }))}
|
||||||
|
onChange={(e) => e?.value ? setEntity(e?.entity) : null}
|
||||||
|
className="!w-full max-w-[400px] self-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<span className="max-w-lg">
|
<span className="max-w-lg">
|
||||||
An admin nor your agent have yet set the price intended to your requirements in terms of the amount of users
|
An admin nor your agent have yet set the price intended to your requirements in terms of the amount of users
|
||||||
you desire and your expected monthly duration.
|
you desire and your expected monthly duration.
|
||||||
@@ -234,9 +257,6 @@ export default function PaymentDue({ user, hasExpired = false, reload }: Props)
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
) : (
|
|
||||||
<div />
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import { deleteEntity, getEntity, getEntityWithRoles } from "@/utils/entities.be
|
|||||||
import client from "@/lib/mongodb";
|
import client from "@/lib/mongodb";
|
||||||
import { Entity } from "@/interfaces/entity";
|
import { Entity } from "@/interfaces/entity";
|
||||||
import { doesEntityAllow } from "@/utils/permissions";
|
import { doesEntityAllow } from "@/utils/permissions";
|
||||||
import { getUser } from "@/utils/users.be";
|
import { getEntityUsers, getUser } from "@/utils/users.be";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import { isAdmin } from "@/utils/users";
|
import { isAdmin } from "@/utils/users";
|
||||||
|
import { filterBy, mapBy } from "@/utils";
|
||||||
|
import { User } from "@/interfaces/user";
|
||||||
|
|
||||||
const db = client.db(process.env.MONGODB_DB);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
@@ -66,5 +68,27 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return res.status(200).json({ ok: entity.acknowledged });
|
return res.status(200).json({ ok: entity.acknowledged });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.body.payment) {
|
||||||
|
const entity = await db.collection<Entity>("entities").updateOne({ id }, { $set: { payment: req.body.payment } });
|
||||||
|
return res.status(200).json({ ok: entity.acknowledged });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.body.expiryDate !== undefined) {
|
||||||
|
const entity = await getEntity(id)
|
||||||
|
const result = await db.collection<Entity>("entities").updateOne({ id }, { $set: { expiryDate: req.body.expiryDate } });
|
||||||
|
|
||||||
|
const users = await getEntityUsers(id, 0, {
|
||||||
|
subscriptionExpirationDate: entity?.expiryDate,
|
||||||
|
$and: [
|
||||||
|
{ type: { $ne: "admin" } },
|
||||||
|
{ type: { $ne: "developer" } },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.collection<User>("users").updateMany({ id: { $in: mapBy(users, 'id') } }, { $set: { subscriptionExpirationDate: req.body.expiryDate } })
|
||||||
|
|
||||||
|
return res.status(200).json({ ok: result.acknowledged });
|
||||||
|
}
|
||||||
|
|
||||||
return res.status(200).json({ ok: true });
|
return res.status(200).json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import axios from "axios";
|
|||||||
import formidable from "formidable-serverless";
|
import formidable from "formidable-serverless";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import FormData from 'form-data';
|
import FormData from 'form-data';
|
||||||
|
import client from "@/lib/mongodb";
|
||||||
|
|
||||||
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -66,6 +68,34 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if there is one eval for the current exercise
|
||||||
|
const previousEval = await db.collection("evaluation").findOne({
|
||||||
|
user: fields.userId,
|
||||||
|
session_id: fields.sessionId,
|
||||||
|
exercise_id: fields.exerciseId,
|
||||||
|
})
|
||||||
|
|
||||||
|
// If there is delete it
|
||||||
|
if (previousEval) {
|
||||||
|
await db.collection("evaluation").deleteOne({
|
||||||
|
user: fields.userId,
|
||||||
|
session_id: fields.sessionId,
|
||||||
|
exercise_id: fields.exerciseId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert the new eval for the backend to place it's result
|
||||||
|
await db.collection("evaluation").insertOne(
|
||||||
|
{
|
||||||
|
user: fields.userId,
|
||||||
|
session_id: fields.sessionId,
|
||||||
|
exercise_id: fields.exerciseId,
|
||||||
|
type: "speaking_interactive",
|
||||||
|
task: fields.task,
|
||||||
|
status: "pending"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
await axios.post(
|
await axios.post(
|
||||||
`${process.env.BACKEND_URL}/grade/speaking/${fields.task}`,
|
`${process.env.BACKEND_URL}/grade/speaking/${fields.task}`,
|
||||||
formData,
|
formData,
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import axios from "axios";
|
|||||||
import formidable from "formidable-serverless";
|
import formidable from "formidable-serverless";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import FormData from 'form-data';
|
import FormData from 'form-data';
|
||||||
|
import client from "@/lib/mongodb";
|
||||||
|
|
||||||
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
@@ -41,6 +44,34 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
formData.append('audio_1', buffer, 'audio_1.wav');
|
formData.append('audio_1', buffer, 'audio_1.wav');
|
||||||
fs.rmSync(audioFile.path);
|
fs.rmSync(audioFile.path);
|
||||||
|
|
||||||
|
// Check if there is one eval for the current exercise
|
||||||
|
const previousEval = await db.collection("evaluation").findOne({
|
||||||
|
user: fields.userId,
|
||||||
|
session_id: fields.sessionId,
|
||||||
|
exercise_id: fields.exerciseId,
|
||||||
|
})
|
||||||
|
|
||||||
|
// If there is delete it
|
||||||
|
if (previousEval) {
|
||||||
|
await db.collection("evaluation").deleteOne({
|
||||||
|
user: fields.userId,
|
||||||
|
session_id: fields.sessionId,
|
||||||
|
exercise_id: fields.exerciseId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert the new eval for the backend to place it's result
|
||||||
|
await db.collection("evaluation").insertOne(
|
||||||
|
{
|
||||||
|
user: fields.userId,
|
||||||
|
session_id: fields.sessionId,
|
||||||
|
exercise_id: fields.exerciseId,
|
||||||
|
type: "speaking",
|
||||||
|
task: 2,
|
||||||
|
status: "pending"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
await axios.post(
|
await axios.post(
|
||||||
`${process.env.BACKEND_URL}/grade/speaking/2`,
|
`${process.env.BACKEND_URL}/grade/speaking/2`,
|
||||||
formData,
|
formData,
|
||||||
|
|||||||
@@ -17,18 +17,13 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {sessionId, userId, exerciseIds} = req.query;
|
const { sessionId, userId } = req.query;
|
||||||
const exercises = (exerciseIds! as string).split(',');
|
|
||||||
const finishedEvaluations = await db.collection("evaluation").find({
|
const singleEval = await db.collection("evaluation").findOne({
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
user: userId,
|
user: userId,
|
||||||
$or: [
|
status: "pending",
|
||||||
{ status: "completed" },
|
});
|
||||||
{ status: "error" }
|
|
||||||
],
|
|
||||||
exercise_id: { $in: exercises }
|
|
||||||
}).toArray();
|
|
||||||
|
|
||||||
const finishedExerciseIds = finishedEvaluations.map(evaluation => evaluation.exercise_id);
|
res.status(200).json({ hasPendingEvaluation: singleEval !== null});
|
||||||
res.status(200).json({ finishedExerciseIds });
|
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,9 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import client from "@/lib/mongodb";
|
||||||
|
|
||||||
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
interface Body {
|
interface Body {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -22,8 +25,36 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { task, ...body} = req.body as Body;
|
const body = req.body as Body;
|
||||||
const taskNumber = task.toString() !== "1" && task.toString() !== "2" ? "1" : task.toString();
|
const taskNumber = body.task.toString() !== "1" && body.task.toString() !== "2" ? "1" : body.task.toString();
|
||||||
|
|
||||||
|
// Check if there is one eval for the current exercise
|
||||||
|
const previousEval = await db.collection("evaluation").findOne({
|
||||||
|
user: body.userId,
|
||||||
|
session_id: body.sessionId,
|
||||||
|
exercise_id: body.exerciseId,
|
||||||
|
})
|
||||||
|
|
||||||
|
// If there is delete it
|
||||||
|
if (previousEval) {
|
||||||
|
await db.collection("evaluation").deleteOne({
|
||||||
|
user: body.userId,
|
||||||
|
session_id: body.sessionId,
|
||||||
|
exercise_id: body.exerciseId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert the new eval for the backend to place it's result
|
||||||
|
await db.collection("evaluation").insertOne(
|
||||||
|
{
|
||||||
|
user: body.userId,
|
||||||
|
session_id: body.sessionId,
|
||||||
|
exercise_id: body.exerciseId,
|
||||||
|
type: "writing",
|
||||||
|
task: body.task,
|
||||||
|
status: "pending"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
await axios.post(`${process.env.BACKEND_URL}/grade/writing/${taskNumber}`, body, {
|
await axios.post(`${process.env.BACKEND_URL}/grade/writing/${taskNumber}`, body, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { Exam, ExamBase, InstructorGender, Variant } from "@/interfaces/exam";
|
|||||||
import { getExams } from "@/utils/exams.be";
|
import { getExams } from "@/utils/exams.be";
|
||||||
import { Module } from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import { getUserCorporate } from "@/utils/groups.be";
|
import { getUserCorporate } from "@/utils/groups.be";
|
||||||
|
import { requestUser } from "@/utils/api";
|
||||||
|
import { isAdmin } from "@/utils/users";
|
||||||
|
import { mapBy } from "@/utils";
|
||||||
|
|
||||||
const db = client.db(process.env.MONGODB_DB);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
@@ -37,25 +40,20 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function POST(req: NextApiRequest, res: NextApiResponse) {
|
async function POST(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
const user = await requestUser(req, res)
|
||||||
res.status(401).json({ ok: false });
|
if (!user) return res.status(401).json({ ok: false });
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { module } = req.query as { module: string };
|
const { module } = req.query as { module: string };
|
||||||
const corporate = await getUserCorporate(req.session.user.id);
|
|
||||||
|
|
||||||
const session = client.startSession();
|
const session = client.startSession();
|
||||||
|
const entities = isAdmin(user) ? [] : mapBy(user.entities, 'id')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const exam = {
|
const exam = {
|
||||||
...req.body,
|
...req.body,
|
||||||
module: module,
|
module: module,
|
||||||
owners: [
|
entities,
|
||||||
...(["mastercorporate", "corporate"].includes(req.session.user.type) ? [req.session.user.id] : []),
|
createdBy: user.id,
|
||||||
...(!!corporate ? [corporate.id] : []),
|
|
||||||
],
|
|
||||||
createdBy: req.session.user.id,
|
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
36
src/pages/api/exam/media/instructions.ts
Normal file
36
src/pages/api/exam/media/instructions.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === "POST") return post(req, res);
|
||||||
|
|
||||||
|
return res.status(404).json({ ok: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (!req.session.user) return res.status(401).json({ ok: false });
|
||||||
|
|
||||||
|
const response = await axios.post(
|
||||||
|
`${process.env.BACKEND_URL}/listening/instructions`,
|
||||||
|
req.body,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
||||||
|
Accept: 'audio/mpeg'
|
||||||
|
},
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'audio/mpeg',
|
||||||
|
'Content-Length': response.data.length
|
||||||
|
});
|
||||||
|
|
||||||
|
res.end(response.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
@@ -10,6 +10,10 @@ import axios from "axios";
|
|||||||
import { IntentionResult, PaymentIntention, TransactionResult } from "@/interfaces/paymob";
|
import { IntentionResult, PaymentIntention, TransactionResult } from "@/interfaces/paymob";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import client from "@/lib/mongodb";
|
import client from "@/lib/mongodb";
|
||||||
|
import { getEntity } from "@/utils/entities.be";
|
||||||
|
import { Entity } from "@/interfaces/entity";
|
||||||
|
import { getEntityUsers } from "@/utils/users.be";
|
||||||
|
import { mapBy } from "@/utils";
|
||||||
|
|
||||||
const db = client.db(process.env.MONGODB_DB);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
@@ -25,10 +29,11 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
if (!checkTransaction(authToken, transactionResult.transaction.order.id)) return res.status(404).json({ ok: false });
|
if (!checkTransaction(authToken, transactionResult.transaction.order.id)) return res.status(404).json({ ok: false });
|
||||||
if (!transactionResult.transaction.success) return res.status(400).json({ ok: false });
|
if (!transactionResult.transaction.success) return res.status(400).json({ ok: false });
|
||||||
|
|
||||||
const {userID, duration, duration_unit} = transactionResult.intention.extras.creation_extras as {
|
const { userID, duration, duration_unit, entity: entityID } = transactionResult.intention.extras.creation_extras as {
|
||||||
userID: string;
|
userID: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
duration_unit: DurationUnit;
|
duration_unit: DurationUnit;
|
||||||
|
entity: string
|
||||||
};
|
};
|
||||||
|
|
||||||
const user = await db.collection("users").findOne<User>({ id: userID as string });
|
const user = await db.collection("users").findOne<User>({ id: userID as string });
|
||||||
@@ -60,22 +65,19 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
value: transactionResult.transaction.amount_cents / 1000,
|
value: transactionResult.transaction.amount_cents / 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user.type === "corporate") {
|
if (entityID) {
|
||||||
const groups = await db.collection("groups").find<Group>({ admin: user.id }).toArray();
|
const entity = await getEntity(entityID)
|
||||||
|
await db.collection<Entity>("entities").updateOne({ id: entityID }, { $set: { expiryDate: req.body.expiryDate } });
|
||||||
|
|
||||||
const participants = (await Promise.all(
|
const users = await getEntityUsers(entityID, 0, {
|
||||||
groups.flatMap((x) => x.participants).map(async (x) => ({...(await db.collection("users").findOne({ id: x}))})),
|
subscriptionExpirationDate: entity?.expiryDate,
|
||||||
)) as User[];
|
$and: [
|
||||||
const sameExpiryDateParticipants = participants.filter(
|
{ type: { $ne: "admin" } },
|
||||||
(x) => x.subscriptionExpirationDate === subscriptionExpirationDate && x.status !== "disabled",
|
{ type: { $ne: "developer" } },
|
||||||
);
|
]
|
||||||
|
})
|
||||||
|
|
||||||
for (const participant of sameExpiryDateParticipants) {
|
await db.collection<User>("users").updateMany({ id: { $in: mapBy(users, 'id') } }, { $set: { subscriptionExpirationDate: req.body.expiryDate } })
|
||||||
await db.collection("users").updateOne(
|
|
||||||
{ id: participant.id },
|
|
||||||
{ $set: {subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active"} }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
|
|||||||
42
src/pages/api/stats/updateDisabled.ts
Normal file
42
src/pages/api/stats/updateDisabled.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import client from "@/lib/mongodb";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import { Stat } from "@/interfaces/user";
|
||||||
|
import { requestUser } from "@/utils/api";
|
||||||
|
import { UserSolution } from "@/interfaces/exam";
|
||||||
|
|
||||||
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === "POST") return post(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Body {
|
||||||
|
solutions: UserSolution[];
|
||||||
|
sessionID: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const user = await requestUser(req, res)
|
||||||
|
if (!user) return res.status(401).json({ ok: false });
|
||||||
|
|
||||||
|
const { solutions, sessionID } = req.body as Body;
|
||||||
|
|
||||||
|
const disabledStats = await db.collection("stats").find({ user: user.id, session: sessionID, disabled: true }).toArray();
|
||||||
|
|
||||||
|
await Promise.all(disabledStats.map(async (stat) => {
|
||||||
|
const matchingSolution = solutions.find(s => s.exercise === stat.exercise);
|
||||||
|
if (matchingSolution) {
|
||||||
|
await db.collection("stats").updateOne(
|
||||||
|
{ id: stat.id },
|
||||||
|
{ $set: { ...matchingSolution } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.status(200).json({ ok: true });
|
||||||
|
}
|
||||||
@@ -38,6 +38,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
await assignToEntity(req.body);
|
await assignToEntity(req.body);
|
||||||
res.status(200).json({"ok": true});
|
res.status(200).json({"ok": true});
|
||||||
break;
|
break;
|
||||||
|
case 'getEntities':
|
||||||
|
res.status(200).json(await getEntities(req.body.emails))
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
res.status(400).json({ error: 'Invalid operation!' })
|
res.status(400).json({ error: 'Invalid operation!' })
|
||||||
}
|
}
|
||||||
@@ -276,6 +279,36 @@ async function getIds(emails: string[]): Promise<Array<{ email: string; id: stri
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getEntities(emails: string[]): Promise<Array<{ email: string; entityLabels: string[] }>> {
|
||||||
|
const users = await db.collection('users')
|
||||||
|
.find({ email: { $in: emails } })
|
||||||
|
.project({ email: 1, entities: 1, _id: 0 })
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
const entityIds = [...new Set(
|
||||||
|
users.flatMap(user =>
|
||||||
|
(user.entities || []).map((entity: any) => entity.id)
|
||||||
|
)
|
||||||
|
)];
|
||||||
|
|
||||||
|
const entityRecords = await db.collection('entities')
|
||||||
|
.find({ id: { $in: entityIds } })
|
||||||
|
.project({ id: 1, label: 1, _id: 0 })
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
const entityMap = new Map(
|
||||||
|
entityRecords.map(entity => [entity.id, entity.label])
|
||||||
|
);
|
||||||
|
|
||||||
|
return users.map(user => ({
|
||||||
|
email: user.email,
|
||||||
|
entityLabels: (user.entities || [])
|
||||||
|
.map((entity: any) => entityMap.get(entity.id))
|
||||||
|
.filter((label: string): label is string => !!label)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async function assignToEntity(body: any) {
|
async function assignToEntity(body: any) {
|
||||||
const { ids, entity } = body;
|
const { ids, entity } = body;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { uniq } from "lodash";
|
|||||||
import { BsFillMortarboardFill, BsPlus } from "react-icons/bs";
|
import { BsFillMortarboardFill, BsPlus } from "react-icons/bs";
|
||||||
import CardList from "@/components/High/CardList";
|
import CardList from "@/components/High/CardList";
|
||||||
import Separator from "@/components/Low/Separator";
|
import Separator from "@/components/Low/Separator";
|
||||||
import { mapBy, redirect, serialize } from "@/utils";
|
import { findBy, mapBy, redirect, serialize } from "@/utils";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import { findAllowedEntities } from "@/utils/permissions";
|
import { findAllowedEntities } from "@/utils/permissions";
|
||||||
import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be";
|
import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be";
|
||||||
@@ -80,6 +80,12 @@ export default function Home({ user, groups, entities }: Props) {
|
|||||||
<span className="bg-mti-purple text-white font-semibold px-2">Admin</span>
|
<span className="bg-mti-purple text-white font-semibold px-2">Admin</span>
|
||||||
{getUserName(group.admin)}
|
{getUserName(group.admin)}
|
||||||
</span>
|
</span>
|
||||||
|
{!!group.entity && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="bg-mti-purple text-white font-semibold px-2">Entity</span>
|
||||||
|
{findBy(entities, 'id', group.entity)?.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="bg-mti-purple text-white font-semibold px-2">Participants</span>
|
<span className="bg-mti-purple text-white font-semibold px-2">Participants</span>
|
||||||
<span className="bg-mti-purple-light/50 px-2">{group.participants.length}</span>
|
<span className="bg-mti-purple-light/50 px-2">{group.participants.length}</span>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import CardList from "@/components/High/CardList";
|
import CardList from "@/components/High/CardList";
|
||||||
import Layout from "@/components/High/Layout";
|
import Layout from "@/components/High/Layout";
|
||||||
import Select from "@/components/Low/Select";
|
import Select from "@/components/Low/Select";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import Tooltip from "@/components/Low/Tooltip";
|
import Tooltip from "@/components/Low/Tooltip";
|
||||||
import { useEntityPermission } from "@/hooks/useEntityPermissions";
|
import { useEntityPermission } from "@/hooks/useEntityPermissions";
|
||||||
import { useListSearch } from "@/hooks/useListSearch";
|
import { useListSearch } from "@/hooks/useListSearch";
|
||||||
@@ -27,7 +29,11 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { Divider } from "primereact/divider";
|
import { Divider } from "primereact/divider";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import ReactDatePicker from "react-datepicker";
|
||||||
|
import { CURRENCIES } from "@/resources/paypal";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
BsCheck,
|
||||||
BsChevronLeft,
|
BsChevronLeft,
|
||||||
BsClockFill,
|
BsClockFill,
|
||||||
BsEnvelopeFill,
|
BsEnvelopeFill,
|
||||||
@@ -43,6 +49,20 @@ import {
|
|||||||
} from "react-icons/bs";
|
} from "react-icons/bs";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
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 CURRENCIES_OPTIONS = CURRENCIES.map(({ label, currency }) => ({
|
||||||
|
value: currency,
|
||||||
|
label,
|
||||||
|
}));
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, params }) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, params }) => {
|
||||||
const user = req.session.user as User;
|
const user = req.session.user as User;
|
||||||
|
|
||||||
@@ -88,6 +108,9 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
|
|||||||
const [isAdding, setIsAdding] = useState(false);
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||||
|
const [expiryDate, setExpiryDate] = useState(entity?.expiryDate)
|
||||||
|
const [paymentPrice, setPaymentPrice] = useState(entity?.payment?.price)
|
||||||
|
const [paymentCurrency, setPaymentCurrency] = useState(entity?.payment?.currency)
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -99,6 +122,7 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
|
|||||||
const canRemoveMembers = useEntityPermission(user, entity, "remove_from_entity")
|
const canRemoveMembers = useEntityPermission(user, entity, "remove_from_entity")
|
||||||
|
|
||||||
const canAssignRole = useEntityPermission(user, entity, "assign_to_role")
|
const canAssignRole = useEntityPermission(user, entity, "assign_to_role")
|
||||||
|
const canPay = useEntityPermission(user, entity, 'pay_entity')
|
||||||
|
|
||||||
const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]));
|
const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]));
|
||||||
|
|
||||||
@@ -166,6 +190,40 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
|
|||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateExpiryDate = () => {
|
||||||
|
if (!isAdmin(user)) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
axios
|
||||||
|
.patch(`/api/entities/${entity.id}`, { expiryDate })
|
||||||
|
.then(() => {
|
||||||
|
toast.success("The entity has been updated successfully!");
|
||||||
|
router.replace(router.asPath);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
toast.error("Something went wrong!");
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePayment = () => {
|
||||||
|
if (!isAdmin(user)) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
axios
|
||||||
|
.patch(`/api/entities/${entity.id}`, { payment: { price: paymentPrice, currency: paymentCurrency } })
|
||||||
|
.then(() => {
|
||||||
|
toast.success("The entity has been updated successfully!");
|
||||||
|
router.replace(router.asPath);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
toast.error("Something went wrong!");
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
const editLicenses = () => {
|
const editLicenses = () => {
|
||||||
if (!isAdmin(user)) return;
|
if (!isAdmin(user)) return;
|
||||||
|
|
||||||
@@ -289,7 +347,7 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
|
|||||||
<Layout user={user}>
|
<Layout user={user}>
|
||||||
<section className="flex flex-col gap-0">
|
<section className="flex flex-col gap-0">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-end justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
href="/entities"
|
href="/entities"
|
||||||
@@ -298,6 +356,20 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
|
|||||||
</Link>
|
</Link>
|
||||||
<h2 className="font-bold text-2xl">{entity.label} {isAdmin(user) && `- ${entity.licenses || 0} licenses`}</h2>
|
<h2 className="font-bold text-2xl">{entity.label} {isAdmin(user) && `- ${entity.licenses || 0} licenses`}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isAdmin(user) && canPay && (
|
||||||
|
<Link
|
||||||
|
href="/payment"
|
||||||
|
className={clsx(
|
||||||
|
"p-2 w-full max-w-[200px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
!entity.expiryDate ? "!bg-mti-green-ultralight !border-mti-green-light" : expirationDateColor(entity.expiryDate),
|
||||||
|
"bg-white border-mti-gray-platinum",
|
||||||
|
)}>
|
||||||
|
{!entity.expiryDate && "Unlimited"}
|
||||||
|
{entity.expiryDate && moment(entity.expiryDate).format("DD/MM/YYYY")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -332,6 +404,91 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isAdmin(user) && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<div className="w-full flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-4 w-full">
|
||||||
|
{!!expiryDate && (
|
||||||
|
<ReactDatePicker
|
||||||
|
className={clsx(
|
||||||
|
"p-2 w-full max-w-[200px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
|
"hover:border-mti-purple tooltip",
|
||||||
|
!expiryDate ? "!bg-mti-green-ultralight !border-mti-green-light" : expirationDateColor(expiryDate),
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
)}
|
||||||
|
filterDate={(date) => moment(date).isAfter(new Date())}
|
||||||
|
dateFormat="dd/MM/yyyy"
|
||||||
|
selected={expiryDate ? moment(expiryDate).toDate() : null}
|
||||||
|
onChange={(date) => setExpiryDate(date)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!expiryDate && (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"p-2 w-full max-w-[200px] 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",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Unlimited
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
isChecked={!!expiryDate}
|
||||||
|
onChange={(checked: boolean) => setExpiryDate(checked ? entity.expiryDate || new Date() : null)}
|
||||||
|
>
|
||||||
|
Enable expiry date
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={updateExpiryDate}
|
||||||
|
disabled={expiryDate === entity.expiryDate}
|
||||||
|
className="flex w-fit text-nowrap items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
|
||||||
|
<BsCheck />
|
||||||
|
<span className="text-xs">Apply Change</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<div className="w-full flex items-center justify-between gap-8">
|
||||||
|
<div className="w-full max-w-xl flex items-center gap-4">
|
||||||
|
<Input
|
||||||
|
name="paymentValue"
|
||||||
|
onChange={(e) => setPaymentPrice(e ? parseInt(e) : undefined)}
|
||||||
|
type="number"
|
||||||
|
defaultValue={entity.payment?.price || 0}
|
||||||
|
thin
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
className={clsx(
|
||||||
|
"px-4 !py-2 !w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
|
||||||
|
)}
|
||||||
|
options={CURRENCIES_OPTIONS}
|
||||||
|
value={CURRENCIES_OPTIONS.find((c) => c.value === paymentCurrency)}
|
||||||
|
onChange={(value) => setPaymentCurrency(value?.value ?? undefined)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={updatePayment}
|
||||||
|
disabled={!paymentPrice || paymentPrice <= 0 || !paymentCurrency}
|
||||||
|
className="flex w-fit text-nowrap items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
|
||||||
|
<BsCheck />
|
||||||
|
<span className="text-xs">Apply Change</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<span className="font-semibold text-xl">Members ({users.length})</span>
|
<span className="font-semibold text-xl">Members ({users.length})</span>
|
||||||
|
|||||||
@@ -98,7 +98,9 @@ const ENTITY_MANAGEMENT: PermissionLayout[] = [
|
|||||||
{ label: "Delete Entity Role", key: "delete_entity_role" },
|
{ label: "Delete Entity Role", key: "delete_entity_role" },
|
||||||
{ label: "Download Statistics Report", key: "download_statistics_report" },
|
{ label: "Download Statistics Report", key: "download_statistics_report" },
|
||||||
{ label: "Edit Grading System", key: "edit_grading_system" },
|
{ label: "Edit Grading System", key: "edit_grading_system" },
|
||||||
{ label: "View Student Performance", key: "view_student_performance" }
|
{ label: "View Student Performance", key: "view_student_performance" },
|
||||||
|
{ label: "Pay for Entity", key: "pay_entity" },
|
||||||
|
{ label: "View Payment Record", key: "view_payment_record" }
|
||||||
]
|
]
|
||||||
|
|
||||||
const ASSIGNMENT_MANAGEMENT: PermissionLayout[] = [
|
const ASSIGNMENT_MANAGEMENT: PermissionLayout[] = [
|
||||||
|
|||||||
@@ -110,6 +110,10 @@ export default function Generation({ id, user, exam, examModule, permissions }:
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (state.listening.instructionsState.customInstructionsURL.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(state.listening.instructionsState.customInstructionsURL);
|
||||||
|
}
|
||||||
|
|
||||||
state.speaking.sections.forEach(section => {
|
state.speaking.sections.forEach(section => {
|
||||||
const sectionState = section.state as Exercise;
|
const sectionState = section.state as Exercise;
|
||||||
if (sectionState.type === 'speaking') {
|
if (sectionState.type === 'speaking') {
|
||||||
|
|||||||
@@ -30,9 +30,12 @@ import { toFixedNumber } from "@/utils/number";
|
|||||||
import { CSVLink } from "react-csv";
|
import { CSVLink } from "react-csv";
|
||||||
import { Tab } from "@headlessui/react";
|
import { Tab } from "@headlessui/react";
|
||||||
import { useListSearch } from "@/hooks/useListSearch";
|
import { useListSearch } from "@/hooks/useListSearch";
|
||||||
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
import { checkAccess, findAllowedEntities, getTypesOfUser } from "@/utils/permissions";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import { redirect } from "@/utils";
|
import { mapBy, redirect, serialize } from "@/utils";
|
||||||
|
import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be";
|
||||||
|
import { isAdmin } from "@/utils/users";
|
||||||
|
import { Entity, EntityWithRoles } from "@/interfaces/entity";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res)
|
||||||
@@ -42,8 +45,14 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
|||||||
return redirect("/")
|
return redirect("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const entityIDs = mapBy(user.entities, 'id')
|
||||||
|
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs)
|
||||||
|
|
||||||
|
const allowedEntities = findAllowedEntities(user, entities, "view_payment_record")
|
||||||
|
if (allowedEntities.length === 0) return redirect("/")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: { user },
|
props: serialize({ user, entities: allowedEntities }),
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
@@ -273,7 +282,13 @@ interface PaypalPaymentWithUserData extends PaypalPayment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const paypalFilterRows = [["email"], ["name"], ["orderId"], ["value"]];
|
const paypalFilterRows = [["email"], ["name"], ["orderId"], ["value"]];
|
||||||
export default function PaymentRecord() {
|
|
||||||
|
interface Props {
|
||||||
|
user: User
|
||||||
|
entities: EntityWithRoles[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PaymentRecord({ user, entities }: Props) {
|
||||||
const [selectedCorporateUser, setSelectedCorporateUser] = useState<User>();
|
const [selectedCorporateUser, setSelectedCorporateUser] = useState<User>();
|
||||||
const [selectedAgentUser, setSelectedAgentUser] = useState<User>();
|
const [selectedAgentUser, setSelectedAgentUser] = useState<User>();
|
||||||
const [isCreatingPayment, setIsCreatingPayment] = useState(false);
|
const [isCreatingPayment, setIsCreatingPayment] = useState(false);
|
||||||
@@ -281,9 +296,9 @@ export default function PaymentRecord() {
|
|||||||
const [displayPayments, setDisplayPayments] = useState<Payment[]>([]);
|
const [displayPayments, setDisplayPayments] = useState<Payment[]>([]);
|
||||||
|
|
||||||
const [corporate, setCorporate] = useState<User>();
|
const [corporate, setCorporate] = useState<User>();
|
||||||
|
const [entity, setEntity] = useState<Entity>();
|
||||||
const [agent, setAgent] = useState<User>();
|
const [agent, setAgent] = useState<User>();
|
||||||
|
|
||||||
const { user } = useUser({ redirectTo: "/login" });
|
|
||||||
const { users, reload: reloadUsers } = useUsers();
|
const { users, reload: reloadUsers } = useUsers();
|
||||||
const { payments: originalPayments, reload: reloadPayment } = usePayments();
|
const { payments: originalPayments, reload: reloadPayment } = usePayments();
|
||||||
const { payments: paypalPayments, reload: reloadPaypalPayment } = usePaypalPayments();
|
const { payments: paypalPayments, reload: reloadPaypalPayment } = usePaypalPayments();
|
||||||
@@ -341,17 +356,17 @@ export default function PaymentRecord() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFilters((prev) => [
|
setFilters((prev) => [
|
||||||
...prev.filter((x) => x.id !== "corporate-filter"),
|
...prev.filter((x) => x.id !== "entity-filter"),
|
||||||
...(!corporate
|
...(!entity
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
id: "corporate-filter",
|
id: "entity-filter",
|
||||||
filter: (p: Payment) => p.corporate === corporate.id,
|
filter: (p: Payment) => p.entity === entity.id,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}, [corporate]);
|
}, [entity]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFilters((prev) => [
|
setFilters((prev) => [
|
||||||
@@ -675,7 +690,7 @@ export default function PaymentRecord() {
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
isChecked={value}
|
isChecked={value}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (user?.type === agent || user?.type === "corporate" || value) return null;
|
if (user?.type === "agent" || user?.type === "corporate" || value) return null;
|
||||||
if (!info.row.original.commissionTransfer || !info.row.original.corporateTransfer)
|
if (!info.row.original.commissionTransfer || !info.row.original.corporateTransfer)
|
||||||
return alert("All files need to be uploaded to consider it paid!");
|
return alert("All files need to be uploaded to consider it paid!");
|
||||||
if (!confirm(`Are you sure you want to consider this payment paid?`)) return null;
|
if (!confirm(`Are you sure you want to consider this payment paid?`)) return null;
|
||||||
|
|||||||
@@ -7,29 +7,39 @@ import PaymentDue from "./(status)/PaymentDue";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import { mapBy, redirect, serialize } from "@/utils";
|
import { mapBy, redirect, serialize } from "@/utils";
|
||||||
import { getEntities } from "@/utils/entities.be";
|
import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be";
|
||||||
import { isAdmin } from "@/utils/users";
|
import { isAdmin } from "@/utils/users";
|
||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import { User } from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
|
import client from "@/lib/mongodb";
|
||||||
|
import { Discount, Package } from "@/interfaces/paypal";
|
||||||
|
|
||||||
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res)
|
||||||
if (!user) return redirect("/login")
|
if (!user) return redirect("/login")
|
||||||
|
|
||||||
const entityIDs = mapBy(user.entities, 'id')
|
const entityIDs = mapBy(user.entities, 'id')
|
||||||
const entities = await getEntities(isAdmin(user) ? undefined : entityIDs)
|
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs)
|
||||||
|
|
||||||
|
const domain = user.email.split("@").pop()
|
||||||
|
const discounts = await db.collection<Discount>("discounts").find({ domain }).toArray()
|
||||||
|
const packages = await db.collection<Package>("packages").find().toArray()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({ user, entities }),
|
props: serialize({ user, entities, discounts, packages }),
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User,
|
user: User,
|
||||||
entities: EntityWithRoles[]
|
entities: EntityWithRoles[]
|
||||||
|
discounts: Discount[]
|
||||||
|
packages: Package[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Home({ user, entities }: Props) {
|
export default function Home(props: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -43,7 +53,8 @@ export default function Home({ user, entities }: Props) {
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<PaymentDue entities={entities} user={user} reload={router.reload} />
|
|
||||||
|
<PaymentDue {...props} reload={router.reload} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,9 @@ export type RolePermission =
|
|||||||
"upload_classroom" |
|
"upload_classroom" |
|
||||||
"download_user_list" |
|
"download_user_list" |
|
||||||
"view_student_record" |
|
"view_student_record" |
|
||||||
"download_student_record"
|
"download_student_record" |
|
||||||
|
"pay_entity" |
|
||||||
|
"view_payment_record"
|
||||||
|
|
||||||
export const DEFAULT_PERMISSIONS: RolePermission[] = [
|
export const DEFAULT_PERMISSIONS: RolePermission[] = [
|
||||||
"view_students",
|
"view_students",
|
||||||
@@ -140,5 +142,7 @@ export const ADMIN_PERMISSIONS: RolePermission[] = [
|
|||||||
"upload_classroom",
|
"upload_classroom",
|
||||||
"download_user_list",
|
"download_user_list",
|
||||||
"view_student_record",
|
"view_student_record",
|
||||||
"download_student_record"
|
"download_student_record",
|
||||||
|
"pay_entity",
|
||||||
|
"view_payment_record"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ const defaultModuleSettings = (module: Module, minTimer: number): ModuleState =>
|
|||||||
examLabel: defaultExamLabel(module),
|
examLabel: defaultExamLabel(module),
|
||||||
minTimer,
|
minTimer,
|
||||||
difficulty: sample(["A1", "A2", "B1", "B2", "C1", "C2"] as Difficulty[])!,
|
difficulty: sample(["A1", "A2", "B1", "B2", "C1", "C2"] as Difficulty[])!,
|
||||||
isPrivate: false,
|
isPrivate: true,
|
||||||
sectionLabels: sectionLabels(module),
|
sectionLabels: sectionLabels(module),
|
||||||
expandedSections: [1],
|
expandedSections: [1],
|
||||||
focusedSection: 1,
|
focusedSection: 1,
|
||||||
@@ -166,6 +166,16 @@ const defaultModuleSettings = (module: Module, minTimer: number): ModuleState =>
|
|||||||
importModule: true,
|
importModule: true,
|
||||||
importing: false,
|
importing: false,
|
||||||
edit: [],
|
edit: [],
|
||||||
|
instructionsState: {
|
||||||
|
isInstructionsOpen: false,
|
||||||
|
chosenOption: { value: "Automatic", label: "Automatic" },
|
||||||
|
currentInstructions: "",
|
||||||
|
presetInstructions: "",
|
||||||
|
customInstructions: "",
|
||||||
|
currentInstructionsURL: "",
|
||||||
|
presetInstructionsURL: "",
|
||||||
|
customInstructionsURL: "",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
if (["reading", "writing"].includes(module)) {
|
if (["reading", "writing"].includes(module)) {
|
||||||
state["type"] = "general";
|
state["type"] = "general";
|
||||||
|
|||||||
@@ -108,6 +108,19 @@ export interface SectionState {
|
|||||||
scriptLoading: boolean;
|
scriptLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ListeningInstructionsState {
|
||||||
|
isInstructionsOpen: boolean;
|
||||||
|
chosenOption: Option;
|
||||||
|
|
||||||
|
currentInstructions: string;
|
||||||
|
presetInstructions: string;
|
||||||
|
customInstructions: string;
|
||||||
|
|
||||||
|
currentInstructionsURL: string;
|
||||||
|
presetInstructionsURL: string;
|
||||||
|
customInstructionsURL: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ModuleState {
|
export interface ModuleState {
|
||||||
examLabel: string;
|
examLabel: string;
|
||||||
sections: SectionState[];
|
sections: SectionState[];
|
||||||
@@ -122,6 +135,7 @@ export interface ModuleState {
|
|||||||
edit: number[];
|
edit: number[];
|
||||||
type?: "general" | "academic";
|
type?: "general" | "academic";
|
||||||
academic_url?: string | undefined;
|
academic_url?: string | undefined;
|
||||||
|
instructionsState: ListeningInstructionsState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Avatar {
|
export interface Avatar {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Db, ObjectId } from "mongodb";
|
|||||||
import client from "@/lib/mongodb";
|
import client from "@/lib/mongodb";
|
||||||
import { MODULE_ARRAY } from "./moduleUtils";
|
import { MODULE_ARRAY } from "./moduleUtils";
|
||||||
import { mapBy } from ".";
|
import { mapBy } from ".";
|
||||||
|
import { getUser } from "./users.be";
|
||||||
|
|
||||||
const db = client.db(process.env.MONGODB_DB);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
@@ -76,7 +77,7 @@ export const getExams = async (
|
|||||||
})) as Exam[],
|
})) as Exam[],
|
||||||
).filter((x) => !x.private);
|
).filter((x) => !x.private);
|
||||||
|
|
||||||
let exams: Exam[] = await filterByOwners(shuffledPublicExams, userId);
|
let exams: Exam[] = await filterByEntities(shuffledPublicExams, userId);
|
||||||
exams = filterByVariant(exams, variant);
|
exams = filterByVariant(exams, variant);
|
||||||
exams = filterByInstructorGender(exams, instructorGender);
|
exams = filterByInstructorGender(exams, instructorGender);
|
||||||
exams = await filterByDifficulty(db, exams, module, userId);
|
exams = await filterByDifficulty(db, exams, module, userId);
|
||||||
@@ -109,16 +110,17 @@ const filterByVariant = (exams: Exam[], variant?: Variant) => {
|
|||||||
return filtered.length > 0 ? filtered : exams;
|
return filtered.length > 0 ? filtered : exams;
|
||||||
};
|
};
|
||||||
|
|
||||||
const filterByOwners = async (exams: Exam[], userID?: string) => {
|
const filterByEntities = async (exams: Exam[], userID?: string) => {
|
||||||
if (!userID) return exams.filter((x) => !x.owners || x.owners.length === 0);
|
if (!userID) return exams.filter((x) => !x.entities || x.entities.length === 0);
|
||||||
|
|
||||||
|
const user = await getUser(userID)
|
||||||
|
|
||||||
return await Promise.all(
|
return await Promise.all(
|
||||||
exams.filter(async (x) => {
|
exams.filter(async (x) => {
|
||||||
if (!x.owners) return true;
|
if (!x.entities) return true;
|
||||||
if (x.owners.length === 0) return true;
|
if (x.entities.length === 0) return true;
|
||||||
if (x.owners.includes(userID)) return true;
|
|
||||||
|
|
||||||
const corporate = await getUserCorporate(userID);
|
return mapBy(user?.entities || [], 'id').some(e => x.entities!.includes(e))
|
||||||
return !corporate ? false : x.owners.includes(corporate.id);
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user