This commit is contained in:
rgrgogu
2026-07-18 14:30:22 +08:00
parent 22c0731cc1
commit 6d9e8e1db9
8 changed files with 419 additions and 29 deletions
+29
View File
@@ -194,6 +194,34 @@ export function ClientLibraryProvider({ children }) {
}
}, []);
// Self-report completion for the manual_complete completion requirement type —
// mirrors upsertLessonProgress's result shape (result.lesson/result.unit, not the
// result.cascade wrapper upsertWatchProgress gets from recordWatchProgress).
const markComplete = useCallback(async (lessonUuid, unitUuid) => {
try {
const { data } = await api.post(`/client/lessons/${lessonUuid}/mark-complete`, {
...(unitUuid ? { unit_uuid: unitUuid } : {}),
});
const result = data.data ?? null;
setUnitDetail((prev) => {
if (!prev || !result?.lesson) return prev;
const nextLessons = prev.lessons.map((l) =>
l.lesson_id === result.lesson.lesson_id
? { ...l, status: result.lesson.status, completed_at: new Date().toISOString() }
: l
);
const is_completed = result.unit ? result.unit.status === "completed" : prev.is_completed;
return { ...prev, lessons: nextLessons, is_completed };
});
return result;
} catch (err) {
toast(err?.response?.data?.message ?? "Could not mark lesson complete.");
return null;
}
}, []);
// ─── Resets ─────────────────────────────────────────────────────────────
const resetUnitDetail = useCallback(() => {
@@ -222,6 +250,7 @@ export function ClientLibraryProvider({ children }) {
saveUnitQuizDraft,
upsertLessonProgress,
upsertWatchProgress,
markComplete,
resetUnitDetail,
resetLesson,
+18 -9
View File
@@ -10,9 +10,12 @@
***********************************************************************************************************************************************************************/
import { useState } from 'react'
import { Navigate, useNavigate, Link } from 'react-router-dom'
import { isValidPhoneNumber, parsePhoneNumber } from 'react-phone-number-input'
import { useAuth } from '@/contexts/AuthContext'
import { useDetectedCountry } from '@/hooks/useDetectedCountry'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { PhoneInput } from '@/components/ui/phone-input'
import { Label } from '@/components/ui/label'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Loader2 } from 'lucide-react'
@@ -47,11 +50,13 @@ export default function IntroPage() {
const [dateOfBirth, setDateOfBirth] = useState(pi.date_of_birth ?? '')
const [occupation, setOccupation] = useState(pi.occupation ?? '')
const [phone, setPhone] = useState(
pi.phone_number?.[0]?.full_number ?? ''
pi.phone_number?.[0]?.full_number ? `+${pi.phone_number[0].full_number}` : ''
)
const [loading, setLoading] = useState(false)
const [errors, setErrors] = useState({})
const detectedCountry = useDetectedCountry()
// ── Derived ────────────────────────────────────────────────────────────────
const avatarUrl = pi.avatar?.url ?? ''
@@ -71,7 +76,7 @@ export default function IntroPage() {
}
if (!phone.trim()) {
e.phone = 'Phone number is required.'
} else if (!/^\+?[0-9\s\-() ]{7,20}$/.test(phone.trim())) {
} else if (!isValidPhoneNumber(phone)) {
e.phone = 'Invalid phone number.'
}
return e
@@ -104,9 +109,13 @@ export default function IntroPage() {
occupation,
phone_number: phone.trim()
? (() => {
const digits = phone.replace(/\D/g, '')
const number = digits.startsWith('63') ? digits.slice(2) : digits.replace(/^0/, '')
return [{ number, country_code: '+63', full_number: `+63${number}`, phone_type: 'mobile' }]
const parsed = parsePhoneNumber(phone)
return [{
number: parsed.nationalNumber,
country_code: parsed.countryCallingCode,
full_number: `${parsed.countryCallingCode}${parsed.nationalNumber}`,
phone_type: 'mobile',
}]
})()
: (pi.phone_number ?? []),
// Preserve existing avatar and addresses
@@ -245,11 +254,11 @@ export default function IntroPage() {
<Label className="text-xs">
Phone number <span className="text-destructive">*</span>
</Label>
<Input
type="tel"
<PhoneInput
value={phone}
onChange={e => setPhone(e.target.value)}
placeholder="+63 912 345 6789"
onChange={setPhone}
international
defaultCountry={detectedCountry}
disabled={loading}
/>
{errors.phone && <p className="text-xs text-destructive">{errors.phone}</p>}
+18 -15
View File
@@ -28,7 +28,7 @@ import { useCourseReadingProgress } from "@/contexts/ClientCourseReadingProgress
import { PageMeta } from "@/contexts/MetadataContext";
import { toast } from "sonner";
import { resolveTierBadge } from "@/utils/tierBadge.util";
import { getTierColor, getContrastText } from "@/utils/tierColors";
import { getTierColor } from "@/utils/tierColors";
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner";
import { Tags } from "lucide-react";
@@ -781,15 +781,15 @@ const CourseDetails = () => {
{course.prerequisites.map((p) => {
// Guarded = the prerequisite itself sits behind a paid tier —
// paint the whole row with that tier's actual admin-configured
// color (Tier Categories → Color) instead of a plain gray row.
// Uses the swatch hex directly (inline style) rather than a
// dynamic Tailwind class, since the color key is admin-defined
// at runtime and arbitrary bg-{key}-500 classes aren't
// guaranteed to survive Tailwind's build-time purge.
// color (Tier Categories → Color). Reuses the exact same
// gradient classes the tier Badge renders with (tierColors.js
// #badge) instead of a flat swatch fill — those strings are
// hardcoded per color key in tierColors.js so they survive
// Tailwind's build-time purge, unlike a runtime-composed
// bg-{key}-500 class would.
const tierInfo = p.subscription ? tierMap[p.subscription] : null;
const guarded = (tierInfo?.rank ?? 0) > 0 && !p.completed;
const tierColor = guarded ? getTierColor(tierInfo.color) : null;
const textColor = tierColor ? getContrastText(tierColor.swatch, tierInfo.color) : null;
return (
<div
@@ -797,9 +797,9 @@ const CourseDetails = () => {
className={cn(
"flex items-center justify-between gap-3 py-3 px-3 rounded-lg",
p.completed && "bg-emerald-50 dark:bg-emerald-950/20",
!p.completed && !guarded && "bg-muted/40"
!p.completed && !guarded && "bg-muted/40",
guarded && tierColor.badge
)}
style={guarded ? { backgroundColor: tierColor.swatch } : undefined}
>
<div className="flex items-center gap-3 min-w-0">
{p.completed ? (
@@ -808,13 +808,15 @@ const CourseDetails = () => {
</div>
) : (
<div
className="size-4 rounded-full border-2 shrink-0"
style={{ borderColor: guarded ? textColor : undefined }}
className={cn("size-4 rounded-full border-2 shrink-0", guarded && "border-white/70")}
/>
)}
<span
className={cn("font-medium truncate", !p.completed && !guarded && "text-muted-foreground")}
style={{ color: guarded ? textColor : undefined }}
className={cn(
"font-medium truncate",
!p.completed && !guarded && "text-muted-foreground",
guarded && "text-white"
)}
>
{p.title ?? "—"}
</span>
@@ -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
? <><CheckCheck className="size-3.5" /> Completed</>
+80 -3
View File
@@ -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: <House className="size-4" />, to: `/dashboard` },
{ label: "Lessons", to: `/lessons` },
@@ -112,6 +160,23 @@ const LessonDetails = () => {
return (
<div className="flex-1 flex flex-col">
<PageMeta title={`${lesson.title} - STARR`} description={lesson.description} />
{/* 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' && (
<div
className="fixed left-0 right-0 z-30 h-1.5 bg-border"
style={{ top: "var(--navbar-h)" }}
>
<div
className={`h-full transition-all duration-150 ease-out ${scrollProgress >= 100 ? 'bg-green-500' : 'bg-primary'}`}
style={{ width: `${scrollProgress}%` }}
/>
</div>
)}
<div
className="flex-1 flex flex-col"
style={{ paddingTop: "var(--navbar-h)" }}
@@ -197,7 +262,19 @@ const LessonDetails = () => {
</div>
) : (
<div className="w-full">
<LessonBlock lesson={lesson} loading={lessonLoading} showHeader={false} onWatchProgress={handleWatchProgress} />
<LessonBlock
lesson={lesson}
loading={lessonLoading}
showHeader={false}
onWatchProgress={['watch_percent', 'watch_video', 'listen_audio'].includes(lessonCompletionType) ? handleWatchProgress : undefined}
/>
{taskCtx?.has_task && !lessonLoading && lesson && lessonCompletionType === 'manual_complete' && (
<MarkCompleteButton
label={lesson?.completion?.button_label}
completed={hasCompleted}
onMarkComplete={handleMarkComplete}
/>
)}
</div>
)}
</div>
@@ -0,0 +1,206 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowLeft, BookOpen, Layers, FileText, LockIcon, CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { useDateFormat } from "@/hooks/useDateFormat";
import api from "@/utils/api.util";
// Polls in the background so items completed elsewhere (another tab, another
// device) show up here without a manual refresh — same convention as
// ClientNotificationContext's badge polling, kept local since only this page
// consumes it.
const POLL_INTERVAL = 30_000;
const EMPTY = { courses: [], units: [], lessons: [] };
const EmptyState = ({ icon: Icon, label }) => (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Icon className="h-10 w-10 text-muted-foreground/40 mb-3" />
<p className="text-sm font-medium">Nothing completed yet</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
);
const RowSkeleton = () => (
<div className="flex items-center gap-4 p-4">
<Skeleton className="h-9 w-9 rounded-full shrink-0" />
<div className="space-y-1.5 flex-1">
<Skeleton className="h-3.5 w-48" />
<Skeleton className="h-3 w-32" />
</div>
</div>
);
const CompletedRow = ({ icon: Icon, title, subtitle, completedAt, onClick }) => {
const { fmtDate } = useDateFormat();
return (
<div
className="flex items-center gap-4 p-4 cursor-pointer hover:bg-accent/40 transition-colors"
onClick={onClick}
>
<div className="h-9 w-9 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center shrink-0">
<Icon className="size-4 text-green-700 dark:text-green-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{title}</p>
{subtitle && <p className="text-xs text-muted-foreground truncate mt-0.5">{subtitle}</p>}
</div>
<div className="shrink-0 text-right">
<Badge variant="outline" className="gap-1 text-xs">
<CheckCircle2 className="size-3" /> {completedAt ? fmtDate(completedAt) : "—"}
</Badge>
</div>
</div>
);
};
export default function MyCompletedContent() {
const navigate = useNavigate();
const [data, setData] = useState(EMPTY);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
const load = async (silent = false) => {
if (!silent) setLoading(true);
try {
const { data: res } = await api.get("/client/courses/completed");
if (cancelled) return;
setData({
courses: res.data?.courses ?? [],
units: res.data?.units ?? [],
lessons: res.data?.lessons ?? [],
});
} catch {
// silent — keep last known state on a failed poll
} finally {
if (!silent) setLoading(false);
}
};
load();
const interval = setInterval(() => load(true), POLL_INTERVAL);
return () => { cancelled = true; clearInterval(interval); };
}, []);
const total = data.courses.length + data.units.length + data.lessons.length;
return (
<section className="mt-17 bg-muted min-h-full">
<div className="p-6 lg:container lg:max-w-3xl lg:mx-auto space-y-5">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/profile")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<div className="flex items-center gap-2">
<h1 className="text-xl font-semibold">Completed</h1>
<Badge variant="outline" className="gap-1 text-xs">
<LockIcon className="h-3 w-3" /> Only you
</Badge>
</div>
<p className="text-sm text-muted-foreground">
Every lesson, unit, and course you've finished.
</p>
</div>
</div>
<Tabs defaultValue="courses" className="w-full">
<TabsList>
<TabsTrigger value="courses">
Courses {!loading && <Badge variant="secondary" className="ml-1.5">{data.courses.length}</Badge>}
</TabsTrigger>
<TabsTrigger value="units">
Units {!loading && <Badge variant="secondary" className="ml-1.5">{data.units.length}</Badge>}
</TabsTrigger>
<TabsTrigger value="lessons">
Lessons {!loading && <Badge variant="secondary" className="ml-1.5">{data.lessons.length}</Badge>}
</TabsTrigger>
</TabsList>
<TabsContent value="courses">
<div className="rounded-xl border bg-card divide-y">
{loading ? (
[...Array(3)].map((_, i) => <RowSkeleton key={i} />)
) : data.courses.length === 0 ? (
<EmptyState icon={BookOpen} label="Finish a course to see it here." />
) : (
data.courses.map((c, i) => (
<div key={c.course_id}>
<CompletedRow
icon={BookOpen}
title={c.title}
subtitle={c.certificate ? `Certificate ${c.certificate.cert_no}` : null}
completedAt={c.completed_at}
onClick={() => navigate(`/course/${c.course_id}`)}
/>
{i < data.courses.length - 1 && <Separator />}
</div>
))
)}
</div>
</TabsContent>
<TabsContent value="units">
<div className="rounded-xl border bg-card divide-y">
{loading ? (
[...Array(3)].map((_, i) => <RowSkeleton key={i} />)
) : data.units.length === 0 ? (
<EmptyState icon={Layers} label="Finish a unit to see it here." />
) : (
data.units.map((u, i) => (
<div key={u.uuid}>
<CompletedRow
icon={Layers}
title={u.title}
subtitle={u.course ? u.course.title : "Standalone"}
completedAt={u.completed_at}
onClick={() => navigate(`/units/${u.uuid}`)}
/>
{i < data.units.length - 1 && <Separator />}
</div>
))
)}
</div>
</TabsContent>
<TabsContent value="lessons">
<div className="rounded-xl border bg-card divide-y">
{loading ? (
[...Array(3)].map((_, i) => <RowSkeleton key={i} />)
) : data.lessons.length === 0 ? (
<EmptyState icon={FileText} label="Finish a lesson to see it here." />
) : (
data.lessons.map((l, i) => (
<div key={l.uuid}>
<CompletedRow
icon={FileText}
title={l.title}
subtitle={l.course ? l.course.title : "Standalone"}
completedAt={l.completed_at}
onClick={() => navigate(`/lessons/${l.uuid}`)}
/>
{i < data.lessons.length - 1 && <Separator />}
</div>
))
)}
</div>
</TabsContent>
</Tabs>
{!loading && total === 0 && (
<p className="text-center text-xs text-muted-foreground">
Nothing completed yet — your finished lessons, units, and courses will appear here in real time.
</p>
)}
</div>
</section>
);
}
+37
View File
@@ -201,6 +201,9 @@ const ProfilePage = () => {
const [pendingModalOpen, setPendingModalOpen] = useState(false);
const [pendingModalCourse, setPendingModalCourse] = useState(null);
const [completedTotal, setCompletedTotal] = useState(0);
const [completedLoading, setCompletedLoading] = useState(false);
useEffect(() => {
getProfile();
getAchievements();
@@ -217,6 +220,18 @@ const ProfilePage = () => {
setInProgressCoursesLoading(false);
}
})();
(async () => {
setCompletedLoading(true);
try {
const { data } = await api.get("/client/courses/completed");
const counts = data.data?.counts ?? {};
setCompletedTotal((counts.courses ?? 0) + (counts.units ?? 0) + (counts.lessons ?? 0));
} catch {
// silent — empty state handles it
} finally {
setCompletedLoading(false);
}
})();
}, []);
// ── Derived ────────────────────────────────────────────────────────────────
@@ -727,6 +742,28 @@ const ProfilePage = () => {
</CardContent>
</Card>
{/* Completed — live count, full list at /completed */}
<Card>
<CardContent
className="flex items-center justify-between cursor-pointer hover:bg-accent/40 transition-colors rounded-lg"
onClick={() => navigate("/completed")}
>
<div className="flex items-center gap-2">
<BookOpen className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Completed</span>
<Badge><LockIcon className="size-3" /> Only you</Badge>
</div>
<div className="flex items-center gap-2">
{completedLoading ? (
<Skeleton className="h-5 w-5 rounded-full" />
) : completedTotal > 0 ? (
<Badge variant="secondary" className="text-xs">{completedTotal}</Badge>
) : null}
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</div>
</CardContent>
</Card>
</div>
</div>
</div>
+29 -2
View File
@@ -8,6 +8,7 @@ import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import ResponsiveModal from "@/components/generic/ResponsiveModal";
import LessonBlock from "../components/LessonBlock.jsx";
import QuizBlock from "../components/blocks/QuizBlock.jsx";
import MarkCompleteButton from "../components/MarkCompleteButton.jsx";
import { useLibrary } from "@/contexts/ClientLibraryContext";
import { PageMeta } from "@/contexts/MetadataContext";
import { Skeleton } from "@/components/ui/skeleton";
@@ -82,7 +83,7 @@ const UnitReader = () => {
unitDetail, unitDetailLoading, unitBlocked, getUnitDetail, resetUnitDetail,
lesson, lessonLoading, getLesson, resetLesson,
quiz, quizLoading, getUnitQuiz, resetQuiz, submitUnitQuiz, saveUnitQuizDraft,
upsertLessonProgress, upsertWatchProgress,
upsertLessonProgress, upsertWatchProgress, markComplete,
} = useLibrary();
// Tracks which lessons have been marked completed this session to avoid duplicate calls
@@ -160,8 +161,15 @@ const UnitReader = () => {
return () => window.removeEventListener("scroll", handleScroll);
}, []);
// ── Lesson completion-trigger dispatch (mirrors UnitList.jsx) ──────────
// read_all_content (or unconfigured/default) → scroll-to-bottom, below.
// watch_percent / manual_complete → their own dedicated triggers — the scroll
// trigger must NOT also fire completion for those, so it's gated here.
const lessonCompletionType = lesson?.completion?.type ?? 'read_all_content';
// ── Mark lesson completed when user scrolls to the bottom ─────────────
useEffect(() => {
if (lessonCompletionType !== 'read_all_content') return;
if (scrollProgress < 100 || !selectedLessonId || !lesson?.uuid) return;
if (completedSessionRef.current.has(selectedLessonId)) return;
const stub = lessons.find((l) => l.lesson_id === selectedLessonId);
@@ -294,6 +302,12 @@ const UnitReader = () => {
upsertWatchProgress(lesson.uuid, uuid, percent, meta);
}, [lesson, uuid, upsertWatchProgress]);
// ── manual_complete trigger: learner clicks the Mark Complete button ────
const handleMarkComplete = useCallback(async () => {
if (!lesson?.uuid) return;
await markComplete(lesson.uuid, uuid);
}, [lesson, uuid, markComplete]);
// ── Next content item ──────────────────────────────────────────────────
const getNextContent = useCallback(() => {
const idx = allContent.findIndex((item) =>
@@ -518,7 +532,20 @@ const UnitReader = () => {
nextLabel={nextLabel}
/>
) : (
<LessonBlock lesson={lesson} loading={lessonLoading} onWatchProgress={handleWatchProgress} />
<>
<LessonBlock
lesson={lesson}
loading={lessonLoading}
onWatchProgress={['watch_percent', 'watch_video', 'listen_audio'].includes(lessonCompletionType) ? handleWatchProgress : undefined}
/>
{!lessonLoading && lesson && lessonCompletionType === 'manual_complete' && (
<MarkCompleteButton
label={lesson?.completion?.button_label}
completed={lesson?.status === 'completed'}
onMarkComplete={handleMarkComplete}
/>
)}
</>
)}
</div>
</div>
@@ -23,6 +23,7 @@ import ViewTask from '../pages/ViewTask'
import CourseCheckout from '../pages/CourseCheckout'
import MyCertificates from '../pages/MyCertificates'
import MyAchievements from '../pages/MyAchievements'
import MyCompletedContent from '../pages/MyCompletedContent'
import AccountSettings from '../pages/AccountSettings'
import Notifications from '../pages/Notifications'
import AdvertisementLandingPage from '../pages/AdvertisementLandingPage'
@@ -65,6 +66,7 @@ export const ClientRoutes = {
},
{ path: 'certificates', element: <MyCertificates /> },
{ path: 'achievements', element: <MyAchievements /> },
{ path: 'completed', element: <MyCompletedContent /> },
{ path: 'settings', element: <AccountSettings /> },
{ path: 'notifications', element: <Notifications /> },
{ path: 'ads/:uuid', element: <AdvertisementLandingPage /> },