diff --git a/app/dashboard/courses/[courseId]/CourseDetailPageClient.jsx b/app/dashboard/courses/[courseId]/CourseDetailPageClient.jsx index 6068a0de..eaa48e0f 100644 --- a/app/dashboard/courses/[courseId]/CourseDetailPageClient.jsx +++ b/app/dashboard/courses/[courseId]/CourseDetailPageClient.jsx @@ -2,7 +2,7 @@ import VidPlayerBox from "@/components/atoms/dashboard/vid-player-box"; import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; import Link from "next/link"; -import React, { useState, useMemo } from "react"; +import React, { useState, useMemo, useCallback } from "react"; import Button from "@/components/atoms/form/Button"; import StarRate from "@/components/atoms/form/StarRate"; import ReviewsSection from "@/components/organisms/dashboard/ReviewsSection"; @@ -10,8 +10,10 @@ import { useAuth } from "@/hooks/useAuth"; import { Textarea } from "@/components/ui/textarea"; import { addCourseReview } from "@/lib/actions/courses/addReview"; import { useHasCourse, usePurchaseCourse } from "@/hooks/usePurchase"; +import { useCourseProgress, formatTime } from "@/hooks/useCourseProgress"; +import { Progress } from "@/components/ui/progress"; import { toast } from "sonner"; -import { Wallet } from "lucide-react"; +import { Wallet, RotateCcw, Play } from "lucide-react"; import PaymentModal from "@/components/stellar/PaymentModal"; import { useStellar } from "@/components/stellar/StellarProvider"; @@ -26,12 +28,45 @@ export default function CourseDetailClient({ course }) { const [loading, setLoading] = useState(false); const [showPaymentModal, setShowPaymentModal] = useState(false); - // Check if user already owns the course const hasCourse = useHasCourse(course?._id); - // Local state to hide button after purchase const [purchased, setPurchased] = useState(false); - // Check if the current user has already reviewed + const { + progress, + reportProgress, + resetProgress, + resumeTime, + resumeLabel, + } = useCourseProgress(course?._id); + + const [useResume, setUseResume] = useState(false); + const [playerKey, setPlayerKey] = useState(0); + + const effectiveStartTime = useResume && resumeTime ? resumeTime : 0; + + const handleTimeUpdate = useCallback( + (currentTime, duration) => { + reportProgress(currentTime, duration); + }, + [reportProgress] + ); + + const handleEnded = useCallback(() => { + if (course?._id) { + reportProgress(progress.durationSeconds || 0, progress.durationSeconds || 0); + } + }, [reportProgress, progress.durationSeconds, course?._id]); + + const handleResume = () => { + setUseResume(true); + }; + + const handleStartOver = () => { + resetProgress(); + setUseResume(false); + setPlayerKey((k) => k + 1); + }; + const userReview = useMemo(() => { if (!user?._id || !course?.reviews) return null; return course.reviews.find( @@ -39,10 +74,8 @@ export default function CourseDetailClient({ course }) { ); }, [user, course?.reviews]); - // Check if creator has wallet connected const creatorHasWallet = course?.createdBy?.stellarWallet?.publicKey; - // Handle opening the payment modal const handlePurchaseCourse = () => { if (!user?._id) { toast.error("Please sign in to purchase this course."); @@ -51,7 +84,6 @@ export default function CourseDetailClient({ course }) { setShowPaymentModal(true); }; - // Handle successful payment const handlePaymentSuccess = async (result) => { setPurchased(true); if (user?._id) { @@ -86,7 +118,6 @@ export default function CourseDetailClient({ course }) { return null; } - // Check if user can access the course const canAccess = hasCourse || purchased || user?._id === course.createdBy?._id; @@ -97,12 +128,77 @@ export default function CourseDetailClient({ course }) { {canAccess ? ( -
- +
+ {progress.percent > 0 && !progress.completed && resumeLabel && !useResume && ( +
+
+ +
+

{resumeLabel}

+ +
+
+
+ + +
+
+ )} + + {progress.completed && ( +
+ + ✅ Course Completed + + +
+ )} + +
+ +
+ + {progress.percent > 0 && ( +
+
+ {progress.percent}% complete + {formatTime(progress.positionSeconds)} / {formatTime(progress.durationSeconds)} +
+ +
+ )}
) : (
- {/* Blurred thumbnail preview */}

🔒 Course Locked

@@ -214,10 +310,8 @@ export default function CourseDetailClient({ course }) {
)}
- {/* Right Column: Pricing & Actions */}
- {/* Reviews Section */}

Reviews

- {/* Stellar Payment Modal */} setShowPaymentModal(false)} diff --git a/app/dashboard/courses/page.jsx b/app/dashboard/courses/page.jsx index c27c2f68..b7ed433b 100644 --- a/app/dashboard/courses/page.jsx +++ b/app/dashboard/courses/page.jsx @@ -8,10 +8,12 @@ import { Card, CardContent } from "@/components/ui/card"; import { fetchCourses } from "@/lib/actions/courses/fetch-courses"; import { getBookmarkedCourses } from "@/lib/actions/courses/bookmark-course"; import useAuth from "@/hooks/useAuth"; +import { useAllCourseProgress } from "@/hooks/useCourseProgress"; import NetworkErrorComp from "@/components/molecules/errors/NetworkError"; export default function CoursesPage() { const { user } = useAuth(); + const { progressMap } = useAllCourseProgress(); const [courses, setCourses] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(false); @@ -125,6 +127,7 @@ export default function CoursesPage() { { if (showBookmarks && !isBookmarked) { setCourses(courses.filter((c) => c._id !== course._id)); diff --git a/components/atoms/dashboard/vid-player-box.jsx b/components/atoms/dashboard/vid-player-box.jsx index 7c6f4f77..19c3048b 100644 --- a/components/atoms/dashboard/vid-player-box.jsx +++ b/components/atoms/dashboard/vid-player-box.jsx @@ -8,13 +8,43 @@ import { MediaProvider, Poster, Track, + useMediaPlayer, } from '@vidstack/react'; import { DefaultVideoLayout, defaultLayoutIcons, } from '@vidstack/react/player/layouts/default'; +import { useEffect, useRef } from 'react'; -const VidPlayerBox = ({ data }) => { +function PlayerProgressTracker({ onTimeUpdate, onEnded }) { + const player = useMediaPlayer(); + const lastReportRef = useRef(0); + + useEffect(() => { + if (!player) return; + + const timeSub = player.on('time-update', (e) => { + if (onTimeUpdate) { + onTimeUpdate(e.currentTime, e.duration); + } + }); + + const endedSub = player.on('ended', () => { + if (onEnded) { + onEnded(); + } + }); + + return () => { + timeSub(); + endedSub(); + }; + }, [player, onTimeUpdate, onEnded]); + + return null; +} + +const VidPlayerBox = ({ data, startTime, onTimeUpdate, onEnded }) => { const textTracks = data?.subtitles?.length ? data.subtitles : []; @@ -29,6 +59,7 @@ const VidPlayerBox = ({ data }) => { playsInline title={data?.title} poster={data?.thumbnail} + clipStartTime={startTime || undefined} > @@ -41,6 +72,11 @@ const VidPlayerBox = ({ data }) => { thumbnails={data?.thumbnails || undefined} icons={defaultLayoutIcons} /> + +
); diff --git a/components/molecules/dashboard/cards/courseCard.jsx b/components/molecules/dashboard/cards/courseCard.jsx index 8886f9e1..c4cb1a67 100644 --- a/components/molecules/dashboard/cards/courseCard.jsx +++ b/components/molecules/dashboard/cards/courseCard.jsx @@ -1,15 +1,16 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; import Button from "@/components/atoms/form/Button"; import Link from "next/link"; -import { Ellipsis } from "lucide-react"; +import { Ellipsis, CheckCircle } from "lucide-react"; import useAuth from "@/hooks/useAuth"; import Image from "next/image"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { useBookmark } from "@/hooks/useBookmark"; import BookmarkButton from "@/components/atoms/BookmarkButton"; -const CourseCard = ({ course, onBookmarkChange, initialIsBookmarked }) => { +const CourseCard = ({ course, onBookmarkChange, initialIsBookmarked, progress }) => { const { user } = useAuth(); const { isBookmarked, loading, toggle } = useBookmark( course._id, @@ -17,6 +18,17 @@ const CourseCard = ({ course, onBookmarkChange, initialIsBookmarked }) => { initialIsBookmarked ); + const hasPurchased = user?.purchasedCourses?.some( + (c) => + c.courseId?.toString?.() === course._id?.toString?.() || + c._id?.toString?.() === course._id?.toString?.() + ) || user?.enrolledCourses?.some( + (c) => c?.toString?.() === course._id?.toString?.() + ); + + const showProgress = hasPurchased && progress && progress.percent > 0; + const isCompleted = progress?.completed || false; + const handleBookmark = async (e) => { e.preventDefault(); e.stopPropagation(); @@ -46,6 +58,22 @@ const CourseCard = ({ course, onBookmarkChange, initialIsBookmarked }) => { ) : null} + + {/* Progress badge on owned courses */} + {showProgress && ( +
+ {isCompleted ? ( +
+ + Completed +
+ ) : ( +
+ {progress.percent}% watched +
+ )} +
+ )} {/* Header */} @@ -60,6 +88,11 @@ const CourseCard = ({ course, onBookmarkChange, initialIsBookmarked }) => { {/* Instructor */} + {showProgress && ( +
+ +
+ )}
{
-
- {course.price ? `$${course.price}` : "Free"} -
+ {!hasPurchased && ( +
+ {course.price ? `$${course.price}` : "Free"} +
+ )} { className="w-full bg-accent text-white hover:bg-accent/90 text-sm font-semibold" to={`/dashboard/courses/${course._id}`} > - View Course + {isCompleted ? "Review Course" : showProgress ? "Continue Learning" : "View Course"}
diff --git a/hooks/useCourseProgress.js b/hooks/useCourseProgress.js new file mode 100644 index 00000000..cee01bce --- /dev/null +++ b/hooks/useCourseProgress.js @@ -0,0 +1,353 @@ +"use client"; +import { useState, useEffect, useRef, useCallback } from "react"; +import useAuth from "./useAuth"; +import axiosInstance from "@/lib/config/axios.config"; + +const STORAGE_PREFIX = "dnb:progress:"; +const THROTTLE_MS = 15000; +const COMPLETION_THRESHOLD = 0.9; + +let backendAvailable = null; + +function getStorageKey(userId, courseId) { + return `${STORAGE_PREFIX}${userId}:${courseId}`; +} + +function readLocalProgress(userId, courseId) { + if (typeof window === "undefined") return null; + try { + const raw = localStorage.getItem(getStorageKey(userId, courseId)); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + +function writeLocalProgress(userId, courseId, data) { + if (typeof window === "undefined") return; + try { + localStorage.setItem( + getStorageKey(userId, courseId), + JSON.stringify(data) + ); + } catch {} +} + +function removeLocalProgress(userId, courseId) { + if (typeof window === "undefined") return; + try { + localStorage.removeItem(getStorageKey(userId, courseId)); + } catch {} +} + +function calcPercent(positionSeconds, durationSeconds) { + if (!durationSeconds || durationSeconds <= 0) return 0; + return Math.min(100, Math.round((positionSeconds / durationSeconds) * 100)); +} + +function isCompleted(positionSeconds, durationSeconds) { + if (!durationSeconds || durationSeconds <= 0) return false; + return positionSeconds / durationSeconds >= COMPLETION_THRESHOLD; +} + +function formatTime(seconds) { + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${s.toString().padStart(2, "0")}`; +} + +export function useCourseProgress(courseId) { + const { user } = useAuth(); + const [progress, setProgress] = useState({ + percent: 0, + positionSeconds: 0, + durationSeconds: 0, + completed: false, + }); + const [loading, setLoading] = useState(true); + + const lastWriteRef = useRef(0); + const currentDataRef = useRef(null); + const completedRef = useRef(false); + const apiCheckedRef = useRef(false); + + const userId = user?._id; + + useEffect(() => { + if (!userId || !courseId) { + setLoading(false); + return; + } + + const local = readLocalProgress(userId, courseId); + if (local) { + const pct = calcPercent(local.positionSeconds, local.durationSeconds); + const done = local.completed || isCompleted(local.positionSeconds, local.durationSeconds); + const restored = { + percent: done ? 100 : pct, + positionSeconds: local.positionSeconds, + durationSeconds: local.durationSeconds, + completed: done, + }; + setProgress(restored); + currentDataRef.current = local; + completedRef.current = done; + } + + async function fetchFromApi() { + if (backendAvailable === false) { + setLoading(false); + return; + } + try { + const res = await axiosInstance.get("/api/progress/courses"); + if (res.data?.success && Array.isArray(res.data.progress)) { + backendAvailable = true; + const entry = res.data.progress.find( + (p) => p.courseId?.toString?.() === courseId.toString() + ); + if (entry) { + const pct = calcPercent(entry.positionSeconds, entry.durationSeconds); + const done = entry.completed || isCompleted(entry.positionSeconds, entry.durationSeconds); + const fromApi = { + percent: done ? 100 : pct, + positionSeconds: entry.positionSeconds, + durationSeconds: entry.durationSeconds, + completed: done, + }; + setProgress(fromApi); + currentDataRef.current = { + positionSeconds: entry.positionSeconds, + durationSeconds: entry.durationSeconds, + completed: done, + }; + completedRef.current = done; + writeLocalProgress(userId, courseId, currentDataRef.current); + } + } + } catch (err) { + if (err?.response?.status === 404 || err?.response?.status === 405) { + backendAvailable = false; + } + } finally { + setLoading(false); + apiCheckedRef.current = true; + } + } + + fetchFromApi(); + }, [userId, courseId]); + + const flushToBackend = useCallback( + async (data) => { + if (backendAvailable === false) return; + try { + await axiosInstance.put(`/api/progress/course/${courseId}`, { + positionSeconds: data.positionSeconds, + durationSeconds: data.durationSeconds, + completed: data.completed, + lessonId: null, + }); + backendAvailable = true; + } catch (err) { + if (err?.response?.status === 404 || err?.response?.status === 405) { + backendAvailable = false; + } + } + }, + [courseId] + ); + + const reportProgress = useCallback( + (positionSeconds, durationSeconds) => { + if (!userId || !courseId) return; + if (typeof positionSeconds !== "number" || typeof durationSeconds !== "number") return; + + const done = completedRef.current || isCompleted(positionSeconds, durationSeconds); + if (done) completedRef.current = true; + + const pct = done ? 100 : calcPercent(positionSeconds, durationSeconds); + + const data = { + positionSeconds, + durationSeconds, + completed: done, + }; + + currentDataRef.current = data; + + setProgress({ + percent: pct, + positionSeconds, + durationSeconds, + completed: done, + }); + + writeLocalProgress(userId, courseId, data); + + const now = Date.now(); + if (now - lastWriteRef.current >= THROTTLE_MS) { + lastWriteRef.current = now; + flushToBackend(data); + } + }, + [userId, courseId, flushToBackend] + ); + + const flushNow = useCallback(() => { + if (currentDataRef.current && userId && courseId) { + lastWriteRef.current = Date.now(); + flushToBackend(currentDataRef.current); + } + }, [userId, courseId, flushToBackend]); + + const resetProgress = useCallback(() => { + if (!userId || !courseId) return; + completedRef.current = false; + currentDataRef.current = null; + lastWriteRef.current = 0; + const empty = { + percent: 0, + positionSeconds: 0, + durationSeconds: 0, + completed: false, + }; + setProgress(empty); + removeLocalProgress(userId, courseId); + flushToBackend({ + positionSeconds: 0, + durationSeconds: 0, + completed: false, + }).catch(() => {}); + }, [userId, courseId, flushToBackend]); + + useEffect(() => { + const handleVisibility = () => { + if (document.visibilityState === "hidden") { + flushNow(); + } + }; + window.addEventListener("visibilitychange", handleVisibility); + return () => { + window.removeEventListener("visibilitychange", handleVisibility); + flushNow(); + }; + }, [flushNow]); + + return { + progress, + loading, + reportProgress, + resetProgress, + resumeTime: + !progress.completed && progress.positionSeconds > 30 && progress.percent < 90 + ? progress.positionSeconds + : null, + resumeLabel: + !progress.completed && progress.positionSeconds > 30 && progress.percent < 90 + ? `Resume from ${formatTime(progress.positionSeconds)}` + : null, + }; +} + +export function useAllCourseProgress() { + const { user } = useAuth(); + const [progressMap, setProgressMap] = useState({}); + const [loading, setLoading] = useState(true); + const fetchedRef = useRef(false); + + const userId = user?._id; + + useEffect(() => { + if (!userId || fetchedRef.current) { + if (!userId) setLoading(false); + return; + } + + let cancelled = false; + + async function fetchAll() { + if (backendAvailable === false) { + loadAllFromLocal(); + return; + } + + try { + const res = await axiosInstance.get("/api/progress/courses"); + if (cancelled) return; + if (res.data?.success && Array.isArray(res.data.progress)) { + backendAvailable = true; + const map = {}; + for (const entry of res.data.progress) { + const cid = entry.courseId?.toString?.(); + if (!cid) continue; + const pct = calcPercent(entry.positionSeconds, entry.durationSeconds); + const done = entry.completed || isCompleted(entry.positionSeconds, entry.durationSeconds); + map[cid] = { + percent: done ? 100 : pct, + completed: done, + positionSeconds: entry.positionSeconds, + durationSeconds: entry.durationSeconds, + }; + writeLocalProgress(userId, cid, { + positionSeconds: entry.positionSeconds, + durationSeconds: entry.durationSeconds, + completed: done, + }); + } + setProgressMap(map); + } else { + loadAllFromLocal(); + } + } catch (err) { + if (err?.response?.status === 404 || err?.response?.status === 405) { + backendAvailable = false; + } + loadAllFromLocal(); + } finally { + fetchedRef.current = true; + if (!cancelled) setLoading(false); + } + } + + function loadAllFromLocal() { + const map = {}; + if (typeof window !== "undefined") { + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(STORAGE_PREFIX)) { + const suffix = key.slice(STORAGE_PREFIX.length); + const parts = suffix.split(":"); + if (parts.length === 2 && parts[0] === userId) { + const cid = parts[1]; + try { + const data = JSON.parse(localStorage.getItem(key)); + if (data) { + const pct = calcPercent(data.positionSeconds, data.durationSeconds); + const done = data.completed || isCompleted(data.positionSeconds, data.durationSeconds); + map[cid] = { + percent: done ? 100 : pct, + completed: done, + positionSeconds: data.positionSeconds, + durationSeconds: data.durationSeconds, + }; + } + } catch {} + } + } + } + } + setProgressMap(map); + } + + fetchAll(); + + return () => { + cancelled = true; + }; + }, [userId]); + + return { progressMap, loading }; +} + +export { formatTime, calcPercent };