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}
/>
) : (
-
+
)}