diff --git a/src/components/content/courses/DocumentLesson.js b/src/components/content/courses/DocumentLesson.js index 08e786e..5aa0379 100644 --- a/src/components/content/courses/DocumentLesson.js +++ b/src/components/content/courses/DocumentLesson.js @@ -11,6 +11,7 @@ import { getTotalFromZaps } from "@/utils/lightning"; import dynamic from "next/dynamic"; import useWindowWidth from "@/hooks/useWindowWidth"; import appConfig from "@/config/appConfig"; +import useTrackDocumentLesson from "@/hooks/tracking/useTrackDocumentLesson"; const MDDisplay = dynamic( () => import("@uiw/react-markdown-preview"), @@ -22,10 +23,19 @@ const MDDisplay = dynamic( const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid }) => { const [zapAmount, setZapAmount] = useState(0); const [nAddress, setNAddress] = useState(null); + const [completed, setCompleted] = useState(false); const { zaps, zapsLoading, zapsError } = useZapsQuery({ event: lesson, type: "lesson" }); const { returnImageProxy } = useImageProxy(); const windowWidth = useWindowWidth(); const isMobileView = windowWidth <= 768; + // todo implement real read time needs to be on form + const readTime = 30; + + const { isCompleted, isTracking } = useTrackDocumentLesson({ + lessonId: lesson?.d, + courseId: course?.d, + readTime: readTime, + }); useEffect(() => { if (!zaps || zapsLoading || zapsError) return; @@ -45,6 +55,12 @@ const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid }) => { } }, [lesson]); + useEffect(() => { + if (isCompleted) { + setCompleted(lesson.id); + } + }, [isCompleted, lesson.id, setCompleted]); + const renderContent = () => { if (isPaid && decryptionPerformed) { return ; @@ -90,14 +106,14 @@ const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid }) => { )} -

{lesson.summary && ( +

{lesson.summary && (
{lesson.summary.split('\n').map((line, index) => (

{line}

))}
)} -

+
['t', topic]), ['published_at', Math.floor(Date.now() / 1000).toString()], ...(draft?.price ? [['price', draft.price.toString()], ['location', `https://plebdevs.com/details/${draft.id}`]] : []), ...(draft?.additionalLinks ? draft.additionalLinks.filter(link => link !== 'https://plebdevs.com').map(link => ['r', link]) : []), + draft?.price ? null : ['i', 'youtube:plebdevs', 'V_fvmyJ91m0'] ]; type = 'video'; diff --git a/src/components/content/courses/VideoLesson.js b/src/components/content/courses/VideoLesson.js index b526049..d776044 100644 --- a/src/components/content/courses/VideoLesson.js +++ b/src/components/content/courses/VideoLesson.js @@ -38,17 +38,53 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted videoPlayed }); + useEffect(() => { + const handleYouTubeMessage = (event) => { + if (event.origin !== "https://www.youtube.com") return; + + try { + const data = JSON.parse(event.data); + console.log('youtube data', data); + if (data.event === "onReady") { + event.source.postMessage('{"event":"listening"}', "https://www.youtube.com"); + } else if (data.event === "infoDelivery" && data?.info && data?.info?.currentTime) { + setVideoPlayed(true); + setVideoDuration(data.info?.progressState?.duration); + event.source.postMessage('{"event":"command","func":"getDuration","args":""}', "https://www.youtube.com"); + } + } catch (error) { + console.error("Error parsing YouTube message:", error); + } + }; + + window.addEventListener("message", handleYouTubeMessage); + + return () => { + window.removeEventListener("message", handleYouTubeMessage); + }; + }, []); + + useEffect(() => { + if (videoDuration && videoPlayed) { + console.log('videoDuration and videoPlayed', videoDuration, videoPlayed); + } + }, [videoDuration, videoPlayed]); + + useEffect(() => { + if (videoPlayed) { + console.log('videoPlayed', videoPlayed); + } + }, [videoPlayed]); + const checkDuration = useCallback(() => { const videoElement = mdDisplayRef.current?.querySelector('video'); + const youtubeIframe = mdDisplayRef.current?.querySelector('iframe[src*="youtube.com"]'); + if (videoElement && videoElement.readyState >= 1) { setVideoDuration(Math.round(videoElement.duration)); - - // Add event listener for play event - videoElement.addEventListener('play', () => { - setVideoPlayed(true); - }); - } else if (videoElement) { - setTimeout(checkDuration, 100); + setVideoPlayed(true); + } else if (youtubeIframe) { + youtubeIframe.contentWindow.postMessage('{"event":"listening"}', '*'); } }, []); @@ -78,6 +114,10 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted if (decryptionPerformed && isPaid) { const timer = setTimeout(checkDuration, 500); return () => clearTimeout(timer); + } else { + // For non-paid content, start checking after 3 seconds + const timer = setTimeout(checkDuration, 3000); + return () => clearTimeout(timer); } }, [decryptionPerformed, isPaid, checkDuration]); @@ -109,7 +149,11 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted
); } else if (lesson?.content) { - return ; + return ( +
+ +
+ ); } return null; } @@ -129,14 +173,14 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted )}
-

{lesson.summary && ( +

{lesson.summary && (
{lesson.summary.split('\n').map((line, index) => (

{line}

))}
)} -

+
{ const [title, setTitle] = useState(draft?.title || ''); const [summary, setSummary] = useState(draft?.summary || ''); @@ -53,7 +54,7 @@ const VideoForm = ({ draft = null }) => { // Check if it's a YouTube video if (videoUrl.includes('youtube.com') || videoUrl.includes('youtu.be')) { const videoId = videoUrl.split('v=')[1] || videoUrl.split('/').pop(); - embedCode = `
`; + embedCode = `
`; } // Check if it's a Vimeo video else if (videoUrl.includes('vimeo.com')) { diff --git a/src/components/forms/course/embedded/EmbeddedVideoForm.js b/src/components/forms/course/embedded/EmbeddedVideoForm.js index 0e0ed5f..b50f16a 100644 --- a/src/components/forms/course/embedded/EmbeddedVideoForm.js +++ b/src/components/forms/course/embedded/EmbeddedVideoForm.js @@ -50,7 +50,7 @@ const EmbeddedVideoForm = ({ draft = null, onSave, isPaid }) => { // Check if it's a YouTube video if (videoUrl.includes('youtube.com') || videoUrl.includes('youtu.be')) { const videoId = videoUrl.split('v=')[1] || videoUrl.split('/').pop(); - embedCode = `
`; + embedCode = `
`; } // Check if it's a Vimeo video else if (videoUrl.includes('vimeo.com')) { diff --git a/src/hooks/tracking/useTrackDocumentLesson.js b/src/hooks/tracking/useTrackDocumentLesson.js new file mode 100644 index 0000000..1e47dd3 --- /dev/null +++ b/src/hooks/tracking/useTrackDocumentLesson.js @@ -0,0 +1,105 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useSession } from 'next-auth/react'; +import axios from 'axios'; + +const useTrackDocumentLesson = ({ lessonId, courseId, readTime }) => { + const [isCompleted, setIsCompleted] = useState(false); + const [timeSpent, setTimeSpent] = useState(0); + const [isTracking, setIsTracking] = useState(false); + const [isAdmin, setIsAdmin] = useState(false); + const timerRef = useRef(null); + const { data: session } = useSession(); + const completedRef = useRef(false); + + useEffect(() => { + if (session?.user?.role?.admin) { + setIsAdmin(true); + setIsCompleted(true); // Automatically mark as completed for admins + } + }, [session]); + + const checkOrCreateUserLesson = useCallback(async () => { + if (!session?.user) return false; + try { + const response = await axios.get(`/api/users/${session.user.id}/lessons/${lessonId}?courseId=${courseId}`); + if (response.status === 200 && response?.data) { + if (response?.data?.completed) { + setIsCompleted(true); + completedRef.current = true; + return true; + } else { + return false; + } + } else if (response.status === 204) { + await axios.post(`/api/users/${session.user.id}/lessons?courseId=${courseId}`, { + resourceId: lessonId, + opened: true, + openedAt: new Date().toISOString(), + }); + return false; + } else { + console.error('Error checking or creating UserLesson:', response.statusText); + return false; + } + } catch (error) { + console.error('Error checking or creating UserLesson:', error); + return false; + } + }, [session, lessonId, courseId]); + + const markLessonAsCompleted = useCallback(async () => { + if (!session?.user || completedRef.current) return; + completedRef.current = true; + + try { + const response = await axios.put(`/api/users/${session.user.id}/lessons/${lessonId}?courseId=${courseId}`, { + completed: true, + completedAt: new Date().toISOString(), + }); + + if (response.status === 200) { + setIsCompleted(true); + setIsTracking(false); + } else { + console.error('Failed to mark lesson as completed:', response.statusText); + } + } catch (error) { + console.error('Error marking lesson as completed:', error); + } + }, [lessonId, courseId, session]); + + useEffect(() => { + const initializeTracking = async () => { + if (isAdmin) return; // Skip tracking for admin users + + const alreadyCompleted = await checkOrCreateUserLesson(); + if (!alreadyCompleted && !completedRef.current) { + setIsTracking(true); + timerRef.current = setInterval(() => { + setTimeSpent(prevTime => prevTime + 1); + }, 1000); + } + }; + + initializeTracking(); + + return () => { + if (timerRef.current) { + clearInterval(timerRef.current); + } + }; + }, [lessonId, checkOrCreateUserLesson, isAdmin]); + + useEffect(() => { + if (isAdmin) return; // Skip tracking for admin users + + // Mark lesson as completed after readTime seconds + if (timeSpent >= readTime && !completedRef.current) { + markLessonAsCompleted(); + } + }, [timeSpent, markLessonAsCompleted, readTime, isAdmin]); + + return { isCompleted, isTracking }; +}; + +export default useTrackDocumentLesson; \ No newline at end of file diff --git a/src/hooks/tracking/useTrackVideoLesson.js b/src/hooks/tracking/useTrackVideoLesson.js index 143f78a..bbfc8fe 100644 --- a/src/hooks/tracking/useTrackVideoLesson.js +++ b/src/hooks/tracking/useTrackVideoLesson.js @@ -6,10 +6,18 @@ const useTrackVideoLesson = ({lessonId, videoDuration, courseId, videoPlayed}) = const [isCompleted, setIsCompleted] = useState(false); const [timeSpent, setTimeSpent] = useState(0); const [isTracking, setIsTracking] = useState(false); + const [isAdmin, setIsAdmin] = useState(false); const timerRef = useRef(null); const { data: session } = useSession(); const completedRef = useRef(false); + useEffect(() => { + if (session?.user?.role?.admin) { + setIsAdmin(true); + setIsCompleted(true); // Automatically mark as completed for admins + } + }, [session]); + // Check if the lesson is already completed or create a new UserLesson record const checkOrCreateUserLesson = useCallback(async () => { if (!session?.user) return false; @@ -68,6 +76,9 @@ const useTrackVideoLesson = ({lessonId, videoDuration, courseId, videoPlayed}) = useEffect(() => { const initializeTracking = async () => { + console.log('initializeTracking', videoDuration, !completedRef.current, videoPlayed); + if (isAdmin) return; // Skip tracking for admin users + const alreadyCompleted = await checkOrCreateUserLesson(); // Case 3: Start tracking if the lesson is not completed, video duration is available, and video has been played if (!alreadyCompleted && videoDuration && !completedRef.current && videoPlayed) { @@ -87,14 +98,16 @@ const useTrackVideoLesson = ({lessonId, videoDuration, courseId, videoPlayed}) = clearInterval(timerRef.current); } }; - }, [lessonId, videoDuration, checkOrCreateUserLesson, videoPlayed]); + }, [lessonId, videoDuration, checkOrCreateUserLesson, videoPlayed, isAdmin]); useEffect(() => { + if (isAdmin) return; // Skip tracking for admin users + // Case 4: Mark lesson as completed when 90% of the video is watched if (videoDuration && timeSpent >= Math.round(videoDuration * 0.9) && !completedRef.current) { markLessonAsCompleted(); } - }, [timeSpent, videoDuration, markLessonAsCompleted]); + }, [timeSpent, videoDuration, markLessonAsCompleted, isAdmin]); return { isCompleted, isTracking }; }; diff --git a/src/pages/api/auth/[...nextauth].js b/src/pages/api/auth/[...nextauth].js index 5ed1eef..c3a93f2 100644 --- a/src/pages/api/auth/[...nextauth].js +++ b/src/pages/api/auth/[...nextauth].js @@ -106,6 +106,7 @@ export const authOptions = { } } + // todo this does not work on first login only the second time if (user && appConfig.authorPubkeys.includes(user?.pubkey) && !user?.role) { // create a new author role for this user const role = await createRole({