diff --git a/src/components/generic/Blocks/Client/AudioBlock.jsx b/src/components/generic/Blocks/Client/AudioBlock.jsx index eb362f3..e364e17 100644 --- a/src/components/generic/Blocks/Client/AudioBlock.jsx +++ b/src/components/generic/Blocks/Client/AudioBlock.jsx @@ -16,6 +16,11 @@ const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2]; const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, ""); +// How often onWatchProgress may fire while playing (ms) — keeps the watch-progress +// endpoint from getting hit on every timeupdate tick. onEnded still always reports +// a final 100% immediately regardless of this window, so completion never lags. +const WATCH_PROGRESS_THROTTLE_MS = 10000; + // ─── AudioBlock (Client — secure) ──────────────────────────────────────────── // // S3/Garage: @@ -125,7 +130,7 @@ export function AudioBlock({ content, onWatchProgress }) { if (onWatchProgress && el?.duration) { const pct = (el.currentTime / el.duration) * 100; const now = Date.now(); - if (now - lastReportRef.current > 3000) { + if (now - lastReportRef.current > WATCH_PROGRESS_THROTTLE_MS) { lastReportRef.current = now; onWatchProgress(pct); } diff --git a/src/components/generic/Blocks/Client/TextVideoBlock.jsx b/src/components/generic/Blocks/Client/TextVideoBlock.jsx index 3873b0b..d6aa9f7 100644 --- a/src/components/generic/Blocks/Client/TextVideoBlock.jsx +++ b/src/components/generic/Blocks/Client/TextVideoBlock.jsx @@ -1,17 +1,17 @@ import { VideoBlock } from "./VideoBlock"; -export function TextVideoBlock({ content }) { +export function TextVideoBlock({ content, onWatchProgress }) { const vidLeft = content.video_position === "left"; return (
- {vidLeft && } + {vidLeft && }
Empty text

", }} /> - {!vidLeft && } + {!vidLeft && }
); } \ No newline at end of file diff --git a/src/components/generic/Blocks/Client/VideoBlock.jsx b/src/components/generic/Blocks/Client/VideoBlock.jsx index 863b4ad..019e242 100644 --- a/src/components/generic/Blocks/Client/VideoBlock.jsx +++ b/src/components/generic/Blocks/Client/VideoBlock.jsx @@ -23,6 +23,11 @@ const PLAYBACK_SPEEDS = ["0.5", "0.75", "Normal", "1.25", "1.5", "2"]; const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, ""); +// How often onWatchProgress may fire while playing (ms) — keeps the watch-progress +// endpoint from getting hit on every timeupdate tick. onEnded still always reports +// a final 100% immediately regardless of this window, so completion never lags. +const WATCH_PROGRESS_THROTTLE_MS = 10000; + // ─── Tooltip control button ─────────────────────────────────────────────────── function CtrlBtn({ label, onClick, children, className = "" }) { @@ -275,7 +280,7 @@ export function VideoBlock({ content, onWatchProgress }) { setProgress(pct); if (onWatchProgress) { const now = Date.now(); - if (now - lastReportRef.current > 3000) { + if (now - lastReportRef.current > WATCH_PROGRESS_THROTTLE_MS) { lastReportRef.current = now; onWatchProgress(pct); } diff --git a/src/contexts/ClientLibraryContext.jsx b/src/contexts/ClientLibraryContext.jsx index f539333..d5b6c67 100644 --- a/src/contexts/ClientLibraryContext.jsx +++ b/src/contexts/ClientLibraryContext.jsx @@ -160,6 +160,40 @@ export function ClientLibraryProvider({ children }) { } }, []); + // Reports watch/listen playback progress for watch_percent / watch_video / listen_audio + // completion requirements — safe to call unconditionally for any standalone lesson (the + // backend no-ops when none of those types is configured on it). Fires frequently while + // playback is running (throttled ~3s by the block itself), so failures are swallowed + // rather than toasted — the next tick, or onEnded's final 100% call, catches up. + const upsertWatchProgress = useCallback(async (lessonUuid, unitUuid, percent, meta = {}) => { + try { + const { data } = await api.post(`/client/lessons/${lessonUuid}/watch-progress`, { + percent, + ...(unitUuid ? { unit_uuid: unitUuid } : {}), + ...(meta.blockId ? { block_id: meta.blockId } : {}), + ...(meta.blockType ? { block_type: meta.blockType } : {}), + }); + const result = data.data ?? null; + const cascadeLesson = result?.cascade?.lesson; + const cascadeUnit = result?.cascade?.unit; + + setUnitDetail((prev) => { + if (!prev || !cascadeLesson) return prev; + const nextLessons = prev.lessons.map((l) => + l.lesson_id === cascadeLesson.lesson_id + ? { ...l, status: cascadeLesson.status } + : l + ); + const is_completed = cascadeUnit ? cascadeUnit.status === "completed" : prev.is_completed; + return { ...prev, lessons: nextLessons, is_completed }; + }); + + return result; + } catch { + return null; + } + }, []); + // ─── Resets ───────────────────────────────────────────────────────────── const resetUnitDetail = useCallback(() => { @@ -187,6 +221,7 @@ export function ClientLibraryProvider({ children }) { submitUnitQuiz, saveUnitQuizDraft, upsertLessonProgress, + upsertWatchProgress, resetUnitDetail, resetLesson, diff --git a/src/modules/admin/components/courses/LessonsPreview.jsx b/src/modules/admin/components/courses/LessonsPreview.jsx index 3a4b53a..ca967c5 100644 --- a/src/modules/admin/components/courses/LessonsPreview.jsx +++ b/src/modules/admin/components/courses/LessonsPreview.jsx @@ -151,7 +151,7 @@ export function PreviewBlock({ block, onWatchProgress }) { case "video": return ; case "text-video": - return ; + return ; case "audio": return ; case "code": diff --git a/src/modules/admin/components/courses/completionRequirementTypes.js b/src/modules/admin/components/courses/completionRequirementTypes.js index 8312fe3..d200a68 100644 --- a/src/modules/admin/components/courses/completionRequirementTypes.js +++ b/src/modules/admin/components/courses/completionRequirementTypes.js @@ -33,15 +33,16 @@ export const TYPE_DEFS = { icon: PlayCircle, entityTypes: ["lesson"], // Either block type satisfies this one — it's one aggregate percent across - // whichever is playing, unlike watch_video/listen_audio below. - requiresBlockTypes: ["video", "audio"], + // whichever is playing, unlike watch_video/listen_audio below. text-video counts + // as video — same
) : (
- +
)} diff --git a/src/modules/client/pages/UnitDetails.jsx b/src/modules/client/pages/UnitDetails.jsx index 82a4897..242ea5c 100644 --- a/src/modules/client/pages/UnitDetails.jsx +++ b/src/modules/client/pages/UnitDetails.jsx @@ -1,7 +1,7 @@ import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import { useParams, useNavigate } from "react-router-dom"; import { - House, Timer, CheckCircle2, Check, ClipboardList, Hourglass, + House, Timer, CheckCircle2, Check, CheckCheck, ClipboardList, Hourglass, } from "lucide-react"; import { cn } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; @@ -14,6 +14,7 @@ import { useLibrary } from "@/contexts/ClientLibraryContext"; import { useClientTiers } from "@/contexts/ClientTiersProvider"; import { PageMeta } from "@/contexts/MetadataContext"; import LockedContentPanel from "@/modules/client/components/LockedContentPanel"; +import { TYPE_DEFS } from "@/modules/admin/components/courses/completionRequirementTypes"; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -157,6 +158,10 @@ const UnitDetails = () => { const currentLesson = lessons.find((l) => l.status !== "completed") ?? null; const progressPct = lessons.length > 0 ? Math.round((completedCount / lessons.length) * 100) : 0; + // Admin-configured completion requirement for the unit itself (null when nothing's set). + const requirementDef = unitDetail?.completion?.type ? TYPE_DEFS[unitDetail.completion.type] : null; + const RequirementIcon = requirementDef?.icon; + const handleLessonClick = (lesson) => { navigate(`/lessons/${lesson.uuid}`); }; @@ -187,14 +192,28 @@ const UnitDetails = () => { ) : ( <>
-
+
{lessons.length} {lessons.length === 1 ? "lesson" : "lessons"} · {formatDuration(unitDetail.duration_seconds) ?? "—"} total · {completedCount} of {lessons.length} complete + {requirementDef && ( + <> + · + + {RequirementIcon && } {requirementDef.label} + + + )}
+ {unitDetail.is_completed && ( +
+ + Success — you've completed this unit. +
+ )}
diff --git a/src/modules/client/pages/UnitReader.jsx b/src/modules/client/pages/UnitReader.jsx index b0eaa11..7a87f7a 100644 --- a/src/modules/client/pages/UnitReader.jsx +++ b/src/modules/client/pages/UnitReader.jsx @@ -81,7 +81,7 @@ const UnitReader = () => { unitDetail, unitDetailLoading, unitBlocked, getUnitDetail, resetUnitDetail, lesson, lessonLoading, getLesson, resetLesson, quiz, quizLoading, getUnitQuiz, resetQuiz, submitUnitQuiz, saveUnitQuizDraft, - upsertLessonProgress, + upsertLessonProgress, upsertWatchProgress, } = useLibrary(); // Tracks which lessons have been marked completed this session to avoid duplicate calls @@ -273,6 +273,13 @@ const UnitReader = () => { saveUnitQuizDraft(uuid, selectedQuizId, answers); }, [uuid, selectedQuizId, saveUnitQuizDraft]); + // watch_percent / watch_video / listen_audio — safe to always pass through, the + // backend no-ops whichever type (if any) isn't configured on this lesson. + const handleWatchProgress = useCallback((percent, meta) => { + if (!lesson?.uuid) return; + upsertWatchProgress(lesson.uuid, uuid, percent, meta); + }, [lesson, uuid, upsertWatchProgress]); + // ── Next content item ────────────────────────────────────────────────── const getNextContent = useCallback(() => { const idx = allContent.findIndex((item) => @@ -482,7 +489,7 @@ const UnitReader = () => { nextLabel={nextLabel} /> ) : ( - + )}