diff --git a/src/components/Categories/CategoriesSection/index.tsx b/src/components/Categories/CategoriesSection/index.tsx index e98b80128..5854653f8 100644 --- a/src/components/Categories/CategoriesSection/index.tsx +++ b/src/components/Categories/CategoriesSection/index.tsx @@ -18,7 +18,7 @@ const StyledSection = styled.section` padding: 30px 0; } h2 { - margin-bottom: 27px; + margin: 27px 0; } .slider-title { @media (max-width: 575px) { diff --git a/src/components/Consultations/ConsultationCard/EndMeetingQuestionnaires/index.tsx b/src/components/Consultations/ConsultationCard/EndMeetingQuestionnaires/index.tsx index 4db130815..5dae5b6bf 100644 --- a/src/components/Consultations/ConsultationCard/EndMeetingQuestionnaires/index.tsx +++ b/src/components/Consultations/ConsultationCard/EndMeetingQuestionnaires/index.tsx @@ -25,7 +25,6 @@ export const EndMeetingQuestionnairesModal = ({ const { questionnaires: questionnairesList, loading, - // error, getQuestionnaires, } = useQuestionnaires({ entityId: entityId || 0, @@ -132,7 +131,7 @@ export const EndMeetingQuestionnairesModal = ({ }, [state, moveToNextQuestionnaire, setIsEnded]); useEffect(() => { - getQuestionnaires(); + getQuestionnaires(handleClose); // eslint-disable-next-line react-hooks/exhaustive-deps }, [entityId]); diff --git a/src/components/Consultations/ConsultationCard/JitsyMeeting/index.tsx b/src/components/Consultations/ConsultationCard/JitsyMeeting/index.tsx index cc707ad4f..5918a2774 100644 --- a/src/components/Consultations/ConsultationCard/JitsyMeeting/index.tsx +++ b/src/components/Consultations/ConsultationCard/JitsyMeeting/index.tsx @@ -1,19 +1,23 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useContext, useEffect, useRef, useState } from "react"; import { JaaSMeeting } from "@jitsi/react-sdk"; import { IJitsiMeetExternalApi } from "@jitsi/react-sdk/lib/types"; -import { Text } from "@escolalms/components/lib/components/atoms/Typography/Text"; - -import * as API from "@escolalms/sdk/lib/types"; -import useCamera, { cameraPermissions } from "@/hooks/meeting/useCamera"; -import { getCurrentUser } from "@/utils/meeting"; import JitsyMeetingMessage from "@/components/Consultations/ConsultationCard/JitsyMeeting/Message"; import { useRoles } from "@/hooks/useRoles"; import { useTranslation } from "react-i18next"; import { Modal } from "@escolalms/components/lib/components/atoms/Modal/Modal"; import styled from "styled-components"; +import JitsyMeetingSkeleton from "@/components/Skeletons/JitsyMeeting"; +import { EscolaLMSContext } from "@escolalms/sdk/lib/react/context"; import { API_URL } from "@/config/index"; +import useCamera, { cameraPermissions } from "@/hooks/meeting/useCamera"; +import { API } from "@escolalms/sdk/lib"; +import { IMeetRecording } from "@/components/Consultations/ConsultationCard/JitsyMeeting/types"; +import { + JITSY_ANALYTICS_INTERVAL, + JITSY_TUTOR_INTERVAL, +} from "@/utils/constants"; +import { Text } from "@escolalms/components/lib/components/atoms/Typography/Text"; import { Button } from "@escolalms/components/lib/components/atoms/Button/Button"; -import JitsyMeetingSkeleton from "@/components/Skeletons/JitsyMeeting"; export const StyledModal = styled(Modal)` .rc-dialog-content { @@ -39,271 +43,334 @@ const StyledAccessWrapper = styled.div` } `; -const FRAME_RATE = 1; -const SEND_INTERVAL = 1000; - -declare global { - interface Window { - api: IJitsiMeetExternalApi; - } -} - -type Props = { +type JitsyMeetingProps = { jitsyData: Omit; term: string; - consultationTermId: number; + consultationTermId?: number; consultationId?: number; close?: () => void; onRecordingAvailable?: (url: string) => void; + modelId: number; + modelType: "consultation" | "webinar"; }; -const JitsyMeeting: React.FC = ({ +const JitsyMeeting: React.FC = ({ jitsyData, + modelId, + modelType, term, consultationTermId, - consultationId, close, onRecordingAvailable, }) => { const [showMeeting, setShowMeeting] = useState(false); const [showModal, setShowModal] = useState(false); - const { camera, getDataUrl, hasCameraAccess, cameraAccessStatus } = - useCamera(); + const { token } = useContext(EscolaLMSContext); + const { isStudent } = useRoles(); + const { t } = useTranslation(); + + const { camera, getDataUrl, cameraAccessStatus } = useCamera(); const userConsentedRef = useRef(false); - const isMeetingActive = useRef(false); - const intervalIdRef = useRef(null); - const workerRef = useRef(null); const isCameraMutedRef = useRef(false); - const { isStudent } = useRoles(); - const { t } = useTranslation(); + const recordingIdRef = useRef(null); + const recordingUrlRef = useRef(null); + const recordingExpiryRef = useRef(null); - const handleConferenceJoined = useCallback(() => { - isMeetingActive.current = true; - }, []); + const analyticsIntervalRef = useRef(null); + const recommenderIntervalRef = useRef(null); + const workerRef = useRef(null); + const apiRef = useRef(null); + const recordingTimeoutRef = useRef(null); - const handleConferenceLeft = useCallback(() => { - isMeetingActive.current = false; - }, []); + const preparePayload = useCallback( + (action: "start-recording" | "end-recording") => { + const now = new Date().toISOString(); + const payload: IMeetRecording = { + id: recordingIdRef.current || 0, + model_type: modelType, + model_id: Number(modelId), + action, + term: term || now, + }; - interface RecordingLinkAvailableEvent { - link: string; - } + if (action === "start-recording") { + payload.start_at = now; + } else { + payload.end_at = now; + if (recordingUrlRef.current) { + payload.url = recordingUrlRef.current; + } - const handleRecordingLinkAvailable = useCallback( - (event: RecordingLinkAvailableEvent) => { - if (event.link && onRecordingAvailable) { - onRecordingAvailable(event.link); + if (recordingExpiryRef.current) { + payload.url_expiration_time_millis = recordingExpiryRef.current; + } } + + return payload; }, - [onRecordingAvailable] + [modelId, modelType, term] ); - const saveImagesInWorker = useCallback( - ( - consultationId: number, - consultationTermId: number, - userEmail: string, - userId: number, - screenshots: { dataURL: Blob; timestamp: number }[], - term: string - ) => { - if (!workerRef.current) { - workerRef.current = new Worker( - new URL("../../../../workers/saveImageWorker.ts", import.meta.url), - { type: "module" } + const sendRecordingEvent = useCallback( + async (action: "start-recording" | "end-recording") => { + if (isStudent) return; + try { + const payload = preparePayload(action); + + const response = await fetch( + `${API_URL}/api/recommender/meet-recordings`, + { + method: "POST", + keepalive: true, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(payload), + } ); - workerRef.current.postMessage({ - apiUrl: API_URL, - }); - } - workerRef.current.onmessage = (event: MessageEvent) => { - const { success, error } = event.data; - if (success) { - console.log("Images saved successfully via Worker."); - } else { - console.error("Error saving images in Worker:", error); - } - }; + const result = await response.json(); - workerRef.current.postMessage({ - consultationId, - consultationTermId, - userEmail, - userId, - screenshots, - term, - }); + if (action === "start-recording" && result?.data?.id) { + recordingIdRef.current = result.data.id; + } + } catch (e) { + console.error("Recording API error", e); + } }, - [] + [isStudent, token, preparePayload] ); - const handleRecordingStatusChanged = useCallback( - async ( - api: IJitsiMeetExternalApi, - getDataUrl: () => Promise, - status: { - on: boolean; - mode: string; - error?: string; - transcription: boolean; - } - ) => { - if (status.on) { - let screenshots: { - dataURL: Blob; - timestamp: number; - userID: number; - consultationId: number | undefined; - }[] = []; - - if (!intervalIdRef.current) { - intervalIdRef.current = setInterval(async () => { - if (isCameraMutedRef.current) { - return; - } + const stopAllIntervals = useCallback(() => { + if (analyticsIntervalRef.current) { + clearInterval(analyticsIntervalRef.current); + analyticsIntervalRef.current = null; + } + if (recommenderIntervalRef.current) { + clearInterval(recommenderIntervalRef.current); + recommenderIntervalRef.current = null; + } - const dataUrl = await getDataUrl(); - if (dataUrl) { - screenshots.push({ - dataURL: dataUrl, - timestamp: new Date().getTime(), - userID: jitsyData.data.userInfo.id, - consultationId, - }); - - if (screenshots.length === FRAME_RATE * (SEND_INTERVAL / 1000)) { - const currentUser = await getCurrentUser(api); - if (currentUser) { - saveImagesInWorker( - consultationId ?? 0, - consultationTermId, - jitsyData.data.userInfo.email, - jitsyData.data.userInfo.id, - screenshots, - term - ); - screenshots = []; - } - } - } - }, 1000 / FRAME_RATE); + if (recordingTimeoutRef.current) { + clearTimeout(recordingTimeoutRef.current); + recordingTimeoutRef.current = null; + } + }, []); + + const startScreenshotFlows = useCallback(() => { + if (!workerRef.current) { + workerRef.current = new Worker( + new URL("../../../../workers/saveImageWorker.ts", import.meta.url), + { type: "module" } + ); + workerRef.current.postMessage({ apiUrl: API_URL }); + } + + const userId = jitsyData.data.userInfo.id; + const userEmail = jitsyData.data.userInfo.email; + + if (isStudent && !analyticsIntervalRef.current) { + analyticsIntervalRef.current = setInterval(async () => { + if (isCameraMutedRef.current || !userConsentedRef.current) return; + + const blob = await getDataUrl(); + if (blob) { + const screenshotPayload = { + dataURL: blob, + timestamp: Date.now(), + userID: userId, + }; + + workerRef.current?.postMessage({ + modelId: Number(modelId), + modelType, + userId, + userEmail, + consultationTermId, + screenshots: [screenshotPayload], + term, + token, + }); } - } else { - if (intervalIdRef.current) { - clearInterval(intervalIdRef.current); - intervalIdRef.current = null; + }, JITSY_ANALYTICS_INTERVAL); + } + + if (!isStudent && !recommenderIntervalRef.current) { + recommenderIntervalRef.current = setInterval(async () => { + if (isCameraMutedRef.current) return; + + const blob = await getDataUrl(); + if (blob) { + workerRef.current?.postMessage({ + action: "recommender-screens", + modelId: Number(modelId), + modelType, + userId, + term: term || new Date().toISOString(), + screenshots: [ + { + dataURL: blob, + timestamp: Date.now(), + userID: userId, + }, + ], + token, + }); } - } - }, - [ - consultationId, - consultationTermId, - jitsyData.data.userInfo.email, - jitsyData.data.userInfo.id, - term, - saveImagesInWorker, - ] - ); + }, JITSY_TUTOR_INTERVAL); + } + }, [ + isStudent, + modelId, + modelType, + term, + token, + jitsyData, + getDataUrl, + consultationTermId, + ]); const onApiReady = useCallback( async (api: IJitsiMeetExternalApi) => { - window.api = api; + apiRef.current = api; await camera(); - api.isVideoMuted().then((muted) => { - isCameraMutedRef.current = muted; - }); - - api.addListener("videoConferenceJoined", () => handleConferenceJoined()); - api.addListener("videoConferenceLeft", () => handleConferenceLeft()); - api.addListener("recordingLinkAvailable", (event) => - handleRecordingLinkAvailable(event) + api.on( + "recordingLinkAvailable", + (event: { link: string; ttl: number }) => { + recordingUrlRef.current = event.link; + if (onRecordingAvailable) { + onRecordingAvailable(event.link); + } + if (event.ttl) { + recordingExpiryRef.current = event.ttl * 1000; + } + } ); - api.on("recordingStatusChanged", (status) => { - if (userConsentedRef.current) - handleRecordingStatusChanged( - api, - async () => (await getDataUrl()) as Blob, - status - ); + + api.on("recordingStatusChanged", async (status: { on: boolean }) => { + if (status.on) { + recordingUrlRef.current = null; + recordingExpiryRef.current = null; + startScreenshotFlows(); + await sendRecordingEvent("start-recording"); + } else { + stopAllIntervals(); + if (recordingTimeoutRef.current) + clearTimeout(recordingTimeoutRef.current); + + recordingTimeoutRef.current = setTimeout(async () => { + if (recordingIdRef.current) { + await sendRecordingEvent("end-recording"); + recordingIdRef.current = null; + recordingUrlRef.current = null; + } + recordingTimeoutRef.current = null; + }, 500); + } }); - api.addListener("videoMuteStatusChanged", (event: { muted: boolean }) => { - isCameraMutedRef.current = event.muted; + api.on("videoMuteStatusChanged", (e: { muted: boolean }) => { + isCameraMutedRef.current = e.muted; + }); + + api.on("videoConferenceLeft", () => { + stopAllIntervals(); + if (!isStudent && recordingIdRef.current) { + sendRecordingEvent("end-recording"); + } }); }, [ camera, - handleConferenceJoined, - handleConferenceLeft, - getDataUrl, - handleRecordingStatusChanged, - userConsentedRef, - handleRecordingLinkAvailable, + isStudent, + onRecordingAvailable, + sendRecordingEvent, + startScreenshotFlows, + stopAllIntervals, ] ); - const handleReadyToClose = () => { - if (close) { - close(); - } - window.api?.dispose(); - window.location.reload(); - }; - - const getProperRoomName = useCallback(() => { - const regex = /\/([^/?]+)\?/; - const match = jitsyData.url.match(regex); - return match ? match[1] : jitsyData.data.roomName; - }, [jitsyData]); - useEffect(() => { return () => { + stopAllIntervals(); + + if (!isStudent && recordingIdRef.current) { + sendRecordingEvent("end-recording"); + } + if (workerRef.current) { workerRef.current.terminate(); + workerRef.current = null; } - if (intervalIdRef.current) { - clearInterval(intervalIdRef.current); + + if (apiRef.current) { + apiRef.current.dispose(); + apiRef.current = null; } }; - }, []); + }, [stopAllIntervals, isStudent, sendRecordingEvent]); useEffect(() => { - const checkPermissions = async () => { + const handleUnload = () => { + if (!isStudent && recordingIdRef.current && token) { + const payload = preparePayload("end-recording"); + + fetch(`${API_URL}/api/recommender/meet-recordings`, { + method: "POST", + keepalive: true, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(payload), + }); + } + }; + + window.addEventListener("beforeunload", handleUnload); + return () => window.removeEventListener("beforeunload", handleUnload); + }, [isStudent, preparePayload, token]); + + useEffect(() => { + const init = async () => { try { await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); - if (hasCameraAccess && isMeetingActive && isStudent) { - setShowModal(true); - } else { - setShowModal(false); - if (!isStudent) { - setShowMeeting(true); - } - } - } catch (error) { - console.error("Error checking permissions:", error); - setShowModal(false); + isStudent ? setShowModal(true) : setShowMeeting(true); + } catch { + setShowMeeting(true); } }; + init(); + }, [isStudent]); - checkPermissions(); - }, [hasCameraAccess, isMeetingActive, isStudent, t]); + const getProperRoomName = () => { + const regex = /\/([^/?]+)\?/; + const match = jitsyData.url.match(regex); + return match ? match[1] : jitsyData.data.roomName; + }; return ( <> - {jitsyData && !showModal && showMeeting && ( + {showMeeting && ( { iframeRef.style.height = "calc(100vh - 76px)"; iframeRef.style.width = "100%"; }} - onApiReady={onApiReady} - onReadyToClose={handleReadyToClose} + onReadyToClose={async () => { + stopAllIntervals(); + if (!isStudent && recordingIdRef.current) { + await sendRecordingEvent("end-recording"); + } + close?.(); + }} interfaceConfigOverwrite={{ ...jitsyData.data.interfaceConfigOverwrite, }} @@ -324,14 +391,14 @@ const JitsyMeeting: React.FC = ({ /> )} [setShowModal(false), setShowMeeting(true)]} visible={showModal} - animation="zoom" - maskAnimation="fade" - destroyOnClose={true} - width={468} + onClose={() => [setShowModal(false), setShowMeeting(true)]} closable={false} maskClosable={false} + destroyOnClose={true} + width={468} + animation="zoom" + maskAnimation="fade" > = ({ userConsentedRef={userConsentedRef} /> - {!showModal && !showMeeting && } + {!showMeeting && !showModal && } {!showModal && !showMeeting && cameraAccessStatus === cameraPermissions.DENIED && ( diff --git a/src/components/Consultations/ConsultationCard/JitsyMeeting/types.ts b/src/components/Consultations/ConsultationCard/JitsyMeeting/types.ts new file mode 100644 index 000000000..f3e7cdafe --- /dev/null +++ b/src/components/Consultations/ConsultationCard/JitsyMeeting/types.ts @@ -0,0 +1,17 @@ +export interface IMeetRecording { + id: number; + model_type: string; + action: "start-recording" | "end-recording"; + model_id: number; + term: string | number; + start_at?: string; + end_at?: string; + url?: string; + url_expiration_time_millis?: number; +} + +export interface IScreenshot { + dataURL: Blob; + timestamp: number; + userID: number; +} diff --git a/src/components/Consultations/ConsultationCard/MeetModal/index.tsx b/src/components/Consultations/ConsultationCard/MeetModal/index.tsx index 90a0df50a..212887e14 100644 --- a/src/components/Consultations/ConsultationCard/MeetModal/index.tsx +++ b/src/components/Consultations/ConsultationCard/MeetModal/index.tsx @@ -47,6 +47,10 @@ const ConsultationMeetModal = ({ onClose }: Props) => { }; getMeetUrl(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [consultationModalContext?.consultationData]); + + useEffect(() => { return () => { Object.keys(localStorage).forEach((key) => { if (key.startsWith("questionnaire_")) { @@ -54,8 +58,7 @@ const ConsultationMeetModal = ({ onClose }: Props) => { } }); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [consultationModalContext?.consultationData]); + }, []); useEffect(() => { setIsEnded(false); @@ -65,7 +68,6 @@ const ConsultationMeetModal = ({ onClose }: Props) => { setIsEnded(true); consultationModalContext?.setModalOpen?.(false); onClose(); - window.location.reload(); }, [setIsEnded, onClose, consultationModalContext]); return ( @@ -91,16 +93,19 @@ const ConsultationMeetModal = ({ onClose }: Props) => { /> {!loading && meetData && ( )} diff --git a/src/components/Courses/RateCourse/index.tsx b/src/components/Courses/RateCourse/index.tsx index 1acd86b5c..da712b9c7 100644 --- a/src/components/Courses/RateCourse/index.tsx +++ b/src/components/Courses/RateCourse/index.tsx @@ -55,7 +55,7 @@ const RateCourse: React.FC = ({ if (request.success) { toast(`${t("RateCourse.AnswerSended")}`, "success"); } - // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { toast("Error", error.message); console.error(error); diff --git a/src/components/MeetingAnalyticsOverlay/MeetingAnalyticsOverlay.tsx b/src/components/MeetingAnalyticsOverlay/MeetingAnalyticsOverlay.tsx index f8c691f41..cb0bf068c 100644 --- a/src/components/MeetingAnalyticsOverlay/MeetingAnalyticsOverlay.tsx +++ b/src/components/MeetingAnalyticsOverlay/MeetingAnalyticsOverlay.tsx @@ -21,6 +21,7 @@ import { ConsultationModalContext } from "@/components/Consultations/Consultatio import { useRoles } from "@/hooks/useRoles"; import { DataPoint, EMOTION_POOL, EmotionHistory } from "@/types/sockets"; import { useTranslation } from "react-i18next"; +import { API } from "@escolalms/sdk/lib"; const getColorByValue = (val: number) => { if (val < 35) return "#FF4D4D"; @@ -30,10 +31,14 @@ const getColorByValue = (val: number) => { export default function MeetingAnalyticsOverlay({ onClose, + modelType = "consultation", recordingUrl, + webinar, }: { onClose: () => void; + modelType?: "consultation" | "webinar"; recordingUrl?: string | null; + webinar?: API.Webinar; }) { const { isTutor } = useRoles(); const { consultation, token } = useContext(EscolaLMSContext); @@ -42,22 +47,27 @@ export default function MeetingAnalyticsOverlay({ const [hoveredPanel, setHoveredPanel] = useState< "emotion" | "attention" | null >(null); + const modelId = useMemo(() => { + if (modelType === "webinar") return webinar?.id; + return consultationModalContext?.consultationData?.consultationId; + }, [modelType, webinar, consultationModalContext]); - const scrollRef = useRef(null); + const unixTimestamp = useMemo(() => { + const rawDate = + modelType === "webinar" + ? webinar?.active_to + : consultationModalContext?.consultationData?.term; + + return rawDate ? Math.floor(new Date(rawDate).getTime() / 1000) : undefined; + }, [modelType, webinar, consultationModalContext]); - const unixTimestamp = consultationModalContext?.consultationData?.term - ? Math.floor( - new Date(consultationModalContext?.consultationData?.term).getTime() / - 1000 - ) - : undefined; + const scrollRef = useRef(null); const { socketData } = useMeetingSockets( - isTutor - ? consultationModalContext?.consultationData?.consultationId - : undefined, + isTutor ? modelId : undefined, isTutor ? unixTimestamp : undefined, - isTutor ? token : undefined + isTutor ? token : undefined, + modelType ); const [attentionData, setAttentionData] = useState([]); @@ -78,6 +88,17 @@ export default function MeetingAnalyticsOverlay({ [t] ); + const displayTitle = useMemo(() => { + if (modelType === "webinar") { + return webinar?.name || ""; + } + return ( + consultation?.value?.name ?? + consultationModalContext?.consultationData?.name ?? + "" + ); + }, [modelType, webinar, consultation, consultationModalContext]); + useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollTo({ @@ -189,11 +210,7 @@ export default function MeetingAnalyticsOverlay({ - - {consultation?.value?.name ?? - consultationModalContext?.consultationData?.name ?? - ""} - + {displayTitle} {isTutor && ( @@ -522,39 +539,59 @@ const StatCard = styled.div<{ active: boolean; glowColor?: string }>` padding: 10px 20px; cursor: pointer; position: relative; - border-radius: 8px; - transition: all 0.2s ease; - background: ${({ active, glowColor }) => - active - ? `linear-gradient(#1A1A1A, #1A1A1A) padding-box, linear-gradient(to bottom, ${glowColor} 0%, transparent 60%) border-box` - : `linear-gradient(#0D0D0D, #0D0D0D) padding-box, linear-gradient(to bottom, #333, #333) border-box`}; - border: 1px solid transparent; + border-radius: 12px; + transition: all 0.3s ease; + background: ${({ active }) => (active ? "#1A1A1A" : "#0D0D0D")}; + border: 1px solid ${({ active }) => (active ? "transparent" : "#222")}; + overflow: hidden; &::before { content: ""; position: absolute; top: -1px; - left: 10%; - right: 10%; - height: 1px; + left: -1px; + right: -1px; + bottom: -1px; + border-radius: 12px; + padding: 1.5px; background: ${({ active, glowColor }) => - active ? glowColor : "transparent"}; - box-shadow: ${({ active, glowColor }) => - active ? `0 0 10px ${glowColor}` : "none"}; - opacity: ${({ active }) => (active ? 0.8 : 0)}; - transition: opacity 0.2s ease; + active + ? `linear-gradient(to bottom, ${glowColor}, transparent 65%)` + : "transparent"}; + + mask: linear-gradient(#fff, #fff) content-box, linear-gradient(#fff, #fff); + mask-composite: exclude; + -webkit-mask: linear-gradient(#fff, #fff) content-box, + linear-gradient(#fff, #fff); + -webkit-mask-composite: destination-out; + opacity: ${({ active }) => (active ? 1 : 0)}; + transition: opacity 0.3s ease; + z-index: 1; + } + + &::after { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 100%; + background: ${({ active, glowColor }) => + active + ? `radial-gradient(circle at 50% 0%, ${glowColor}25 0%, transparent 60%)` + : "transparent"}; + opacity: ${({ active }) => (active ? 1 : 0)}; pointer-events: none; + transition: opacity 0.3s ease; } &:hover { - background: #141414; + background: ${({ active }) => (active ? "#1A1A1A" : "#141414")}; } - @media (max-width: 768px) { - padding: 8px 12px; - gap: 8px; - flex: 1; - justify-content: center; + & > * { + position: relative; + z-index: 2; } `; diff --git a/src/components/Profile/ProfileHeader/index.tsx b/src/components/Profile/ProfileHeader/index.tsx index 972f54195..bf1138668 100644 --- a/src/components/Profile/ProfileHeader/index.tsx +++ b/src/components/Profile/ProfileHeader/index.tsx @@ -29,7 +29,9 @@ const StyledHeader = styled.div<{ withTabs?: boolean }>` const ProfileHeader: React.FC = ({ title, withTabs, actions }) => { return ( - {title} + + {title} + {actions &&
{actions}
}
); diff --git a/src/components/Profile/ProfileLayout/index.tsx b/src/components/Profile/ProfileLayout/index.tsx index 7aafa4be5..24f3ff912 100644 --- a/src/components/Profile/ProfileLayout/index.tsx +++ b/src/components/Profile/ProfileLayout/index.tsx @@ -87,6 +87,11 @@ const ProfileLayout: React.FC = ({ title: t("MyProfilePage.MyConsultations"), url: routeRoutes.myConsultations, }, + { + key: "WEBINARS", + title: t("MyProfilePage.MyWebinars"), + url: routeRoutes.myWebinars, + }, ], [t] ); diff --git a/src/components/Profile/ProfileWebinars/index.tsx b/src/components/Profile/ProfileWebinars/index.tsx index 8450f9008..59c984968 100644 --- a/src/components/Profile/ProfileWebinars/index.tsx +++ b/src/components/Profile/ProfileWebinars/index.tsx @@ -1,4 +1,4 @@ -import { memo, useState } from "react"; +import { memo, useContext, useState, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Col, Row } from "react-grid-system"; import ContentLoader from "@/components/_App/ContentLoader"; @@ -10,6 +10,7 @@ import { API } from "@escolalms/sdk/lib"; import { ProfileWebinarItemFooter } from "./ItemFooter"; import { ProfileWebinarItemActions } from "./ItemActions"; import WebinarMeetModal from "@/components/Webinars/Webinar/WebinarMeetModal"; +import { WebinarsContext } from "@/components/Webinars/List/WebinarsContext"; const RowStyled = styled(Row)` gap: 30px 0; @@ -29,8 +30,17 @@ ProfileWebinarsProps) => { const [webinarJoinId, setWebinarJoinId] = useState( undefined ); + const [webinarData, setWebinarData] = useState( + undefined + ); + + const webinarsContext = useContext(WebinarsContext); const { t } = useTranslation(); + const handleOnCloseModal = useCallback(() => { + setWebinarJoinId(undefined); + }, []); + if (loading) { return ; } @@ -54,19 +64,23 @@ ProfileWebinarsProps) => { actions={ setWebinarJoinId(webinar.id)} + onJoin={() => { + setWebinarData(webinar); + setWebinarJoinId(webinar.id); + webinarsContext?.setModalOpen?.(true); + }} /> } footer={} /> ))} - {/* MEET MODAL */} {!!webinarJoinId && ( setWebinarJoinId(undefined)} + onClose={handleOnCloseModal} webinarId={webinarJoinId} + webinar={webinarData} /> )} diff --git a/src/components/Routes/index.tsx b/src/components/Routes/index.tsx index 1cdb67be8..7983d358a 100644 --- a/src/components/Routes/index.tsx +++ b/src/components/Routes/index.tsx @@ -35,8 +35,8 @@ const ConsultationsPage = lazy(() => import("../../pages/consultations")); const ResetPage = lazy(() => import("../../pages/reset-password/index")); // const EventsPage = lazy(() => import("../../pages/events")); // const EventPage = lazy(() => import("../../pages/event")); -// const WebinarsPage = lazy(() => import("../../pages/webinars")); -// const WebinarPage = lazy(() => import("../../pages/webinar")); +const WebinarsPage = lazy(() => import("../../pages/webinars")); +const WebinarPage = lazy(() => import("../../pages/webinar")); // const PackagesPage = lazy(() => import("../../pages/packages")); const PackagePage = lazy(() => import("../../pages/package")); const SubscriptionsPage = lazy(() => import("../../pages/subscriptions")); @@ -62,7 +62,7 @@ const MyConsultationsPage = lazy( const MyDataPage = lazy(() => import("../../pages/user/my-data")); const CourseProgramPage = lazy(() => import("../../pages/course/index")); const CartPage = lazy(() => import("../../pages/cart/index")); -// const MyWebinarsPage = lazy(() => import("../../pages/user/MyWebinars")); +const MyWebinarsPage = lazy(() => import("../../pages/user/MyWebinars")); const MyCertificatesPage = lazy( () => import("../../pages/user/my-certificates") ); @@ -116,9 +116,9 @@ const Routes: React.FC = (): ReactElement => { // myStationaryEvents, // myTasks, // myBookmarks, - // webinars, - // webinar, - // myWebinars, + webinars, + webinar, + myWebinars, // packages, packageProduct, myCertificates, @@ -154,9 +154,9 @@ const Routes: React.FC = (): ReactElement => { {/* - + */} - */} + {/* */} {/* privates pages*/} @@ -164,7 +164,7 @@ const Routes: React.FC = (): ReactElement => { {/* */} - {/* */} + void; onlyFree?: boolean; + webinarData?: API.Webinar | null; + setWebinarData?: (webinar: API.Webinar | null) => void; + isModalOpen?: boolean; + setModalOpen?: (open: boolean) => void; }> = React.createContext({}); diff --git a/src/components/Webinars/List/WebinarsProvider.tsx b/src/components/Webinars/List/WebinarsProvider.tsx index a32542263..bb2dacea6 100644 --- a/src/components/Webinars/List/WebinarsProvider.tsx +++ b/src/components/Webinars/List/WebinarsProvider.tsx @@ -20,17 +20,16 @@ const WebinarsProvider: React.FC<{ const { fetchWebinars, webinars, fetchTags } = useContext(EscolaLMSContext); const location = useLocation(); const { push } = useHistory(); - const [params, setParams] = useState(); + const [webinarData, setWebinarData] = useState(null); + const [isModalOpen, setModalOpen] = useState(false); const getApiParams = (params: WebinarsParams = {}): WebinarsParams => { - const apiParams = { + return { page: 1, per_page: 8, - // order_by: 'created_at', ...params, }; - return apiParams; }; useEffect(() => { @@ -53,11 +52,20 @@ const WebinarsProvider: React.FC<{ } else { fetchWebinars(getApiParams(params)); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [location.search]); + }, [fetchWebinars, location.search, params]); return ( - + {children} ); diff --git a/src/components/Webinars/Webinar/WebinarMeetModal/index.tsx b/src/components/Webinars/Webinar/WebinarMeetModal/index.tsx index 858dc7107..1f29c93f1 100644 --- a/src/components/Webinars/Webinar/WebinarMeetModal/index.tsx +++ b/src/components/Webinars/Webinar/WebinarMeetModal/index.tsx @@ -1,4 +1,4 @@ -import { useContext, useEffect, useState } from "react"; +import { useCallback, useContext, useEffect, useRef, useState } from "react"; import { EscolaLMSContext } from "@escolalms/sdk/lib/react"; import { Modal } from "@escolalms/components/lib/components/atoms/Modal/Modal"; import { JitsyData } from "@escolalms/sdk/lib/types"; @@ -6,64 +6,138 @@ import ContentLoader from "@/components/_App/ContentLoader"; import { WebinarMeetModalStyles } from "./WebinarMeetModalStyles"; import { useTranslation } from "react-i18next"; import { toast } from "@/utils/toast"; +import styled from "styled-components"; +import JitsyMeeting from "@/components/Consultations/ConsultationCard/JitsyMeeting"; +import MeetingAnalyticsOverlay from "@/components/MeetingAnalyticsOverlay/MeetingAnalyticsOverlay"; +import { EndMeetingQuestionnairesModal } from "@/components/Consultations/ConsultationCard/EndMeetingQuestionnaires"; +import { QuestionnaireModelType } from "@/types/questionnaire"; +import { API } from "@escolalms/sdk/lib"; +import { useRoles } from "@/hooks/useRoles"; interface Props { onClose: () => void; visible: boolean; webinarId: number; + webinar?: API.Webinar; } -const WebinarMeetModal = ({ onClose, visible, webinarId }: Props) => { +const JitsiContainer = styled.div` + position: relative; + width: 100%; + height: 100%; + overflow: hidden; +`; + +const WebinarMeetModal = ({ onClose, visible, webinarId, webinar }: Props) => { const [webinarMeetData, setWebinarMeetData] = useState( null ); const [loading, setLoading] = useState(false); + const [isEnded, setIsEnded] = useState(false); + const [recordingUrl, setRecordingUrl] = useState(null); + const onCloseRef = useRef(onClose); const { generateWebinarJitsy } = useContext(EscolaLMSContext); const { t } = useTranslation(); + const { isTutor } = useRoles(); + + useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); useEffect(() => { const getMeetUrl = async () => { + if (!webinarId || !visible || webinarMeetData) return; + setLoading(true); - if (webinarId) { + try { const res = await generateWebinarJitsy(webinarId); if (res.success) { setWebinarMeetData((res as { data: JitsyData }).data); + } else { + toast(t("WebinarPage.ErrorWhileGeneratingUrl"), "error"); + onCloseRef.current(); } - if (!res.success) { - toast(`${t("WebinarPage.ErrorWhileGeneratingUrl")}`, "error"); - onClose(); - } + } catch (error) { + console.error("Error generating Jitsi URL:", error); + toast(t("WebinarPage.ErrorWhileGeneratingUrl"), "error"); + } finally { + setLoading(false); } - setLoading(false); }; - getMeetUrl(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [webinarId]); + }, [webinarId, visible, generateWebinarJitsy, t, webinarMeetData]); + + useEffect(() => { + return () => { + Object.keys(localStorage).forEach((key) => { + if (key.startsWith("questionnaire_")) { + localStorage.removeItem(key); + } + }); + }; + }, []); + + useEffect(() => { + if (visible) { + setIsEnded(false); + } + }, [visible]); + + const handleOnClose = useCallback(() => { + setIsEnded(true); + isTutor && onClose(); + }, [isTutor, onClose]); return ( - - - {loading && } - {!loading && webinarMeetData && ( -