mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
added new requirements and fix UI bugs
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 (
|
||||
<CoursesContext.Provider value={{
|
||||
@@ -1158,6 +1232,7 @@ export function CoursesProvider({ children }) {
|
||||
lessons, lesson,
|
||||
lessonPage,
|
||||
prerequisites,
|
||||
requirements,
|
||||
quiz,
|
||||
questions,
|
||||
assessment,
|
||||
@@ -1171,6 +1246,7 @@ export function CoursesProvider({ children }) {
|
||||
setLesson,
|
||||
setLessonPage,
|
||||
setPrerequisites,
|
||||
setRequirements,
|
||||
setQuiz,
|
||||
setQuestions,
|
||||
setAssessment,
|
||||
@@ -1198,6 +1274,14 @@ export function CoursesProvider({ children }) {
|
||||
fetchPrerequisites,
|
||||
syncPrerequisites,
|
||||
|
||||
// ── completion requirements ────────────────────────────────────────────
|
||||
fetchCourseRequirements,
|
||||
syncCourseRequirements,
|
||||
fetchUnitRequirements,
|
||||
syncUnitRequirements,
|
||||
fetchLessonRequirements,
|
||||
syncLessonRequirements,
|
||||
|
||||
// ── units ──────────────────────────────────────────────────────────────
|
||||
fetchUnits,
|
||||
fetchUnit,
|
||||
|
||||
@@ -156,7 +156,7 @@ export function LibraryProvider({ children }) {
|
||||
);
|
||||
|
||||
const archiveUnits = useCallback(
|
||||
(ids) =>
|
||||
({ 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.");
|
||||
|
||||
@@ -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 (
|
||||
<CourseReadingProgressContext.Provider value={{
|
||||
progressMap,
|
||||
summary,
|
||||
loading,
|
||||
isRead,
|
||||
isCompleted,
|
||||
fetchCourseProgress,
|
||||
fetchCourseProgressSummary,
|
||||
upsertLessonProgress,
|
||||
upsertWatchProgress,
|
||||
markComplete,
|
||||
completedTasks,
|
||||
clearCompletedTasks,
|
||||
resetProgress,
|
||||
|
||||
@@ -137,6 +137,11 @@ export function ClientLibraryProvider({ children }) {
|
||||
});
|
||||
const result = data.data ?? null;
|
||||
|
||||
// Trust the server's evaluated status for both lesson and unit — no local
|
||||
// re-derivation (previously `lessons.every(status === 'completed')` here,
|
||||
// which drifted from the consolidated evaluator once per-lesson completion
|
||||
// requirements could be configured; the server already ran that same
|
||||
// evaluator via recomputeCascade and returned the authoritative result).
|
||||
setUnitDetail((prev) => {
|
||||
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 };
|
||||
});
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{items.length === 0 && (
|
||||
<p className="flex items-start gap-2 text-sm text-muted-foreground py-4 px-3 border border-dashed rounded-lg">
|
||||
<Info className="size-4 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
No completion requirements configured — by default, {DEFAULT_BEHAVIOR_TEXT[entityType]}.
|
||||
Add a requirement below to override this.
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{items.map((item, idx) => {
|
||||
const def = TYPE_DEFS[item.type];
|
||||
const Icon = def?.icon ?? BookOpenCheck;
|
||||
return (
|
||||
<Card key={item._key}>
|
||||
<CardContent className="pt-4 pb-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<Badge variant="outline" className="text-xs gap-1 shrink-0">
|
||||
<Icon className="h-3 w-3" />
|
||||
{idx + 1}
|
||||
</Badge>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{def?.label ?? item.type}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{def?.describe(entityType)}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-destructive hover:text-destructive"
|
||||
onClick={() => removeItem(item._key)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{item.type === "watch_percent" && (
|
||||
<div className="pl-7 flex items-center gap-2 max-w-48">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={item.min_percent ?? 100}
|
||||
onChange={(e) => update(item._key, { min_percent: Math.min(100, Math.max(1, parseInt(e.target.value) || 1)) })}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<Label className="text-xs text-muted-foreground whitespace-nowrap">% watched required</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.type === "manual_complete" && (
|
||||
<div className="pl-7 space-y-1 max-w-64">
|
||||
<Label className="text-xs">Button label (optional)</Label>
|
||||
<Input
|
||||
placeholder='Defaults to "Mark Complete"'
|
||||
value={item.button_label ?? ""}
|
||||
onChange={(e) => update(item._key, { button_label: e.target.value })}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{addableTypes.length > 0 && (
|
||||
<Select value="" onValueChange={(v) => addItem(v)}>
|
||||
<SelectTrigger className="h-9 w-full text-sm">
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Requirement
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{addableTypes.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
<span className="flex items-center gap-2">
|
||||
<t.icon className="h-3.5 w-3.5" />
|
||||
{t.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-1">
|
||||
<Button type="button" size="sm" className="gap-2" onClick={handleSave} disabled={saving || !dirty}>
|
||||
{saving ? <Spinner className="size-4" /> : <Save className="size-4" />}
|
||||
Save Requirements
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-3">
|
||||
{items.length === 0 && (
|
||||
<p className="flex items-start gap-2 text-sm text-muted-foreground py-4 px-3 border border-dashed rounded-lg">
|
||||
<Info className="size-4 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
No completion requirements configured — by default, {DEFAULT_BEHAVIOR_TEXT[entityType]}.
|
||||
Add a requirement below to override this.
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{items.map((item, idx) => {
|
||||
const def = TYPE_DEFS[item.type];
|
||||
const Icon = def?.icon ?? BookOpenCheck;
|
||||
return (
|
||||
<Card key={item._key}>
|
||||
<CardContent className="pt-4 pb-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<Badge variant="outline" className="text-xs gap-1 shrink-0">
|
||||
<Icon className="h-3 w-3" />
|
||||
{idx + 1}
|
||||
</Badge>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{def?.label ?? item.type}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{def?.describe(entityType)}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-destructive hover:text-destructive"
|
||||
onClick={() => removeItem(item._key)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{item.type === "watch_percent" && (
|
||||
<div className="pl-7 flex items-center gap-2 max-w-48">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={item.min_percent ?? 100}
|
||||
onChange={(e) => update(item._key, { min_percent: Math.min(100, Math.max(1, parseInt(e.target.value) || 1)) })}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<Label className="text-xs text-muted-foreground whitespace-nowrap">% watched required</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.type === "manual_complete" && (
|
||||
<div className="pl-7 space-y-1 max-w-64">
|
||||
<Label className="text-xs">Button label (optional)</Label>
|
||||
<Input
|
||||
placeholder='Defaults to "Mark Complete"'
|
||||
value={item.button_label ?? ""}
|
||||
onChange={(e) => update(item._key, { button_label: e.target.value })}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{addableTypes.length > 0 && (
|
||||
<Select value="" onValueChange={(v) => addItem(v)}>
|
||||
<SelectTrigger className="h-9 w-full text-sm">
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Requirement
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{addableTypes.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
<span className="flex items-center gap-2">
|
||||
<t.icon className="h-3.5 w-3.5" />
|
||||
{t.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="border border-border rounded-lg p-4 space-y-1">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<ListChecks className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Completion Requirements</span>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
None added — default behavior applies ({DEFAULT_BEHAVIOR_TEXT[entityType]}).
|
||||
</p>
|
||||
) : (
|
||||
items.map((r) => (
|
||||
<div key={r._key ?? r.type} className="flex justify-between py-1.5 text-sm">
|
||||
<span className="text-muted-foreground min-w-[140px]">{TYPE_DEFS[r.type]?.label ?? r.type}</span>
|
||||
<span className="text-foreground text-right">{describeValue(r)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <TextImageBlock blockId={id} content={content} readOnly />;
|
||||
case "video":
|
||||
return <VideoBlock content={content} readOnly />;
|
||||
return <VideoBlock content={content} readOnly onWatchProgress={withBlockMeta} />;
|
||||
case "text-video":
|
||||
return <TextVideoBlock blockId={id} content={content} readOnly />;
|
||||
case "audio":
|
||||
return <AudioBlock content={content} />;
|
||||
return <AudioBlock content={content} onWatchProgress={withBlockMeta} />;
|
||||
case "code":
|
||||
return <CodeBlock content={content} />;
|
||||
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 (
|
||||
<PhotoProvider
|
||||
speed={() => 300}
|
||||
@@ -185,7 +194,7 @@ export function PreviewContent({ lesson, blocks, empty = "No content yet.", show
|
||||
<div className="space-y-4 sm:space-y-5">
|
||||
{blocks.map((block) => (
|
||||
<div key={block.id}>
|
||||
<PreviewBlock block={block} />
|
||||
<PreviewBlock block={block} onWatchProgress={onWatchProgress} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -588,15 +588,17 @@ export default function CourseAssessment() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id="assessment_required"
|
||||
checked={isRequired === true}
|
||||
onCheckedChange={(val) => setIsRequired(val)}
|
||||
/>
|
||||
<Label htmlFor="assessment_required" className="cursor-pointer">
|
||||
Required to complete course
|
||||
</Label>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox id="assessment_required" checked={isRequired === true} disabled />
|
||||
<Label htmlFor="assessment_required" className="text-muted-foreground">
|
||||
Required to complete course
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground pl-7">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -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() {
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* ── Step 3: Pricing ── */}
|
||||
{/* ── Step 3: Completion Requirements ── */}
|
||||
{currentStep === 3 && (
|
||||
<SectionCard
|
||||
title="Completion Requirements"
|
||||
description="What a learner must do for this course to count as complete."
|
||||
>
|
||||
<CompletionRequirementBuilder
|
||||
entityType="course"
|
||||
fetchFn={fetchCourseRequirements}
|
||||
syncFn={syncCourseRequirements}
|
||||
args={[courseId]}
|
||||
/>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* ── Step 4: Pricing ── */}
|
||||
{currentStep === 4 && (
|
||||
<SectionCard
|
||||
title="Product Listing"
|
||||
description="Allow learners to purchase this course individually via PayPal."
|
||||
|
||||
@@ -1,19 +1,37 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { useForm, useFieldArray, useWatch } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeft, Plus, Trash2 } from "lucide-react";
|
||||
import { nanoid } from "nanoid";
|
||||
import {
|
||||
ChevronLeft, ChevronRight, Check,
|
||||
FileText, LayoutTemplate, ListChecks,
|
||||
Plus, Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription,
|
||||
DrawerFooter, DrawerClose,
|
||||
} from "@/components/ui/drawer";
|
||||
import { BlockList, DEFAULT_CONTENT } from "@/components/generic/BlockList";
|
||||
import { AddBlockMenu } from "@/components/generic/AddBlockMenu";
|
||||
import { PreviewContent, PreviewChrome } from "../../../components/courses/LessonsPreview";
|
||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||
import DraftRequirementsEditor from "@/modules/admin/components/courses/DraftRequirementsEditor";
|
||||
|
||||
function makeBlock(type) {
|
||||
return { id: nanoid(), type, content: { ...DEFAULT_CONTENT[type] } };
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
title: z.string().min(1, "Title is required."),
|
||||
@@ -22,40 +40,238 @@ const schema = z.object({
|
||||
objectives: z.array(
|
||||
z.object({ value: z.string().min(1, "Objective cannot be empty.") })
|
||||
).optional(),
|
||||
blocks: z.array(z.any()).optional(),
|
||||
});
|
||||
|
||||
const STEPS = [
|
||||
{ id: 0, label: "Details", icon: FileText },
|
||||
{ id: 1, label: "Page Builder", icon: LayoutTemplate },
|
||||
{ id: 2, label: "Completion Requirements", icon: ListChecks },
|
||||
];
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null;
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
// ─── Step 1 — Details ───────────────────────────────────────────────────────────
|
||||
function StepDetails({ register, errors, control }) {
|
||||
const { fields, append, remove } = useFieldArray({ control, name: "objectives" });
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
|
||||
<Input id="title" placeholder="Lesson title" {...register("title")} />
|
||||
<FieldError message={errors.title?.message} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 max-w-[120px]">
|
||||
<Label htmlFor="order">Order</Label>
|
||||
<Input id="order" type="number" min={0} {...register("order")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Objectives */}
|
||||
<div className="rounded-lg border bg-card p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Objectives</p>
|
||||
<p className="text-xs text-muted-foreground">What learners will achieve from this lesson.</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => append({ value: "" })}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fields.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No objectives yet. Click Add to get started.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-start gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Input placeholder={`Objective ${index + 1}`} {...register(`objectives.${index}.value`)} />
|
||||
<FieldError message={errors.objectives?.[index]?.value?.message} />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => remove(index)}
|
||||
className="text-muted-foreground hover:text-destructive mt-0.5"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step 2 — Page Builder ──────────────────────────────────────────────────────
|
||||
function StepPageBuilder({ control, setValue, getValues }) {
|
||||
const title = useWatch({ control, name: "title" });
|
||||
const description = useWatch({ control, name: "description" });
|
||||
const blocks = useWatch({ control, name: "blocks" }) ?? [];
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const setBlocks = (updater) => {
|
||||
const next = typeof updater === "function" ? updater(getValues("blocks") ?? []) : updater;
|
||||
setValue("blocks", next, { shouldDirty: true });
|
||||
};
|
||||
|
||||
const addBlock = (type) => setBlocks((prev) => [...prev, makeBlock(type)]);
|
||||
const updateBlock = (id, content) => setBlocks((prev) => prev.map((b) => (b.id === id ? { ...b, content } : b)));
|
||||
const deleteBlock = (id) => setBlocks((prev) => prev.filter((b) => b.id !== id));
|
||||
const moveBlock = (id, direction) => setBlocks((prev) => {
|
||||
const index = prev.findIndex((b) => b.id === id);
|
||||
const swapIndex = direction === "up" ? index - 1 : index + 1;
|
||||
if (swapIndex < 0 || swapIndex >= prev.length) return prev;
|
||||
const next = [...prev];
|
||||
[next[index], next[swapIndex]] = [next[swapIndex], next[index]];
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between border border-border rounded-lg p-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{title || "Untitled lesson"}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{blocks.length} block{blocks.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setDrawerOpen(true)}>
|
||||
<LayoutTemplate className="h-4 w-4 mr-1.5" />
|
||||
Open Page Builder
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Drawer open={drawerOpen} onOpenChange={setDrawerOpen} shouldScaleBackground>
|
||||
<DrawerContent className="data-[vaul-drawer-direction=bottom]:max-h-[90vh]">
|
||||
<DrawerHeader className="border-b text-left">
|
||||
<DrawerTitle>Page Builder</DrawerTitle>
|
||||
<DrawerDescription>{title || "Untitled lesson"}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
Editor
|
||||
{blocks.length > 0 && (
|
||||
<span className="text-xs font-normal">· {blocks.length} block{blocks.length !== 1 ? "s" : ""}</span>
|
||||
)}
|
||||
</div>
|
||||
<BlockList blocks={blocks} onUpdate={updateBlock} onMove={moveBlock} onDelete={deleteBlock} />
|
||||
<AddBlockMenu onAdd={addBlock} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium text-muted-foreground">Live Preview</div>
|
||||
<PreviewChrome title={title}>
|
||||
<div className="p-6 space-y-5 min-h-[300px]">
|
||||
<PreviewContent
|
||||
lesson={{ title, description }}
|
||||
blocks={blocks}
|
||||
empty="Your content will appear here as you build."
|
||||
/>
|
||||
</div>
|
||||
</PreviewChrome>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DrawerFooter className="border-t flex-row justify-end">
|
||||
<DrawerClose asChild>
|
||||
<Button type="button">Done</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ──────────────────────────────────────────────────────────────────
|
||||
export default function AddLesson() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId, unitId } = useParams();
|
||||
const { createLesson, fetchUnit, course, unit, loading } = useCourses();
|
||||
const {
|
||||
createLesson, saveLessonPage, fetchUnit, unit, loading,
|
||||
syncLessonRequirements,
|
||||
} = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { register, handleSubmit, control, formState: { errors, isDirty } } = useForm({
|
||||
const [step, setStep] = useState(0);
|
||||
const [requirements, setRequirements] = useState([]);
|
||||
|
||||
const {
|
||||
register, control, trigger, getValues, setValue,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "", description: "", order: 0, objectives: [] },
|
||||
defaultValues: { title: "", description: "", order: 0, objectives: [], blocks: [] },
|
||||
mode: "onTouched",
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({ control, name: "objectives" });
|
||||
|
||||
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
|
||||
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty || requirements.length > 0);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUnit(courseId, unitId);
|
||||
}, [courseId, unitId]);
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
const handleNext = async () => {
|
||||
const valid = await trigger(["title", "description", "order", "objectives"]);
|
||||
if (valid) setStep((s) => Math.min(s + 1, 2));
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (step === 0) navigate(`/admin/courses/${courseId}/units/${unitId}/lessons`);
|
||||
else setStep((s) => s - 1);
|
||||
};
|
||||
|
||||
// The one and only persistence point — nothing is created until this final
|
||||
// click at Requirements (the last step), which is purely a draft form
|
||||
// until now. No <form> tag wraps the wizard, so this is invoked manually.
|
||||
const handleCreate = async () => {
|
||||
const valid = await trigger();
|
||||
if (!valid) return;
|
||||
|
||||
const data = getValues();
|
||||
const payload = {
|
||||
...data,
|
||||
title: data.title,
|
||||
description: data.description || null,
|
||||
order: data.order,
|
||||
objectives: data.objectives?.map((o) => o.value) ?? [],
|
||||
createdBy: user?.user_id,
|
||||
};
|
||||
const result = await createLesson(courseId, unitId, payload);
|
||||
if (!result) return;
|
||||
const newLessonId = result?.data?.data?.lesson_id;
|
||||
if (!newLessonId) return;
|
||||
|
||||
if ((data.blocks ?? []).length > 0) {
|
||||
await saveLessonPage(courseId, unitId, newLessonId, { blocks: data.blocks, updatedBy: user?.user_id });
|
||||
}
|
||||
|
||||
if (requirements.length > 0) {
|
||||
const clean = requirements.map(({ _key, ...r }) => r);
|
||||
await syncLessonRequirements(courseId, unitId, newLessonId, clean);
|
||||
}
|
||||
|
||||
bypassOnce();
|
||||
navigate(`/admin/courses/${courseId}/units/${unitId}/lessons`);
|
||||
};
|
||||
@@ -65,10 +281,10 @@ export default function AddLesson() {
|
||||
<PageMeta title={unit ? `Add Lesson – ${unit.title} - STARR` : undefined} />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
|
||||
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons`)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<div className="w-full max-w-3xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={handleBack}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Create Lesson</h1>
|
||||
@@ -76,89 +292,93 @@ export default function AddLesson() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||
{/* Stepper */}
|
||||
<div className="flex items-center gap-0">
|
||||
{STEPS.map((s, i) => {
|
||||
const Icon = s.icon;
|
||||
const isActive = step === i;
|
||||
const isDone = step > i;
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
|
||||
<Input id="title" placeholder="Lesson title" {...register("title")} />
|
||||
<FieldError message={errors.title?.message} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 max-w-[120px]">
|
||||
<Label htmlFor="order">Order</Label>
|
||||
<Input id="order" type="number" min={0} {...register("order")} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Objectives */}
|
||||
<div className="rounded-lg border bg-card p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Objectives</p>
|
||||
<p className="text-xs text-muted-foreground">What learners will achieve from this lesson.</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => append({ value: "" })}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fields.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No objectives yet. Click Add to get started.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-start gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Input
|
||||
placeholder={`Objective ${index + 1}`}
|
||||
{...register(`objectives.${index}.value`)}
|
||||
/>
|
||||
<FieldError message={errors.objectives?.[index]?.value?.message} />
|
||||
return (
|
||||
<div key={s.id} className="flex items-center flex-1 last:flex-none">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className={cn(
|
||||
"h-8 w-8 rounded-full flex items-center justify-center border transition-colors",
|
||||
isDone && "bg-emerald-600 border-emerald-600 text-white",
|
||||
isActive && "border-primary bg-primary text-primary-foreground",
|
||||
!isActive && !isDone && "border-border bg-background text-muted-foreground"
|
||||
)}>
|
||||
{isDone ? <Check className="h-4 w-4" /> : <Icon className="h-4 w-4" />}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => remove(index)}
|
||||
className="text-muted-foreground hover:text-destructive mt-0.5"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className={cn(
|
||||
"text-[11px] font-medium whitespace-nowrap hidden sm:block",
|
||||
isActive ? "text-foreground" : "text-muted-foreground",
|
||||
isDone ? "text-emerald-600" : ""
|
||||
)}>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{i < STEPS.length - 1 && (
|
||||
<div className={cn(
|
||||
"flex-1 h-px mx-2 mb-4 transition-colors",
|
||||
step > i ? "bg-emerald-600" : "bg-border"
|
||||
)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons`)} disabled={loading}>
|
||||
Cancel
|
||||
{/* Step content */}
|
||||
<div>
|
||||
{step === 0 && (
|
||||
<StepDetails register={register} errors={errors} control={control} />
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<StepPageBuilder control={control} setValue={setValue} getValues={getValues} />
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="rounded-lg border bg-card p-6 space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure how learners complete this lesson — optional, sensible defaults apply automatically. This is created together with the rest of the lesson when you finish.
|
||||
</p>
|
||||
<DraftRequirementsEditor
|
||||
entityType="lesson"
|
||||
items={requirements}
|
||||
onChange={setRequirements}
|
||||
blockTypes={(getValues("blocks") ?? []).map((b) => b.type)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-3">
|
||||
<Button type="button" variant="outline" onClick={handleBack} disabled={loading}>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
{step === 0 ? "Cancel" : "Back"}
|
||||
</Button>
|
||||
|
||||
{step < 2 ? (
|
||||
<Button type="button" onClick={handleNext}>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
) : (
|
||||
// Only the true final step (Requirements) actually persists
|
||||
// anything — the lesson, its page content, and any draft
|
||||
// requirements are all created together in one shot here.
|
||||
<Button type="button" onClick={handleCreate} disabled={loading}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Create Lesson
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{unsavedChangesDialog}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||
import CompletionRequirementBuilder from "@/modules/admin/components/courses/CompletionRequirementBuilder";
|
||||
|
||||
const schema = z.object({
|
||||
title: z.string().min(1, "Title is required."),
|
||||
@@ -33,7 +34,7 @@ function FieldError({ message }) {
|
||||
export default function EditLesson() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId, unitId, lessonId } = useParams();
|
||||
const { fetchLesson, updateLesson, course, unit, loading } = useCourses();
|
||||
const { fetchLesson, updateLesson, course, unit, loading, fetchLessonRequirements, syncLessonRequirements } = useCourses();
|
||||
const { user } = useAuth();
|
||||
const [lessonTitle, setLessonTitle] = useState("");
|
||||
|
||||
@@ -172,6 +173,19 @@ export default function EditLesson() {
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="rounded-lg border bg-card p-6 space-y-4 mt-6">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">Completion Requirements</h2>
|
||||
<p className="text-xs text-muted-foreground">What a learner must do for this lesson to count as complete.</p>
|
||||
</div>
|
||||
<CompletionRequirementBuilder
|
||||
entityType="lesson"
|
||||
fetchFn={fetchLessonRequirements}
|
||||
syncFn={syncLessonRequirements}
|
||||
args={[courseId, unitId, lessonId]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeft, House } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, Check, FileText, ListChecks } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||
import DraftRequirementsEditor from "@/modules/admin/components/courses/DraftRequirementsEditor";
|
||||
|
||||
const schema = z.object({
|
||||
title: z.string().min(1, "Title is required."),
|
||||
@@ -22,6 +23,11 @@ const schema = z.object({
|
||||
order: z.coerce.number().min(0).default(0),
|
||||
});
|
||||
|
||||
const STEPS = [
|
||||
{ id: 0, label: "Details", icon: FileText },
|
||||
{ id: 1, label: "Completion Requirements", icon: ListChecks },
|
||||
];
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null;
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
@@ -30,23 +36,41 @@ function FieldError({ message }) {
|
||||
export default function AddUnit() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId } = useParams();
|
||||
const { createUnit, fetchCourse, course, loading } = useCourses();
|
||||
const { createUnit, fetchCourse, course, loading, syncUnitRequirements } = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { register, handleSubmit, formState: { errors, isDirty } } = useForm({
|
||||
const [step, setStep] = useState(0);
|
||||
const [requirements, setRequirements] = useState([]);
|
||||
|
||||
const { register, trigger, getValues, formState: { errors, isDirty } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "", description: "", order: 0 },
|
||||
});
|
||||
|
||||
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
|
||||
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty || requirements.length > 0);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCourse(courseId);
|
||||
}, [courseId]);
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
const result = await createUnit(courseId, { ...data, createdBy: user?.user_id });
|
||||
if (!result) return;
|
||||
const handleNext = async () => {
|
||||
const valid = await trigger();
|
||||
if (valid) setStep(1);
|
||||
};
|
||||
|
||||
// The one and only persistence point — nothing is created until this final
|
||||
// click at Requirements (the last step), which is purely a draft form
|
||||
// until now.
|
||||
const handleCreate = async () => {
|
||||
const result = await createUnit(courseId, { ...getValues(), createdBy: user?.user_id });
|
||||
const newUnitId = result?.data?.data?.unit_id;
|
||||
if (!newUnitId) return;
|
||||
|
||||
if (requirements.length > 0) {
|
||||
const clean = requirements.map(({ _key, ...r }) => r);
|
||||
await syncUnitRequirements(courseId, newUnitId, clean);
|
||||
}
|
||||
|
||||
bypassOnce();
|
||||
navigate(`/admin/courses/${courseId}/units`);
|
||||
};
|
||||
@@ -56,10 +80,15 @@ export default function AddUnit() {
|
||||
<PageMeta title={course ? `Add Unit – ${course.title} - STARR` : undefined} />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
|
||||
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/courses/${courseId}/units`)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<div className="w-full max-w-2xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => (step === 0 ? navigate(`/admin/courses/${courseId}/units`) : setStep(0))}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Create Unit</h1>
|
||||
@@ -67,41 +96,105 @@ export default function AddUnit() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||
{/* Stepper */}
|
||||
<div className="flex items-center gap-0">
|
||||
{STEPS.map((s, i) => {
|
||||
const Icon = s.icon;
|
||||
const isActive = step === i;
|
||||
const isDone = step > i;
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
|
||||
<Input id="title" placeholder="Unit title" {...register("title")} />
|
||||
<FieldError message={errors.title?.message} />
|
||||
return (
|
||||
<div key={s.id} className="flex items-center flex-1 last:flex-none">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className={cn(
|
||||
"h-8 w-8 rounded-full flex items-center justify-center border transition-colors",
|
||||
isDone && "bg-emerald-600 border-emerald-600 text-white",
|
||||
isActive && "border-primary bg-primary text-primary-foreground",
|
||||
!isActive && !isDone && "border-border bg-background text-muted-foreground"
|
||||
)}>
|
||||
{isDone ? <Check className="h-4 w-4" /> : <Icon className="h-4 w-4" />}
|
||||
</div>
|
||||
<span className={cn(
|
||||
"text-[11px] font-medium whitespace-nowrap hidden sm:block",
|
||||
isActive ? "text-foreground" : "text-muted-foreground",
|
||||
isDone ? "text-emerald-600" : ""
|
||||
)}>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
{i < STEPS.length - 1 && (
|
||||
<div className={cn(
|
||||
"flex-1 h-px mx-2 mb-4 transition-colors",
|
||||
step > i ? "bg-emerald-600" : "bg-border"
|
||||
)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||
{step === 0 && (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
|
||||
<Input id="title" placeholder="Unit title" {...register("title")} />
|
||||
<FieldError message={errors.title?.message} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 max-w-[120px]">
|
||||
<Label htmlFor="order">Order</Label>
|
||||
<Input id="order" type="number" min={0} {...register("order")} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure how learners complete this unit — optional, sensible defaults apply automatically. This is created together with the rest of the unit when you finish.
|
||||
</p>
|
||||
<DraftRequirementsEditor entityType="unit" items={requirements} onChange={setRequirements} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 max-w-[120px]">
|
||||
<Label htmlFor="order">Order</Label>
|
||||
<Input id="order" type="number" min={0} {...register("order")} />
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => (step === 0 ? navigate(`/admin/courses/${courseId}/units`) : setStep(0))}
|
||||
disabled={loading}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
{step === 0 ? "Cancel" : "Back"}
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(`/admin/courses/${courseId}/units`)} disabled={loading}>
|
||||
Cancel
|
||||
{step === 0 ? (
|
||||
<Button type="button" onClick={handleNext}>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
) : (
|
||||
// Only the true final step (Requirements) actually persists
|
||||
// anything — the unit and any draft requirements are created
|
||||
// together in one shot here.
|
||||
<Button type="button" onClick={handleCreate} disabled={loading}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Create Unit
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{unsavedChangesDialog}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||
import CompletionRequirementBuilder from "@/modules/admin/components/courses/CompletionRequirementBuilder";
|
||||
|
||||
const schema = z.object({
|
||||
title: z.string().min(1, "Title is required."),
|
||||
@@ -29,7 +30,7 @@ function FieldError({ message }) {
|
||||
export default function EditUnit() {
|
||||
const [unitTitle, setUnitTitle] = useState("");
|
||||
const { courseId, unitId } = useParams();
|
||||
const { fetchUnit, updateUnit, course, loading } = useCourses();
|
||||
const { fetchUnit, updateUnit, course, loading, fetchUnitRequirements, syncUnitRequirements } = useCourses();
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -110,6 +111,19 @@ export default function EditUnit() {
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="rounded-lg border bg-card p-6 space-y-4 mt-6">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">Completion Requirements</h2>
|
||||
<p className="text-xs text-muted-foreground">What a learner must do for this unit to count as complete.</p>
|
||||
</div>
|
||||
<CompletionRequirementBuilder
|
||||
entityType="unit"
|
||||
fetchFn={fetchUnitRequirements}
|
||||
syncFn={syncUnitRequirements}
|
||||
args={[courseId, unitId]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -524,15 +524,17 @@ export default function ModifyQuiz() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id="quiz_required"
|
||||
checked={isRequired === true}
|
||||
onCheckedChange={(val) => setIsRequired(val)}
|
||||
/>
|
||||
<Label htmlFor="quiz_required" className="cursor-pointer">
|
||||
Required to proceed to next unit
|
||||
</Label>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox id="quiz_required" checked={isRequired === true} disabled />
|
||||
<Label htmlFor="quiz_required" className="text-muted-foreground">
|
||||
Required to proceed to next unit
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground pl-7">
|
||||
Derived from this unit's Completion Requirements — add or remove a "Pass the Quiz"
|
||||
requirement on the unit's Edit page to change this.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -6,10 +6,12 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import {
|
||||
ArrowLeft, ChevronLeft, ChevronRight, Check,
|
||||
FileText, LayoutTemplate, ClipboardCheck,
|
||||
FileText, LayoutTemplate, ClipboardCheck, ListChecks,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useLibrary } from "@/contexts/AdminLibraryContext";
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import DraftRequirementsEditor, { DraftRequirementsSummary } from "@/modules/admin/components/courses/DraftRequirementsEditor";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -42,7 +44,8 @@ const DEFAULT_VALUES = { title: "", description: "", blocks: [] };
|
||||
const STEPS = [
|
||||
{ id: 0, label: "Lesson", icon: FileText },
|
||||
{ id: 1, label: "Page Builder", icon: LayoutTemplate },
|
||||
{ id: 2, label: "Review", icon: ClipboardCheck },
|
||||
{ id: 2, label: "Requirements", icon: ListChecks },
|
||||
{ id: 3, label: "Review", icon: ClipboardCheck },
|
||||
];
|
||||
|
||||
// Fields validated with trigger() before advancing past each step.
|
||||
@@ -157,7 +160,19 @@ function StepPageBuilder({ control, setValue, getValues }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step 3 — Review ─────────────────────────────────────────────────────────────
|
||||
// ─── Step 3 — Requirements ───────────────────────────────────────────────────────
|
||||
function StepRequirements({ requirements, setRequirements, blockTypes }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure how learners complete this lesson — optional, sensible defaults apply automatically. This is created together with the rest of the lesson when you finish.
|
||||
</p>
|
||||
<DraftRequirementsEditor entityType="lesson" items={requirements} onChange={setRequirements} blockTypes={blockTypes} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step 4 — Review ─────────────────────────────────────────────────────────────
|
||||
function SummaryRow({ label, value }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
@@ -168,7 +183,7 @@ function SummaryRow({ label, value }) {
|
||||
);
|
||||
}
|
||||
|
||||
function StepReview({ data, attachUnitId }) {
|
||||
function StepReview({ data, attachUnitId, requirements }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="border border-border rounded-lg p-4 space-y-1">
|
||||
@@ -181,6 +196,8 @@ function StepReview({ data, attachUnitId }) {
|
||||
<SummaryRow label="Page content" value={`${(data.blocks ?? []).length} block(s)`} />
|
||||
{attachUnitId && <SummaryRow label="Attaches to" value="The unit you came from" />}
|
||||
</div>
|
||||
|
||||
<DraftRequirementsSummary entityType="lesson" items={requirements} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -190,12 +207,14 @@ export default function AddLibraryLesson() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { createLesson, saveLessonPage, loading } = useLibrary();
|
||||
const { syncLessonRequirements } = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
// ?unit_id=… → create-and-attach in one call (from the unit lessons manager)
|
||||
const attachUnitId = searchParams.get("unit_id");
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
const [requirements, setRequirements] = useState([]);
|
||||
|
||||
const {
|
||||
register, control, trigger, getValues, setValue,
|
||||
@@ -206,7 +225,7 @@ export default function AddLibraryLesson() {
|
||||
mode: "onTouched",
|
||||
});
|
||||
|
||||
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
|
||||
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty || requirements.length > 0);
|
||||
|
||||
const handleNext = async () => {
|
||||
const fields = STEP_FIELDS[step];
|
||||
@@ -219,8 +238,9 @@ export default function AddLibraryLesson() {
|
||||
else setStep((s) => s - 1);
|
||||
};
|
||||
|
||||
// Called manually on click — no <form> tag, so no accidental submit from
|
||||
// Enter or another button while stepping through the wizard.
|
||||
// The one and only persistence point — nothing is created until this final
|
||||
// click at Review, so Requirements (the step before it) is purely a draft
|
||||
// form. No <form> tag wraps the wizard, so this is invoked manually.
|
||||
const handleCreate = async () => {
|
||||
const valid = await trigger();
|
||||
if (!valid) return;
|
||||
@@ -235,10 +255,17 @@ export default function AddLibraryLesson() {
|
||||
if (!result) return;
|
||||
|
||||
const lessonId = result?.data?.data?.lesson_id;
|
||||
if (lessonId && (data.blocks ?? []).length > 0) {
|
||||
if (!lessonId) return;
|
||||
|
||||
if ((data.blocks ?? []).length > 0) {
|
||||
await saveLessonPage(lessonId, { blocks: data.blocks, updatedBy: user?.user_id });
|
||||
}
|
||||
|
||||
if (requirements.length > 0) {
|
||||
const clean = requirements.map(({ _key, ...r }) => r);
|
||||
await syncLessonRequirements(null, null, lessonId, clean);
|
||||
}
|
||||
|
||||
bypassOnce();
|
||||
navigate(attachUnitId ? `/admin/units/${attachUnitId}/view` : "/admin/lessons");
|
||||
};
|
||||
@@ -311,7 +338,14 @@ export default function AddLibraryLesson() {
|
||||
<StepPageBuilder control={control} setValue={setValue} getValues={getValues} />
|
||||
)}
|
||||
{step === 2 && (
|
||||
<StepReview data={getValues()} attachUnitId={attachUnitId} />
|
||||
<StepRequirements
|
||||
requirements={requirements}
|
||||
setRequirements={setRequirements}
|
||||
blockTypes={(getValues("blocks") ?? []).map((b) => b.type)}
|
||||
/>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<StepReview data={getValues()} attachUnitId={attachUnitId} requirements={requirements} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -328,6 +362,9 @@ export default function AddLibraryLesson() {
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
) : (
|
||||
// Only the true final step (Review) actually persists anything —
|
||||
// the lesson, its page content, and any draft requirements are
|
||||
// all created together in one shot here.
|
||||
<Button type="button" onClick={handleCreate} disabled={loading}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Create Lesson
|
||||
|
||||
@@ -6,6 +6,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
import { useLibrary } from "@/contexts/AdminLibraryContext";
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -14,6 +15,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||
import CompletionRequirementBuilder from "@/modules/admin/components/courses/CompletionRequirementBuilder";
|
||||
|
||||
const schema = z.object({
|
||||
title: z.string().min(1, "Title is required."),
|
||||
@@ -29,6 +31,7 @@ export default function EditLibraryLesson() {
|
||||
const navigate = useNavigate();
|
||||
const { lessonId } = useParams();
|
||||
const { fetchLesson, updateLesson, lesson, loading } = useLibrary();
|
||||
const { fetchLessonRequirements, syncLessonRequirements } = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { register, handleSubmit, reset, formState: { errors, isDirty } } = useForm({
|
||||
@@ -99,6 +102,19 @@ export default function EditLibraryLesson() {
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="rounded-lg border bg-card p-6 space-y-4 mt-6">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">Completion Requirements</h2>
|
||||
<p className="text-xs text-muted-foreground">What a learner must do for this lesson to count as complete, wherever it's attached.</p>
|
||||
</div>
|
||||
<CompletionRequirementBuilder
|
||||
entityType="lesson"
|
||||
fetchFn={fetchLessonRequirements}
|
||||
syncFn={syncLessonRequirements}
|
||||
args={[null, null, lessonId]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams, Link } from "react-router-dom";
|
||||
import {
|
||||
House, Pencil, LayoutTemplate, FileText, Clock, BookCheck,
|
||||
House, Pencil, LayoutTemplate, Clock, BookCheck,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useLibrary } from "@/contexts/AdminLibraryContext";
|
||||
@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { formatDuration } from "@/utils/timestamp.util";
|
||||
import { PreviewChrome, PreviewContent } from "../../../components/courses/LessonsPreview";
|
||||
|
||||
export default function ViewLibraryLesson() {
|
||||
const navigate = useNavigate();
|
||||
@@ -67,9 +68,6 @@ export default function ViewLibraryLesson() {
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/lessons/${lessonId}/edit`)}>
|
||||
<Pencil className="h-3.5 w-3.5 mr-1.5" /> Edit
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/lessons/${lessonId}/page/view`)}>
|
||||
<FileText className="h-3.5 w-3.5 mr-1.5" /> View Page
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => navigate(`/admin/lessons/${lessonId}/page`)}>
|
||||
<LayoutTemplate className="h-3.5 w-3.5 mr-1.5" /> Page Builder
|
||||
</Button>
|
||||
@@ -127,6 +125,33 @@ export default function ViewLibraryLesson() {
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Content preview ── */}
|
||||
<div className="space-y-2">
|
||||
<h2 className="font-semibold px-1">Content</h2>
|
||||
<PreviewChrome title={lesson?.title}>
|
||||
<div className="p-3 sm:p-5 min-h-[200px]">
|
||||
<PreviewContent
|
||||
lesson={lesson}
|
||||
blocks={blocks}
|
||||
showHeader={false}
|
||||
empty="No content blocks yet."
|
||||
/>
|
||||
{blocks.length === 0 && (
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/admin/lessons/${lessonId}/page`)}
|
||||
>
|
||||
Go to Page Builder
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PreviewChrome>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -6,11 +6,13 @@ import { z } from "zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import {
|
||||
ArrowLeft, ChevronLeft, ChevronRight, Check,
|
||||
FileText, BookOpen, LayoutTemplate, ClipboardCheck,
|
||||
FileText, BookOpen, LayoutTemplate, ClipboardCheck, ListChecks,
|
||||
Plus, Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useLibrary } from "@/contexts/AdminLibraryContext";
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import DraftRequirementsEditor, { DraftRequirementsSummary } from "@/modules/admin/components/courses/DraftRequirementsEditor";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import api from "@/utils/api.util";
|
||||
@@ -46,6 +48,7 @@ const lessonSchema = z.object({
|
||||
z.object({ value: z.string().min(1, "Objective cannot be empty.") })
|
||||
).optional(),
|
||||
blocks: z.array(z.any()).optional(),
|
||||
requirements: z.array(z.any()).optional(),
|
||||
});
|
||||
|
||||
const schema = z.object({
|
||||
@@ -66,7 +69,8 @@ const STEPS = [
|
||||
{ id: 0, label: "Create Unit", icon: FileText },
|
||||
{ id: 1, label: "Lessons", icon: BookOpen },
|
||||
{ id: 2, label: "Page Builder", icon: LayoutTemplate },
|
||||
{ id: 3, label: "Review", icon: ClipboardCheck },
|
||||
{ id: 3, label: "Requirements", icon: ListChecks },
|
||||
{ id: 4, label: "Review", icon: ClipboardCheck },
|
||||
];
|
||||
|
||||
// Fields validated with trigger() before advancing past each step.
|
||||
@@ -194,7 +198,7 @@ function StepLessons({ control, register, errors }) {
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => append({ title: "", description: "", objectives: [], blocks: [] })}
|
||||
onClick={() => append({ title: "", description: "", objectives: [], blocks: [], requirements: [] })}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" /> Add Lesson
|
||||
</Button>
|
||||
@@ -239,6 +243,9 @@ function StepPageBuilder({ control, setValue }) {
|
||||
return next;
|
||||
});
|
||||
|
||||
const requirements = activeLesson?.requirements ?? [];
|
||||
const setRequirements = (next) => setValue(`lessons.${activeIndex}.requirements`, next, { shouldDirty: true });
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{lessons.map((l, i) => (
|
||||
@@ -247,6 +254,7 @@ function StepPageBuilder({ control, setValue }) {
|
||||
<p className="text-sm font-medium">{l.title || `Lesson ${i + 1}`}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(l.blocks?.length ?? 0)} block{(l.blocks?.length ?? 0) !== 1 ? "s" : ""}
|
||||
{(l.requirements?.length ?? 0) > 0 && ` · ${l.requirements.length} requirement${l.requirements.length !== 1 ? "s" : ""}`}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -313,6 +321,21 @@ function StepPageBuilder({ control, setValue }) {
|
||||
</PreviewChrome>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Completion Requirements — {activeLesson?.title || `Lesson ${activeIndex + 1}`}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
What this specific lesson's blocks unlock — e.g. a video block above makes "Finish Watching the Full Video" available.
|
||||
</p>
|
||||
</div>
|
||||
<DraftRequirementsEditor
|
||||
entityType="lesson"
|
||||
items={requirements}
|
||||
onChange={setRequirements}
|
||||
blockTypes={blocks.map((b) => b.type)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DrawerFooter className="border-t flex-row justify-end">
|
||||
@@ -337,7 +360,7 @@ function SummaryRow({ label, value }) {
|
||||
);
|
||||
}
|
||||
|
||||
function StepReview({ data, tierCategories }) {
|
||||
function StepReview({ data, tierCategories, requirements }) {
|
||||
const tierName = tierCategories.find((c) => c.slug === data.subscription)?.name;
|
||||
|
||||
return (
|
||||
@@ -364,12 +387,26 @@ function StepReview({ data, tierCategories }) {
|
||||
<div key={i} className="flex items-center justify-between text-sm border-t border-border pt-2 first:border-t-0 first:pt-0">
|
||||
<span>{i + 1}. {l.title}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{(l.objectives ?? []).filter((o) => o.value).length} objective(s) · {(l.blocks ?? []).length} block(s)
|
||||
{(l.objectives ?? []).filter((o) => o.value).length} objective(s) · {(l.blocks ?? []).length} block(s) · {(l.requirements ?? []).length} requirement(s)
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DraftRequirementsSummary entityType="unit" items={requirements} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step 5 — Requirements ──────────────────────────────────────────────────────
|
||||
function StepRequirements({ requirements, setRequirements }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure how learners complete this unit — optional, sensible defaults apply automatically. This is created together with the rest of the unit when you finish.
|
||||
</p>
|
||||
<DraftRequirementsEditor entityType="unit" items={requirements} onChange={setRequirements} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -380,7 +417,10 @@ export default function AddLibraryUnit() {
|
||||
const { createUnitFull, loading } = useLibrary();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { syncUnitRequirements } = useCourses();
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
const [requirements, setRequirements] = useState([]);
|
||||
const [tierCategories, setTierCategories] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -398,7 +438,7 @@ export default function AddLibraryUnit() {
|
||||
mode: "onTouched",
|
||||
});
|
||||
|
||||
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
|
||||
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty || requirements.length > 0);
|
||||
|
||||
const handleNext = async () => {
|
||||
const fields = STEP_FIELDS[step];
|
||||
@@ -411,8 +451,9 @@ export default function AddLibraryUnit() {
|
||||
else setStep((s) => s - 1);
|
||||
};
|
||||
|
||||
// Called manually on click — no <form> tag, so no accidental submit from
|
||||
// Enter or another button while stepping through the wizard.
|
||||
// The one and only persistence point — nothing is created until this final
|
||||
// click at Review, so Requirements (the step before it) is purely a draft
|
||||
// form. No <form> tag wraps the wizard, so this is invoked manually.
|
||||
const handleCreate = async () => {
|
||||
const valid = await trigger();
|
||||
if (!valid) return;
|
||||
@@ -427,6 +468,7 @@ export default function AddLibraryUnit() {
|
||||
description: l.description || null,
|
||||
objectives: (l.objectives ?? []).map((o) => o.value).filter(Boolean),
|
||||
blocks: l.blocks ?? [],
|
||||
requirements: (l.requirements ?? []).map(({ _key, ...r }) => r),
|
||||
})),
|
||||
createdBy: user?.user_id,
|
||||
};
|
||||
@@ -434,7 +476,14 @@ export default function AddLibraryUnit() {
|
||||
// Single request creates the unit, its lessons, objectives, and page
|
||||
// content in one transaction — no per-lesson/per-page follow-up calls.
|
||||
const result = await createUnitFull(payload);
|
||||
if (!result) return;
|
||||
const newUnitId = result?.data?.data?.unit_id;
|
||||
if (!newUnitId) return;
|
||||
|
||||
if (requirements.length > 0) {
|
||||
const clean = requirements.map(({ _key, ...r }) => r);
|
||||
await syncUnitRequirements(null, newUnitId, clean);
|
||||
}
|
||||
|
||||
bypassOnce();
|
||||
navigate("/admin/units");
|
||||
};
|
||||
@@ -514,7 +563,10 @@ export default function AddLibraryUnit() {
|
||||
<StepPageBuilder control={control} setValue={setValue} />
|
||||
)}
|
||||
{step === 3 && (
|
||||
<StepReview data={getValues()} tierCategories={tierCategories} />
|
||||
<StepRequirements requirements={requirements} setRequirements={setRequirements} />
|
||||
)}
|
||||
{step === 4 && (
|
||||
<StepReview data={getValues()} tierCategories={tierCategories} requirements={requirements} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -531,6 +583,9 @@ export default function AddLibraryUnit() {
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
) : (
|
||||
// Only the true final step (Review) actually persists anything —
|
||||
// the unit, its lessons/page content, and any draft requirements
|
||||
// are all created together in one shot here.
|
||||
<Button type="button" onClick={handleCreate} disabled={loading}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Create Unit
|
||||
|
||||
@@ -6,9 +6,11 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
import { useLibrary } from "@/contexts/AdminLibraryContext";
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import api from "@/utils/api.util";
|
||||
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";
|
||||
@@ -34,6 +36,7 @@ export default function EditLibraryUnit() {
|
||||
const navigate = useNavigate();
|
||||
const { unitId } = useParams();
|
||||
const { fetchUnit, updateUnit, unit, loading } = useLibrary();
|
||||
const { fetchUnitRequirements, syncUnitRequirements } = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [tierCategories, setTierCategories] = useState([]);
|
||||
@@ -136,6 +139,19 @@ export default function EditLibraryUnit() {
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="rounded-lg border bg-card p-6 space-y-4 mt-6">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">Completion Requirements</h2>
|
||||
<p className="text-xs text-muted-foreground">What a learner must do for this unit to count as complete, wherever it's attached.</p>
|
||||
</div>
|
||||
<CompletionRequirementBuilder
|
||||
entityType="unit"
|
||||
fetchFn={fetchUnitRequirements}
|
||||
syncFn={syncUnitRequirements}
|
||||
args={[null, unitId]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user