) : (
)}
{p.title ?? "—"}
@@ -825,9 +827,10 @@ const CourseDetails = () => {
"shrink-0 border-0 gap-1",
p.completed
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400"
- : !guarded && "text-muted-foreground"
+ : guarded
+ ? "bg-white/15 text-white"
+ : "text-muted-foreground"
)}
- style={guarded ? { backgroundColor: `${textColor}1a`, color: textColor } : undefined}
>
{p.completed
? <> Completed>
diff --git a/src/modules/client/pages/LessonDetails.jsx b/src/modules/client/pages/LessonDetails.jsx
index b315e4c..d454203 100644
--- a/src/modules/client/pages/LessonDetails.jsx
+++ b/src/modules/client/pages/LessonDetails.jsx
@@ -5,12 +5,13 @@ import {
} from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { useLibrary } from "@/contexts/ClientLibraryContext";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { PageMeta } from "@/contexts/MetadataContext";
import LockedContentPanel from "@/modules/client/components/LockedContentPanel";
import LessonBlock from "../components/LessonBlock.jsx";
+import MarkCompleteButton from "@/modules/client/components/MarkCompleteButton.jsx";
import { TYPE_DEFS } from "@/modules/admin/components/courses/completionRequirementTypes";
import api from "@/utils/api.util";
@@ -46,9 +47,10 @@ const LessonDetails = () => {
const {
getLesson, lesson, lessonLoading, unitBlocked, unitBlockedInfo, resetLesson,
- upsertWatchProgress,
+ upsertLessonProgress, upsertWatchProgress, markComplete,
} = useLibrary();
const { tierMap, getTierCategories } = useClientTiers();
+ const completedSessionRef = useRef(new Set());
const hasCompleted = lesson?.status === "completed";
const unit = lesson?.unit ?? null;
@@ -66,6 +68,10 @@ const LessonDetails = () => {
// Admin-configured completion requirement (null when nothing's set — default behavior).
const requirementDef = lesson?.completion?.type ? TYPE_DEFS[lesson.completion.type] : null;
const RequirementIcon = requirementDef?.icon;
+ // read_all_content (or unconfigured/default) → scroll-to-bottom tracking, below.
+ // watch_percent / manual_complete → their own dedicated triggers — same dispatch
+ // pattern as UnitList.jsx / UnitReader.jsx, brought here for standalone lessons.
+ const lessonCompletionType = lesson?.completion?.type ?? 'read_all_content';
useEffect(() => {
getTierCategories();
@@ -74,6 +80,42 @@ const LessonDetails = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uuid]);
+ // ── Scroll progress (read_all_content) — mirrors UnitList.jsx/UnitReader.jsx's
+ // scrollY-based indicator, only meaningful for content-only standalone lessons.
+ const [scrollProgress, setScrollProgress] = useState(0);
+
+ useEffect(() => {
+ setScrollProgress(0);
+ window.scrollTo(0, 0);
+ }, [uuid]);
+
+ useEffect(() => {
+ const handleScroll = () => {
+ const scrollTop = window.scrollY;
+ const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
+ if (scrollHeight <= 0) { setScrollProgress(100); return; }
+ setScrollProgress(Math.round((scrollTop / scrollHeight) * 100));
+ };
+ window.addEventListener("scroll", handleScroll, { passive: true });
+ return () => window.removeEventListener("scroll", handleScroll);
+ }, []);
+
+ // ── Mark lesson completed when user scrolls to the bottom ─────────────
+ // Only fires in Task mode — outside of it, this standalone lesson page is a
+ // passive preview/reader with no automatic progress persistence (matches the
+ // "Task mode — progress is being tracked automatically" banner's promise: no
+ // banner, no tracking).
+ useEffect(() => {
+ if (!taskCtx?.has_task) return;
+ if (lessonCompletionType !== 'read_all_content') return;
+ if (scrollProgress < 100 || !lesson?.uuid || hasCourse) return;
+ if (completedSessionRef.current.has(lesson.uuid)) return;
+ if (hasCompleted) return;
+ completedSessionRef.current.add(lesson.uuid);
+ upsertLessonProgress(lesson.uuid, 'completed', unit?.uuid ?? null);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [scrollProgress]);
+
// 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) => {
@@ -81,6 +123,12 @@ const LessonDetails = () => {
upsertWatchProgress(lesson.uuid, unit?.uuid ?? null, percent, meta);
}, [lesson, unit, upsertWatchProgress]);
+ // ── manual_complete trigger: learner clicks the Mark Complete button ────
+ const handleMarkComplete = useCallback(async () => {
+ if (!lesson?.uuid) return;
+ await markComplete(lesson.uuid, unit?.uuid ?? null);
+ }, [lesson, unit, markComplete]);
+
const items = [
{ label: "Home", icon: , to: `/dashboard` },
{ label: "Lessons", to: `/lessons` },
@@ -112,6 +160,23 @@ const LessonDetails = () => {
return (
+
+ {/* Scroll progress indicator — Task mode only (matches the "progress is being
+ tracked automatically" banner's promise), read_all_content lessons only,
+ fixed edge-to-edge under the navbar, same style as the Course > Unit >
+ Lesson reader's top bar. */}
+ {taskCtx?.has_task && !hasCourse && !contentNotReady && lessonCompletionType === 'read_all_content' && (
+