From 5a9c0390e654722e03cb74038687ad71687ea98a Mon Sep 17 00:00:00 2001 From: rgrgogu Date: Wed, 15 Jul 2026 16:26:59 +0800 Subject: [PATCH] added new requirements and fix UI bugs --- .../generic/Blocks/Client/AudioBlock.jsx | 20 +- .../generic/Blocks/Client/VideoBlock.jsx | 23 +- src/contexts/AdminCoursesContext.jsx | 84 ++++ src/contexts/AdminLibraryContext.jsx | 12 +- .../ClientCourseReadingProgressContext.jsx | 74 ++++ src/contexts/ClientLibraryContext.jsx | 7 +- .../courses/CompletionRequirementBuilder.jsx | 178 ++++++++ .../courses/DraftRequirementsEditor.jsx | 166 ++++++++ .../components/courses/LessonsPreview.jsx | 19 +- .../courses/completionRequirementTypes.js | 66 +++ .../admin/pages/courses/CourseAssessment.jsx | 20 +- .../admin/pages/courses/EditCourse.jsx | 28 +- .../admin/pages/courses/lessons/AddLesson.jsx | 396 ++++++++++++++---- .../pages/courses/lessons/EditLesson.jsx | 16 +- .../admin/pages/courses/units/AddUnit.jsx | 163 +++++-- .../admin/pages/courses/units/EditUnit.jsx | 16 +- .../admin/pages/courses/units/ModifyQuiz.jsx | 20 +- .../library/lessons/AddLibraryLesson.jsx | 55 ++- .../library/lessons/EditLibraryLesson.jsx | 16 + .../library/lessons/ViewLibraryLesson.jsx | 33 +- .../pages/library/units/AddLibraryUnit.jsx | 75 +++- .../pages/library/units/EditLibraryUnit.jsx | 16 + src/modules/client/components/LessonBlock.jsx | 3 +- .../client/components/MarkCompleteButton.jsx | 35 ++ src/modules/client/pages/CourseDetails.jsx | 13 +- src/modules/client/pages/LessonDetails.jsx | 41 +- src/modules/client/pages/UnitList.jsx | 60 ++- 27 files changed, 1423 insertions(+), 232 deletions(-) create mode 100644 src/modules/admin/components/courses/CompletionRequirementBuilder.jsx create mode 100644 src/modules/admin/components/courses/DraftRequirementsEditor.jsx create mode 100644 src/modules/admin/components/courses/completionRequirementTypes.js create mode 100644 src/modules/client/components/MarkCompleteButton.jsx diff --git a/src/components/generic/Blocks/Client/AudioBlock.jsx b/src/components/generic/Blocks/Client/AudioBlock.jsx index 7bbbdc1..eb362f3 100644 --- a/src/components/generic/Blocks/Client/AudioBlock.jsx +++ b/src/components/generic/Blocks/Client/AudioBlock.jsx @@ -32,8 +32,11 @@ const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").repla // // content shape: { asset_id?, url?, storage_provider?, title?, artist?, tag?, thumbnail? } -export function AudioBlock({ content }) { +// onWatchProgress(percent) — optional, called (throttled) as playback advances and +// immediately at 100% on end. Backing the watch_percent completion requirement type. +export function AudioBlock({ content, onWatchProgress }) { const audioRef = useRef(null); + const lastReportRef = useRef(0); // ── Stream state ────────────────────────────────────────────────────────── const [blobUrl, setBlobUrl] = useState(null); @@ -116,9 +119,20 @@ export function AudioBlock({ content }) { // ── Audio events ────────────────────────────────────────────────────────── - const onTimeUpdate = useCallback(() => setCurrentTime(audioRef.current?.currentTime ?? 0), []); + const onTimeUpdate = useCallback(() => { + const el = audioRef.current; + setCurrentTime(el?.currentTime ?? 0); + if (onWatchProgress && el?.duration) { + const pct = (el.currentTime / el.duration) * 100; + const now = Date.now(); + if (now - lastReportRef.current > 3000) { + lastReportRef.current = now; + onWatchProgress(pct); + } + } + }, [onWatchProgress]); const onLoadedMeta = useCallback(() => setDuration(audioRef.current?.duration ?? 0), []); - const onEnded = useCallback(() => setPlaying(false), []); + const onEnded = useCallback(() => { setPlaying(false); onWatchProgress?.(100); }, [onWatchProgress]); const onProgress = useCallback(() => { const el = audioRef.current; if (el?.buffered.length && el.duration) { diff --git a/src/components/generic/Blocks/Client/VideoBlock.jsx b/src/components/generic/Blocks/Client/VideoBlock.jsx index c24f9ec..863b4ad 100644 --- a/src/components/generic/Blocks/Client/VideoBlock.jsx +++ b/src/components/generic/Blocks/Client/VideoBlock.jsx @@ -159,9 +159,13 @@ function SettingsPanel({ speed, onSpeed, onClose }) { // // content shape: { asset_id, url, storage_provider, thumbnail_url? } -export function VideoBlock({ content }) { +// onWatchProgress(percent) — optional, called (throttled) as playback advances and +// immediately at 100% on end. Backing the watch_percent completion requirement type; +// harmless/unused when the lesson isn't configured for it (caller just won't pass it). +export function VideoBlock({ content, onWatchProgress }) { const wrapRef = useRef(null); const vidRef = useRef(null); + const lastReportRef = useRef(0); // ── Stream state ────────────────────────────────────────────────────────── const [blobUrl, setBlobUrl] = useState(null); @@ -266,10 +270,23 @@ export function VideoBlock({ content }) { const onTimeUpdate = () => { setCurrentTime(v.currentTime); - if (v.duration) setProgress((v.currentTime / v.duration) * 100); + if (v.duration) { + const pct = (v.currentTime / v.duration) * 100; + setProgress(pct); + if (onWatchProgress) { + const now = Date.now(); + if (now - lastReportRef.current > 3000) { + lastReportRef.current = now; + onWatchProgress(pct); + } + } + } }; const onLoaded = () => setTotalDuration(v.duration); - const onEnded = () => { setPlaying(false); setOverlayVisible(false); setEnded(true); }; + const onEnded = () => { + setPlaying(false); setOverlayVisible(false); setEnded(true); + onWatchProgress?.(100); + }; const onWaiting = () => setBuffering(true); const onCanPlay = () => setBuffering(false); const onProgress = () => { diff --git a/src/contexts/AdminCoursesContext.jsx b/src/contexts/AdminCoursesContext.jsx index b401ed4..177c822 100644 --- a/src/contexts/AdminCoursesContext.jsx +++ b/src/contexts/AdminCoursesContext.jsx @@ -52,6 +52,7 @@ export function CoursesProvider({ children }) { const [lesson, setLesson] = useState(null); const [lessonPage, setLessonPage] = useState(null); const [prerequisites, setPrerequisites] = useState([]); + const [requirements, setRequirements] = useState([]); const [quiz, setQuiz] = useState(null); const [questions, setQuestions] = useState([]); const [assessment, setAssessment] = useState(null); @@ -1149,6 +1150,79 @@ export function CoursesProvider({ children }) { [request], ); + // ========================================================================= + // COMPLETION REQUIREMENTS + // ========================================================================= + + const fetchCourseRequirements = useCallback( + (courseId) => + request(async () => { + const { data } = await api.get(`${BASE}/${courseId}/requirements`); + const result = data?.data ?? []; + setRequirements(result); + return result; + }), + [request], + ); + + const syncCourseRequirements = useCallback( + (courseId, requirements) => + request(async () => { + const { data } = await api.put(`${BASE}/${courseId}/requirements`, { requirements }); + const result = data?.data ?? []; + setRequirements(result); + toast("Completion requirements updated."); + return result; + }), + [request], + ); + + const fetchUnitRequirements = useCallback( + (courseId, unitId) => + request(async () => { + const { data } = await api.get(`${unitBase(courseId, unitId)}/requirements`); + const result = data?.data ?? []; + setRequirements(result); + return result; + }), + [request], + ); + + const syncUnitRequirements = useCallback( + (courseId, unitId, requirements) => + request(async () => { + const { data } = await api.put(`${unitBase(courseId, unitId)}/requirements`, { requirements }); + const result = data?.data ?? []; + setRequirements(result); + toast("Completion requirements updated."); + return result; + }), + [request], + ); + + const fetchLessonRequirements = useCallback( + (courseId, unitId, lessonId) => + request(async () => { + const { data } = await api.get(`${lessonBase(courseId, unitId, lessonId)}/requirements`); + const result = data?.data ?? []; + setRequirements(result); + return result; + }), + [request], + ); + + const syncLessonRequirements = useCallback( + (courseId, unitId, lessonId, requirements) => + request(async () => { + const { data } = await api.put(`${lessonBase(courseId, unitId, lessonId)}/requirements`, { requirements }); + const result = data?.data ?? []; + setRequirements(result); + toast("Completion requirements updated."); + return result; + }), + [request], + ); + // ─── Provider value ─────────────────────────────────────────────────────── return ( + ({ ids }) => request(async () => { const { data } = await api.delete(`${UNITS_BASE}/bulk`, { data: { ids } }); toast(data?.message ?? "Units archived."); @@ -181,7 +181,7 @@ export function LibraryProvider({ children }) { ); const restoreUnits = useCallback( - (ids) => + ({ ids }) => request(async () => { const { data } = await api.patch(`${UNITS_BASE}/restore/bulk`, { ids }); toast(data?.message ?? "Units restored."); @@ -201,7 +201,7 @@ export function LibraryProvider({ children }) { ); const permanentlyDeleteUnits = useCallback( - (ids) => + ({ ids }) => request(async () => { const { data } = await api.delete(`${UNITS_BASE}/bulk/permanent`, { data: { ids } }); toast(data?.message ?? "Units permanently deleted."); @@ -344,7 +344,7 @@ export function LibraryProvider({ children }) { ); const archiveLessons = useCallback( - (ids) => + ({ ids }) => request(async () => { const { data } = await api.delete(`${LESSONS_BASE}/bulk`, { data: { ids } }); toast(data?.message ?? "Lessons archived."); @@ -369,7 +369,7 @@ export function LibraryProvider({ children }) { ); const restoreLessons = useCallback( - (ids) => + ({ ids }) => request(async () => { const { data } = await api.patch(`${LESSONS_BASE}/restore/bulk`, { ids }); toast(data?.message ?? "Lessons restored."); @@ -389,7 +389,7 @@ export function LibraryProvider({ children }) { ); const permanentlyDeleteLessons = useCallback( - (ids) => + ({ ids }) => request(async () => { const { data } = await api.delete(`${LESSONS_BASE}/bulk/permanent`, { data: { ids } }); toast(data?.message ?? "Lessons permanently deleted."); diff --git a/src/contexts/ClientCourseReadingProgressContext.jsx b/src/contexts/ClientCourseReadingProgressContext.jsx index eba442d..c83ff06 100644 --- a/src/contexts/ClientCourseReadingProgressContext.jsx +++ b/src/contexts/ClientCourseReadingProgressContext.jsx @@ -30,6 +30,7 @@ export function useCourseReadingProgress() { export function CourseReadingProgressProvider({ children }) { // { [reference_id]: 'in_progress' | 'completed' } const [progressMap, setProgressMap] = useState({}); + const [summary, setSummary] = useState(null); // { lessons_total, lessons_completed, percent, status } const [loading, setLoading] = useState(false); // Tasks whose all read requirements just became complete — consumed by UnitList for toasts const [completedTasks, setCompletedTasks] = useState([]); @@ -65,6 +66,20 @@ export function CourseReadingProgressProvider({ children }) { } }, []); + // ─── Fetch compact progress summary (course-level % complete) ───────────── + + const fetchCourseProgressSummary = useCallback(async (courseId) => { + try { + const { data } = await api.get(`/client/courses/${courseId}/progress/summary`); + const result = data.data ?? null; + setSummary(result); + return result; + } catch (err) { + console.error('[COURSE PROGRESS SUMMARY]', err); + return null; + } + }, []); + // ─── UPSERT lesson progress ─────────────────────────────────────────────── const upsertLessonProgress = useCallback(async (courseId, unitId, lessonId, lessonUuid, status) => { @@ -106,6 +121,60 @@ export function CourseReadingProgressProvider({ children }) { } }, []); + // ─── Watch-percent progress (video/audio) ──────────────────────────────── + + const upsertWatchProgress = useCallback(async (courseId, unitId, lessonId, percent, meta = {}) => { + try { + const { data } = await api.post( + `/client/courses/${courseId}/units/${unitId}/lessons/${lessonId}/watch-progress`, + { percent, block_id: meta.blockId ?? null, block_type: meta.blockType ?? null } + ); + const result = data.data ?? {}; + if (result.cascade) { + setProgressMap((prev) => { + const next = { ...prev }; + if (result.cascade.lesson) next[result.cascade.lesson.reference_id] = result.cascade.lesson.status; + if (result.cascade.unit) next[result.cascade.unit.reference_id] = result.cascade.unit.status; + if (result.cascade.course) next[result.cascade.course.reference_id] = result.cascade.course.status; + return next; + }); + if (result.cascade.completed_tasks?.length) { + setCompletedTasks(result.cascade.completed_tasks); + } + } + return result; + } catch (err) { + console.error('[WATCH PROGRESS]', err); + return null; + } + }, []); + + // ─── Manual "mark complete" ─────────────────────────────────────────────── + + const markComplete = useCallback(async (courseId, unitId, lessonId) => { + try { + const { data } = await api.post( + `/client/courses/${courseId}/units/${unitId}/lessons/${lessonId}/mark-complete` + ); + const result = data.data ?? {}; + setProgressMap((prev) => { + const next = { ...prev }; + if (result.lesson) next[result.lesson.reference_id] = result.lesson.status; + if (result.unit) next[result.unit.reference_id] = result.unit.status; + if (result.course) next[result.course.reference_id] = result.course.status; + return next; + }); + if (result.completed_tasks?.length) { + setCompletedTasks(result.completed_tasks); + } + toast('Lesson marked complete.'); + return result; + } catch (err) { + toast(err?.response?.data?.message ?? 'Could not mark lesson complete.'); + return null; + } + }, []); + // ─── Clear completed tasks signal after consumption ─────────────────────── const clearCompletedTasks = useCallback(() => setCompletedTasks([]), []); @@ -114,17 +183,22 @@ export function CourseReadingProgressProvider({ children }) { const resetProgress = useCallback(() => { setProgressMap({}); + setSummary(null); setCompletedTasks([]); }, []); return ( { if (!prev || !result?.lesson) return prev; const nextLessons = prev.lessons.map((l) => @@ -144,7 +149,7 @@ export function ClientLibraryProvider({ children }) { ? { ...l, status: result.lesson.status, completed_at: status === "completed" ? new Date().toISOString() : l.completed_at } : l ); - const is_completed = nextLessons.length > 0 && nextLessons.every((l) => l.status === "completed"); + const is_completed = result.unit ? result.unit.status === "completed" : prev.is_completed; return { ...prev, lessons: nextLessons, is_completed }; }); diff --git a/src/modules/admin/components/courses/CompletionRequirementBuilder.jsx b/src/modules/admin/components/courses/CompletionRequirementBuilder.jsx new file mode 100644 index 0000000..14ebf85 --- /dev/null +++ b/src/modules/admin/components/courses/CompletionRequirementBuilder.jsx @@ -0,0 +1,178 @@ +import { useEffect, useState } from "react"; +import { Plus, Trash2, GripVertical, BookOpenCheck, Save, Info } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select"; +import { Spinner } from "@/components/ui/spinner"; +import { TYPE_DEFS, DEFAULT_BEHAVIOR_TEXT } from "./completionRequirementTypes"; + +function createRequirement(type) { + return { _key: crypto.randomUUID(), type, min_percent: 100, button_label: "", is_required: true }; +} + +/** + * Self-contained editor for one entity's CompletionRequirement rows — fetches on mount, + * saves via its own button (matches EditCourse.jsx's per-section-save convention, not a + * bundled page-level submit). Works for both nested (courseId present) and standalone + * library (courseId omitted) entities — fetchFn/syncFn + args resolve that server-side. + */ +export default function CompletionRequirementBuilder({ entityType, fetchFn, syncFn, args = [] }) { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [dirty, setDirty] = useState(false); + + useEffect(() => { + let active = true; + fetchFn(...args).then((rows) => { + if (!active) return; + setItems((rows ?? []).map((r) => ({ _key: crypto.randomUUID(), ...r }))); + setLoading(false); + setDirty(false); + }); + return () => { active = false; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [...args]); + + const availableTypes = Object.entries(TYPE_DEFS) + .filter(([, def]) => def.entityTypes.includes(entityType)) + .map(([value, def]) => ({ value, ...def })); + + const usedTypes = new Set(items.map((i) => i.type)); + const addableTypes = availableTypes.filter((t) => !usedTypes.has(t.value)); + + const update = (key, patch) => { + setItems((prev) => prev.map((i) => (i._key === key ? { ...i, ...patch } : i))); + setDirty(true); + }; + const addItem = (type) => { + setItems((prev) => [...prev, createRequirement(type)]); + setDirty(true); + }; + const removeItem = (key) => { + setItems((prev) => prev.filter((i) => i._key !== key)); + setDirty(true); + }; + + const handleSave = async () => { + setSaving(true); + const clean = items.map(({ _key, ...r }) => r); + const result = await syncFn(...args, clean); + if (result) { + setItems(result.map((r) => ({ _key: crypto.randomUUID(), ...r }))); + setDirty(false); + } + setSaving(false); + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {items.length === 0 && ( +

+ + + No completion requirements configured — by default, {DEFAULT_BEHAVIOR_TEXT[entityType]}. + Add a requirement below to override this. + +

+ )} + + {items.map((item, idx) => { + const def = TYPE_DEFS[item.type]; + const Icon = def?.icon ?? BookOpenCheck; + return ( + + +
+ + + + {idx + 1} + +
+

{def?.label ?? item.type}

+

{def?.describe(entityType)}

+
+ +
+ + {item.type === "watch_percent" && ( +
+ update(item._key, { min_percent: Math.min(100, Math.max(1, parseInt(e.target.value) || 1)) })} + className="h-8 text-sm" + /> + +
+ )} + + {item.type === "manual_complete" && ( +
+ + update(item._key, { button_label: e.target.value })} + className="h-8 text-sm" + /> +
+ )} +
+
+ ); + })} + + {addableTypes.length > 0 && ( + + )} + +
+ +
+
+ ); +} diff --git a/src/modules/admin/components/courses/DraftRequirementsEditor.jsx b/src/modules/admin/components/courses/DraftRequirementsEditor.jsx new file mode 100644 index 0000000..90c7454 --- /dev/null +++ b/src/modules/admin/components/courses/DraftRequirementsEditor.jsx @@ -0,0 +1,166 @@ +import { Plus, Trash2, GripVertical, BookOpenCheck, ListChecks, Info } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select"; +import { TYPE_DEFS, DEFAULT_BEHAVIOR_TEXT } from "./completionRequirementTypes"; + +function createRequirement(type) { + return { _key: crypto.randomUUID(), type, min_percent: 100, button_label: "", is_required: true }; +} + +/** + * Draft-only requirements editor for creation wizards — no fetch, no + * independent save. The parent wizard holds `items` in local state and only + * persists them (via its own sync call) together with the rest of the entity + * when the wizard's final Create/Finish action fires — the entity doesn't + * exist yet while this renders, so there's nothing to fetch or save against + * until then. Mirrors CompletionRequirementBuilder's item UI, minus the + * loading/save plumbing that assumes an already-existing entity. + */ +export default function DraftRequirementsEditor({ entityType, items, onChange, blockTypes }) { + // blockTypes (optional) — the actual block types currently on this lesson's page + // (e.g. ["video", "text"]). When provided, requirement types that need a specific + // block (watch_video needs "video", listen_audio needs "audio") only show up once + // that block actually exists — "detection" rather than always offering them. + const availableTypes = Object.entries(TYPE_DEFS) + .filter(([, def]) => def.entityTypes.includes(entityType)) + .filter(([, def]) => !blockTypes || !def.requiresBlockTypes || def.requiresBlockTypes.some((bt) => blockTypes.includes(bt))) + .map(([value, def]) => ({ value, ...def })); + + const usedTypes = new Set(items.map((i) => i.type)); + const addableTypes = availableTypes.filter((t) => !usedTypes.has(t.value)); + + const update = (key, patch) => onChange(items.map((i) => (i._key === key ? { ...i, ...patch } : i))); + const addItem = (type) => onChange([...items, createRequirement(type)]); + const removeItem = (key) => onChange(items.filter((i) => i._key !== key)); + + return ( +
+ {items.length === 0 && ( +

+ + + No completion requirements configured — by default, {DEFAULT_BEHAVIOR_TEXT[entityType]}. + Add a requirement below to override this. + +

+ )} + + {items.map((item, idx) => { + const def = TYPE_DEFS[item.type]; + const Icon = def?.icon ?? BookOpenCheck; + return ( + + +
+ + + + {idx + 1} + +
+

{def?.label ?? item.type}

+

{def?.describe(entityType)}

+
+ +
+ + {item.type === "watch_percent" && ( +
+ update(item._key, { min_percent: Math.min(100, Math.max(1, parseInt(e.target.value) || 1)) })} + className="h-8 text-sm" + /> + +
+ )} + + {item.type === "manual_complete" && ( +
+ + update(item._key, { button_label: e.target.value })} + className="h-8 text-sm" + /> +
+ )} +
+
+ ); + })} + + {addableTypes.length > 0 && ( + + )} +
+ ); +} + +/** + * Read-only summary of a draft requirements array for a wizard's Review step + * — reads directly from local state (no fetch), since nothing has been + * persisted yet at that point. + */ +export function DraftRequirementsSummary({ entityType, items }) { + const describeValue = (r) => { + if (r.type === "watch_percent") return `${r.min_percent}% watched`; + if (r.type === "manual_complete") return r.button_label || "Mark Complete"; + return "Required"; + }; + + return ( +
+
+ + Completion Requirements +
+ {items.length === 0 ? ( +

+ None added — default behavior applies ({DEFAULT_BEHAVIOR_TEXT[entityType]}). +

+ ) : ( + items.map((r) => ( +
+ {TYPE_DEFS[r.type]?.label ?? r.type} + {describeValue(r)} +
+ )) + )} +
+ ); +} diff --git a/src/modules/admin/components/courses/LessonsPreview.jsx b/src/modules/admin/components/courses/LessonsPreview.jsx index ff924ef..3a4b53a 100644 --- a/src/modules/admin/components/courses/LessonsPreview.jsx +++ b/src/modules/admin/components/courses/LessonsPreview.jsx @@ -129,8 +129,17 @@ export function PreviewVideo({ url, thumb }) { ); } -export function PreviewBlock({ block }) { +// onWatchProgress(percent, { blockId, blockType }) — only meaningful for video/audio +// blocks; optional, undefined in admin preview mode (only the client reader passes it, +// for watch_percent/watch_video/listen_audio tracking). PreviewBlock (not VideoBlock/ +// AudioBlock themselves) attaches the block's own id/type to each call, since a lesson +// can have several blocks of the same type and watch_video/listen_audio need to know +// which specific one just reported progress. +export function PreviewBlock({ block, onWatchProgress }) { const { id, type, content } = block; + const withBlockMeta = onWatchProgress + ? (percent) => onWatchProgress(percent, { blockId: id, blockType: type }) + : undefined; switch (type) { case "text": @@ -140,11 +149,11 @@ export function PreviewBlock({ block }) { case "text-image": return ; case "video": - return ; + return ; case "text-video": return ; case "audio": - return ; + return ; case "code": return ; case "markdown": @@ -158,7 +167,7 @@ export function PreviewBlock({ block }) { // PhotoProvider wraps ALL blocks so images across the whole lesson share // one lightbox session — users can swipe between them naturally. -export function PreviewContent({ lesson, blocks, empty = "No content yet.", showHeader = true }) { +export function PreviewContent({ lesson, blocks, empty = "No content yet.", showHeader = true, onWatchProgress }) { return ( 300} @@ -185,7 +194,7 @@ export function PreviewContent({ lesson, blocks, empty = "No content yet.", show
{blocks.map((block) => (
- +
))}
diff --git a/src/modules/admin/components/courses/completionRequirementTypes.js b/src/modules/admin/components/courses/completionRequirementTypes.js new file mode 100644 index 0000000..8312fe3 --- /dev/null +++ b/src/modules/admin/components/courses/completionRequirementTypes.js @@ -0,0 +1,66 @@ +import { BookOpenCheck, ClipboardCheck, PlayCircle, Video, Headphones, MousePointerClick } from "lucide-react"; + +// ─── Requirement type config ────────────────────────────────────────────────── +// Mirrors utils/courses/completion_requirements.registry.js's VALID_ENTITY_TYPES +// on the backend — keep in sync if a new type is added there. +// Split out of CompletionRequirementBuilder.jsx (rather than exported from +// there) so both it and other views (e.g. wizard Review steps) can import +// these without tripping the react-refresh/only-export-components rule, +// which requires component files to export components only. +export const TYPE_DEFS = { + read_all_content: { + label: "Read / View All Content", + icon: BookOpenCheck, + entityTypes: ["course", "unit", "lesson"], + describe: (entityType) => + entityType === "lesson" + ? "Learner must read through this lesson's content." + : entityType === "unit" + ? "Every lesson in this unit must be completed." + : "Every unit in this course must be completed.", + }, + pass_quiz: { + label: "Pass the Quiz", + icon: ClipboardCheck, + entityTypes: ["unit", "course"], + describe: (entityType) => + entityType === "unit" + ? "Learner must pass this unit's quiz." + : "Learner must pass the course's final assessment.", + }, + watch_percent: { + label: "Watch % of Video/Audio", + 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"], + describe: () => "Learner must watch at least the configured percentage of the lesson's video/audio content.", + }, + watch_video: { + label: "Finish Watching the Full Video", + icon: Video, + entityTypes: ["lesson"], + requiresBlockTypes: ["video"], + describe: () => "Learner must watch every video block on this lesson all the way through (100%).", + }, + listen_audio: { + label: "Finish Listening to the Full Audio", + icon: Headphones, + entityTypes: ["lesson"], + requiresBlockTypes: ["audio"], + describe: () => "Learner must listen to every audio block on this lesson all the way through (100%).", + }, + manual_complete: { + label: 'Manual "Mark Complete"', + icon: MousePointerClick, + entityTypes: ["course", "unit", "lesson"], + describe: () => "Learner clicks a button to self-report completion — no automatic tracking.", + }, +}; + +export const DEFAULT_BEHAVIOR_TEXT = { + lesson: "the learner reading through the content marks it complete", + unit: "every lesson in the unit must be completed", + course: "every unit must be completed and the course assessment (if any) must be passed", +}; diff --git a/src/modules/admin/pages/courses/CourseAssessment.jsx b/src/modules/admin/pages/courses/CourseAssessment.jsx index 997a790..61d3ed7 100644 --- a/src/modules/admin/pages/courses/CourseAssessment.jsx +++ b/src/modules/admin/pages/courses/CourseAssessment.jsx @@ -588,15 +588,17 @@ export default function CourseAssessment() { -
- setIsRequired(val)} - /> - +
+
+ + +
+

+ Derived from this course's Completion Requirements — add or remove a "Pass the Quiz" + requirement on the Requirements step of the course editor to change this. +

diff --git a/src/modules/admin/pages/courses/EditCourse.jsx b/src/modules/admin/pages/courses/EditCourse.jsx index dbb60f8..e23faae 100644 --- a/src/modules/admin/pages/courses/EditCourse.jsx +++ b/src/modules/admin/pages/courses/EditCourse.jsx @@ -18,6 +18,7 @@ import { useCategories } from "@/contexts/AdminCategoriesContext"; import { useAuth } from "@/contexts/AuthContext"; import api from "@/utils/api.util"; import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard"; +import CompletionRequirementBuilder from "@/modules/admin/components/courses/CompletionRequirementBuilder"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -55,10 +56,11 @@ const schema = z.object({ // ─── Steps config ───────────────────────────────────────────────────────────── const STEPS = [ - { label: "Basic Info", description: "Title, level & objectives" }, - { label: "Categories", description: "Tags & instructors" }, - { label: "Rewards", description: "Badge & achievements" }, - { label: "Pricing", description: "Product listing" }, + { label: "Basic Info", description: "Title, level & objectives" }, + { label: "Categories", description: "Tags & instructors" }, + { label: "Rewards", description: "Badge & achievements" }, + { label: "Requirements", description: "What counts as complete" }, + { label: "Pricing", description: "Product listing" }, ]; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -143,6 +145,7 @@ export default function EditCourse() { fetchCourseCategories, syncCourseCategories, fetchInstructors, syncInstructors, fetchCourseAchievements, syncCourseAchievements, + fetchCourseRequirements, syncCourseRequirements, loading, course, } = useCourses(); const { categories: allCategories, fetchCategories } = useCategories(); @@ -1012,8 +1015,23 @@ export default function EditCourse() { )} - {/* ── Step 3: Pricing ── */} + {/* ── Step 3: Completion Requirements ── */} {currentStep === 3 && ( + + + + )} + + {/* ── Step 4: Pricing ── */} + {currentStep === 4 && ( {message}

; } +// ─── Step 1 — Details ─────────────────────────────────────────────────────────── +function StepDetails({ register, errors, control }) { + const { fields, append, remove } = useFieldArray({ control, name: "objectives" }); + + return ( +
+
+
+ + + +
+ +
+ +