added new requirements and fix UI bugs

This commit is contained in:
rgrgogu
2026-07-15 16:26:59 +08:00
parent 95987edd22
commit 5a9c0390e6
27 changed files with 1423 additions and 232 deletions
@@ -24,7 +24,7 @@ function LessonSkeleton() {
* when the caller already renders its own lesson header
* above (e.g. LessonDetails), to avoid showing it twice.
*/
const LessonBlock = ({ lesson, loading = false, showHeader = true }) => {
const LessonBlock = ({ lesson, loading = false, showHeader = true, onWatchProgress }) => {
if (!lesson && !loading) {
return (
<div className="h-full w-full flex items-center justify-center text-muted-foreground py-20">
@@ -45,6 +45,7 @@ const LessonBlock = ({ lesson, loading = false, showHeader = true }) => {
blocks={lesson.blocks ?? []}
empty="No content blocks yet."
showHeader={showHeader}
onWatchProgress={onWatchProgress}
/>
</div>
</PreviewChrome>
@@ -0,0 +1,35 @@
import { useState } from "react";
import { CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button";
/**
* Explicit self-report completion button, backing the manual_complete
* completion requirement type. Renders as already-completed (disabled,
* checkmark) once the lesson is marked done.
*/
export default function MarkCompleteButton({ label, completed, onMarkComplete }) {
const [saving, setSaving] = useState(false);
const handleClick = async () => {
if (completed || saving) return;
setSaving(true);
await onMarkComplete?.();
setSaving(false);
};
return (
<div className="flex justify-center py-6">
<Button
type="button"
size="lg"
variant={completed ? "outline" : "default"}
disabled={completed || saving}
onClick={handleClick}
className="gap-2"
>
<CheckCircle2 className="size-4" />
{completed ? "Completed" : saving ? "Marking complete…" : (label || "Mark Complete")}
</Button>
</div>
);
}
+12 -1
View File
@@ -12,6 +12,7 @@ import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Progress } from "@/components/ui/progress";
import { motion, AnimatePresence } from "framer-motion";
import { useRef, useEffect, useState } from "react";
import { useScrollTrigger } from "../hooks/ScrollTrigger";
@@ -513,7 +514,7 @@ const CourseDetails = () => {
const { getCourse, course, courseBlocked, courseLoading, resetCourse } = useClientCourses();
const { myTier, getMyTier } = useClientTiers();
const { fetchCourseProgress, isCompleted, resetProgress } = useCourseReadingProgress();
const { fetchCourseProgress, fetchCourseProgressSummary, summary: progressSummary, isCompleted, resetProgress } = useCourseReadingProgress();
const { advertisements, loading: adLoading, getActiveAdvertisements, handleAdCtaClick } = useClientAdvertisements();
const [tierMap, setTierMap] = useState({});
@@ -541,6 +542,7 @@ const CourseDetails = () => {
getMyTier();
getCourse(courseId);
fetchCourseProgress(courseId);
fetchCourseProgressSummary(courseId);
getActiveAdvertisements(["course_details.banner"]);
return () => { resetCourse(); resetProgress(); setBadgeImageUrl(null); };
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -678,6 +680,15 @@ const CourseDetails = () => {
</div>
)}
</div>
{progressSummary && progressSummary.lessons_total > 0 && (
<div className="flex flex-col gap-1.5 max-w-md">
<div className="flex items-center justify-between text-xs xs:text-white/80 lg:text-muted-foreground">
<span>{progressSummary.lessons_completed} of {progressSummary.lessons_total} lessons complete</span>
<span>{progressSummary.percent}%</span>
</div>
<Progress value={progressSummary.percent} />
</div>
)}
<div ref={CourseBreadcrumb} className="w-fit">
{contentNotReady ? (
<div className="flex items-center gap-2 rounded-lg border bg-muted px-4 py-2.5 text-sm text-muted-foreground">
+4 -37
View File
@@ -1,10 +1,9 @@
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useParams, useNavigate } from "react-router-dom";
import {
House, SendHorizonal, CheckCheck, Check, Hourglass, Clock, Layers, BookOpen, Video,
House, SendHorizonal, CheckCheck, Check, Hourglass, Clock, Video,
} from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useEffect } from "react";
import { useLibrary } from "@/contexts/ClientLibraryContext";
@@ -31,7 +30,6 @@ const LessonDetails = () => {
const {
getLesson, lesson, lessonLoading, unitBlocked, unitBlockedInfo, resetLesson,
upsertLessonProgress,
} = useLibrary();
const { tierMap, getTierCategories } = useClientTiers();
@@ -63,12 +61,6 @@ const LessonDetails = () => {
{ label: lesson?.title ?? "Lesson" },
];
const badge = hasCourse
? { label: "Unit lesson · Part of a course", icon: BookOpen }
: hasUnit
? { label: "Unit lesson", icon: Layers }
: { label: "Standalone lesson", icon: Clock };
// ── Deep-link to a lesson under a locked unit — inline blocked panel ────
if (unitBlocked) {
return <LockedContentPanel course={unitBlockedInfo?.course} tierMap={tierMap} />;
@@ -88,10 +80,6 @@ const LessonDetails = () => {
if (!hasUnit) return;
navigate(`/units/${unit.uuid}/read`, { state: { lessonId: lesson.lesson_id } });
};
const handleMarkComplete = () => {
if (hasCompleted) return;
upsertLessonProgress(lesson.uuid, "completed", unit?.uuid);
};
return (
<div className="flex-1 flex flex-col">
@@ -104,10 +92,6 @@ const LessonDetails = () => {
<AppBreadcrumb items={items} />
<div className="flex flex-col gap-3 max-w-2xl">
<Badge variant="secondary" className="w-fit uppercase tracking-wide gap-1.5 px-2.5 py-1">
<badge.icon className="size-3" />
{badge.label}
</Badge>
<h1 className="font-bold xs:text-2xl lg:text-3xl">{lesson.title}</h1>
<p className="text-muted-foreground">{lesson.description ?? ""}</p>
{!hasCourse && (
@@ -154,26 +138,9 @@ const LessonDetails = () => {
</Button>
</div>
) : (
<>
<div className="w-full">
<LessonBlock lesson={lesson} loading={lessonLoading} showHeader={false} />
</div>
<div className="flex items-center justify-between gap-4 max-w-2xl">
<p className="text-sm text-muted-foreground">
{hasUnit
? `This lesson is part of the "${unit.title}" unit — progress is tracked on its own.`
: "This lesson isn't part of a course — progress is tracked on its own."
}
</p>
<Button
className="shrink-0 bg-blue-500"
disabled={hasCompleted}
onClick={handleMarkComplete}
>
{hasCompleted ? <><CheckCheck /> Completed</> : "Mark as Complete"}
</Button>
</div>
</>
<div className="w-full">
<LessonBlock lesson={lesson} loading={lessonLoading} showHeader={false} />
</div>
)}
</div>
</div>
+56 -4
View File
@@ -16,6 +16,7 @@ import { InfoDialog } from "@/components/generic/Dialogs/Client/InfoDialog";
import LessonBlock from "../components/LessonBlock.jsx";
import QuizBlock from "../components/blocks/QuizBlock.jsx";
import CourseCompleteBlock from "../components/blocks/CourseCompleteBlock.jsx";
import MarkCompleteButton from "../components/MarkCompleteButton.jsx";
import { useClientCourses } from "@/contexts/ClientCoursesContext";
import { PageMeta } from "@/contexts/MetadataContext";
import { useCourseReadingProgress } from "@/contexts/ClientCourseReadingProgressContext";
@@ -307,6 +308,8 @@ const UnitList = () => {
const {
fetchCourseProgress,
upsertLessonProgress,
upsertWatchProgress,
markComplete,
isCompleted: isProgressCompleted,
isRead: isProgressRead,
completedTasks,
@@ -448,8 +451,45 @@ const UnitList = () => {
return () => window.removeEventListener("scroll", handleScroll);
}, []);
// ── Lesson completion-trigger dispatch ─────────────────────────────────
// read_all_content (or unconfigured/default) → scroll-to-bottom, below.
// watch_percent / manual_complete → their own dedicated triggers further down
// (video/audio onWatchProgress callback, MarkCompleteButton) — the scroll
// trigger must NOT also fire completion for those, so it's gated here.
const selectedUnitStub = course?.units?.find((u) => u.unit_id === selectedUnitId);
const selectedLessonStub = selectedUnitStub?.lessons?.find((l) => l.lesson_id === selectedLessonId) ?? null;
const selectedLessonCompletionType = selectedLessonStub?.completion?.type ?? 'read_all_content';
// Quiz/assessment submits go through ClientCoursesContext, not
// ClientCourseReadingProgressContext, so their completed_tasks (pass_quiz can complete a
// read_unit/read_course task requirement with no lesson ever read) don't flow through the
// shared completedTasks toast effect above — surface them directly here instead.
const notifyCompletedTasks = useCallback((tasks) => {
(tasks ?? []).forEach((t) => toast(`"${t.task_name}" automatically turned in!`));
}, []);
// ── watch_percent / watch_video / listen_audio trigger: video/audio block
// reports playback progress. meta.blockId/blockType (attached by PreviewBlock)
// let the backend drive the per-block watch_video/listen_audio types alongside
// the aggregate watch_percent one — safe to always pass through, the backend
// no-ops whichever type isn't configured on the lesson.
const handleWatchProgress = useCallback((percent, meta) => {
if (!selectedLessonId || !selectedUnitId) return;
upsertWatchProgress(courseId, selectedUnitId, selectedLessonId, percent, meta);
}, [courseId, selectedUnitId, selectedLessonId, upsertWatchProgress]);
// ── manual_complete trigger: learner clicks the Mark Complete button ────
const handleMarkComplete = useCallback(async () => {
if (!selectedLessonId || !selectedUnitId) return;
const result = await markComplete(courseId, selectedUnitId, selectedLessonId);
if (result?.course?.status === 'completed') {
toast.success('All lessons read! Finish the quizzes & assessment to get certified.', { duration: 5000 });
}
}, [courseId, selectedUnitId, selectedLessonId, markComplete]);
// ── Mark lesson completed when user scrolls to the bottom ─────────────
useEffect(() => {
if (selectedLessonCompletionType !== 'read_all_content') return;
if (scrollProgress < 100 || !selectedLessonId || !selectedUnitId || !lesson?.uuid) return;
if (completedSessionRef.current.has(selectedLessonId)) return;
if (isProgressCompleted(lesson.uuid)) return;
@@ -992,6 +1032,7 @@ const UnitList = () => {
onRefreshSession={() => refreshAssessmentSession(courseId, assessment.assessment_id)}
onSubmit={async (answers, sessionId) => {
const result = await submitCourseAssessment(courseId, assessment.assessment_id, answers, sessionId);
notifyCompletedTasks(result?.completed_tasks);
await getCourse(courseId);
return result;
}}
@@ -1015,6 +1056,7 @@ const UnitList = () => {
onDraft={handleQuizDraft}
onSubmit={async (answers) => {
const result = await submitUnitQuiz(courseId, selectedUnitId, selectedQuizId, answers);
notifyCompletedTasks(result?.completed_tasks);
await getCourse(courseId);
return result;
}}
@@ -1025,10 +1067,20 @@ const UnitList = () => {
/>
)
) : (
<LessonBlock
lesson={lesson ? { ...lesson, blocks: lesson.page?.blocks ?? [] } : null}
loading={lessonLoading}
/>
<>
<LessonBlock
lesson={lesson ? { ...lesson, blocks: lesson.page?.blocks ?? [] } : null}
loading={lessonLoading}
onWatchProgress={['watch_percent', 'watch_video', 'listen_audio'].includes(selectedLessonCompletionType) ? handleWatchProgress : undefined}
/>
{!lessonLoading && lesson && selectedLessonCompletionType === 'manual_complete' && (
<MarkCompleteButton
label={selectedLessonStub?.completion?.button_label}
completed={isProgressCompleted(lesson.uuid)}
onMarkComplete={handleMarkComplete}
/>
)}
</>
)}
</div>
</div>