Files
starr-philproperties/src/modules/client/pages/ViewRequirement.jsx
T
kennethobsequio bac7168b1e push
pushy

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-06-28 11:30:01 +08:00

779 lines
42 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* ViewRequirement.jsx
* Route: /group/:groupId/view/:taskListId/task/:taskId/requirement
*
* Sidebar: sectioned flat list — one plain heading per requirement type,
* each requirement is a clickable button. When selected, its lesson
* sub-tree renders inline below the item (no accordion, no scoping).
*
* read_course → CourseUnitLessonView (on-demand lesson fetch)
* read_unit → LessonBlock (lessons from unitLessonsMap)
* read_lesson → LessonView (standalone fetch by uuid)
*/
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import AppBreadcrumb from '@/components/generic/Breadcrumb/AppBreadcrumb';
import {
House, TableOfContents, CheckCheck, Circle,
Layers, ArrowLeft, Lock, Zap, RefreshCw, Tag,
ClipboardList, GraduationCap, CheckCircle2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { Skeleton } from '@/components/ui/skeleton';
import { useTask } from '@/contexts/ClientTaskContext';
import { PageMeta } from '@/contexts/MetadataContext';
import { useTaskProgress } from '@/contexts/ClientTaskProgressContext';
import LessonBlock from '@/modules/client/components/LessonBlock';
import api from '@/utils/api.util';
import { useClientTiers } from '@/contexts/ClientTiersProvider';
import { resolveTierBadge } from '@/utils/tierBadge.util';
// ─── Constants ────────────────────────────────────────────────────────────────
const TYPE_LABEL = {
read_course: 'Read a Course',
read_unit: 'Read a Unit',
read_lesson: 'Read a Lesson',
};
// ─── Compact tier badge ───────────────────────────────────────────────────────
const TierBadge = ({ tier }) => {
const { tierMap } = useClientTiers();
if (!tier) return null;
const { label, cls } = resolveTierBadge(tier, tierMap);
return (
<Badge className={`${cls} text-[10px] px-1.5 py-0 h-[18px] leading-none shrink-0`}>
{label}
</Badge>
);
};
// ─── Sidebar ──────────────────────────────────────────────────────────────────
const SidebarContent = ({
requirements,
selection,
onSelectReq,
onSelectLesson,
isCompleted,
unitLessonsMap,
unitLoadingMap,
courseUnitsMap,
courseLoadingMap,
referenceMetaMap,
onNavigateToCourse,
}) => {
const groups = ['read_course', 'read_unit', 'read_lesson']
.map((type) => ({ type, items: requirements.filter((r) => r.type === type) }))
.filter((g) => g.items.length > 0);
return (
<ScrollArea className="h-full">
<div className="p-4 space-y-6">
<div className="flex items-center justify-between gap-2 pr-10 lg:pr-0">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground shrink-0">
Requirements
</p>
{groups.length === 1 && (
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/60 bg-muted px-2 py-0.5 rounded-full shrink-0">
{TYPE_LABEL[groups[0].type]}
</span>
)}
</div>
{groups.map(({ type, items }) => (
<div key={type} className="space-y-1">
{/* Section heading — hidden when only one type is visible (entry-scoped) */}
{groups.length > 1 && (
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground px-1 mb-2">
{TYPE_LABEL[type]}
</p>
)}
{items.map((req) => {
const isSelected = selection?.reqId === req.requirement_id;
const done = isCompleted(req.requirement_id, req.reference_id);
const meta = referenceMetaMap[req.requirement_id];
return (
<div key={req.requirement_id}>
{/* Requirement button */}
<button
onClick={() => onSelectReq(req)}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors text-left ${
isSelected
? 'bg-muted text-foreground'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
}`}
>
{done
? <CheckCheck className="size-3.5 text-green-500 shrink-0" />
: <Circle className="size-3.5 shrink-0 opacity-40" />
}
<span className="truncate flex-1 font-medium min-w-0">
{req.reference_label ?? req.type}
</span>
<TierBadge tier={meta?.subscription} />
</button>
{/* Breadcrumb — always visible below the button */}
{type === 'read_unit' && meta?.courseTitle && (
<p className="pl-9 text-[11px] text-muted-foreground truncate -mt-0.5 mb-1">
from <span className="font-medium">{meta.courseTitle}</span>
</p>
)}
{type === 'read_lesson' && (meta?.unitTitle || meta?.courseTitle) && (
<p className="pl-9 text-[11px] text-muted-foreground truncate -mt-0.5 mb-1">
{[meta.unitTitle, meta.courseTitle].filter(Boolean).join(' › ')}
</p>
)}
{/* Sub-tree — only for the selected item */}
{isSelected && type === 'read_unit' && (() => {
const lessons = unitLessonsMap[req.requirement_id]?.lessons ?? [];
const loading = unitLoadingMap[req.requirement_id];
return (
<div className="pl-5 mt-1 space-y-0.5 border-l ml-4 mb-2">
{loading && (
<div className="py-1 space-y-1.5">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-3 w-24" />
</div>
)}
{lessons.map((lesson, i) => {
const active = selection?.lessonUuid === lesson.uuid;
return (
<button
key={lesson.uuid}
onClick={() => onSelectLesson(req, lesson)}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-left transition-colors ${
active
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
<span className="text-xs tabular-nums w-4 shrink-0 opacity-40">{i + 1}</span>
<span className="truncate">{lesson.title}</span>
</button>
);
})}
</div>
);
})()}
{isSelected && type === 'read_course' && (() => {
const courseData = courseUnitsMap[req.requirement_id] ?? {};
const units = courseData.units ?? [];
const assessment = courseData.assessment ?? null;
const courseId = courseData.course_id;
const loading = courseLoadingMap[req.requirement_id];
return (
<div className="pl-5 mt-1 space-y-3 border-l ml-4 mb-2">
{loading && (
<div className="py-1 space-y-1.5">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-3 w-24" />
</div>
)}
{units.map((unit) => (
<div key={unit.unit_id} className="space-y-0.5">
<p className="flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-semibold text-muted-foreground uppercase tracking-wide truncate">
<Layers className="size-3 shrink-0 opacity-50" />
{unit.title}
</p>
{(unit.lessons ?? []).map((lesson, lIdx) => {
const active = selection?.lessonUuid === lesson.uuid;
return (
<button
key={lesson.uuid}
onClick={() => onSelectLesson(req, lesson)}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-left transition-colors ${
active
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
<span className="text-xs tabular-nums w-4 shrink-0 opacity-40">{lIdx + 1}</span>
<span className="truncate">{lesson.title}</span>
</button>
);
})}
{unit.quiz && (
<button
onClick={() => onNavigateToCourse(courseId, { quizUnitId: String(unit.unit_id) })}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-left transition-colors text-muted-foreground hover:bg-muted hover:text-foreground"
>
<ClipboardList className="size-3.5 shrink-0 opacity-50" />
<span className="truncate flex-1">{unit.quiz.title || 'Quiz'}</span>
{unit.quiz.has_passed
? <CheckCircle2 className="size-3 text-green-500 shrink-0" />
: <Circle className="size-3 shrink-0 opacity-30" />
}
</button>
)}
</div>
))}
{assessment && (
<button
onClick={() => onNavigateToCourse(courseId, { seekAssessment: true })}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-left transition-colors text-muted-foreground hover:bg-muted hover:text-foreground border-t pt-2 mt-1"
>
<GraduationCap className="size-3.5 shrink-0 opacity-50" />
<span className="truncate flex-1">{assessment.title || 'Final Assessment'}</span>
{assessment.has_passed
? <CheckCircle2 className="size-3 text-green-500 shrink-0" />
: <Circle className="size-3 shrink-0 opacity-30" />
}
</button>
)}
</div>
);
})()}
</div>
);
})}
</div>
))}
</div>
</ScrollArea>
);
};
// ─── Course overview (before first lesson selected) ───────────────────────────
const CourseOverview = ({ meta }) => {
const { tierMap } = useClientTiers();
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2 flex-wrap">
{meta?.subscription && (() => {
const { rank, label, cls } = resolveTierBadge(meta.subscription, tierMap);
return (
<Badge className={`${cls} capitalize`}>
{rank > 0 ? <Lock className="size-3 mr-1" /> : <Tag className="size-3 mr-1" />}
{label}
</Badge>
);
})()}
{meta?.level && <Badge variant="outline" className="text-xs capitalize">{meta.level}</Badge>}
</div>
<h1 className="text-2xl font-bold">{meta?.title ?? 'Course'}</h1>
{meta?.description && <p className="text-sm text-muted-foreground leading-relaxed">{meta.description}</p>}
<p className="text-sm text-blue-500 dark:text-blue-400 mt-2">Select a lesson from the sidebar to begin reading.</p>
</div>
);
};
// ─── On-demand lesson fetch for read_course ───────────────────────────────────
const CourseUnitLessonView = ({ lessonUuid }) => {
const [lesson, setLesson] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!lessonUuid) return;
setLoading(true);
setLesson(null);
api.get(`/client/courses/lesson/uuid/${lessonUuid}`)
.then((r) => {
const d = r.data?.data;
if (d) setLesson({ ...d, blocks: d.blocks ?? [] });
})
.catch(() => {})
.finally(() => setLoading(false));
}, [lessonUuid]);
if (loading) return <ContentSkeleton />;
return <LessonBlock lesson={lesson} loading={false} />;
};
// ─── Standalone lesson view (read_lesson) ─────────────────────────────────────
const LessonView = ({ req, onMeta }) => {
const [lesson, setLesson] = useState(null);
const [loading, setLoading] = useState(true);
const [locked, setLocked] = useState(false);
useEffect(() => {
if (!req.reference_id) { setLoading(false); return; }
api.get(`/client/courses/lesson/uuid/${req.reference_id}`)
.then((r) => {
const d = r.data?.data;
if (d) {
setLesson({ ...d, blocks: d.blocks ?? [] });
onMeta?.(req.requirement_id, {
subscription: d.unit?.course?.subscription,
courseTitle: d.unit?.course?.title,
unitTitle: d.unit?.title,
});
}
})
.catch((err) => { if (err?.response?.status === 403) setLocked(true); })
.finally(() => setLoading(false));
}, [req.reference_id]);
if (loading) return <ContentSkeleton />;
if (locked) return <LockedContent />;
return <LessonBlock lesson={lesson} loading={false} />;
};
// ─── Skeletons / locked ───────────────────────────────────────────────────────
const ContentSkeleton = () => (
<div className="flex flex-col gap-3">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-7 w-64" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
);
const LockedContent = () => {
const navigate = useNavigate();
return (
<div className="flex flex-col items-center gap-6 py-20 text-center">
<div className="h-16 w-16 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<Lock className="size-7 text-amber-500" />
</div>
<div className="space-y-2 max-w-sm">
<h2 className="text-lg font-semibold">Premium / Exclusive Content</h2>
<p className="text-sm text-muted-foreground leading-relaxed">
Subscribe to one of our available tier plans to unlock access to this content.
</p>
</div>
<div className="flex flex-col items-center gap-2">
<Button onClick={() => navigate('/plans')} className="gap-1.5">
<Zap className="size-4" /> View Available Plans
</Button>
<p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this tier.</p>
</div>
</div>
);
};
// ─── Main page ────────────────────────────────────────────────────────────────
const ViewRequirement = () => {
const { groupId, taskListId, taskId } = useParams();
const navigate = useNavigate();
const location = useLocation();
const { task, taskList, loading, fetchTask, fetchTaskList } = useTask();
const { fetchProgress, isCompleted, updateLessonProgress, loading: progressLoading } = useTaskProgress();
const [selection, setSelection] = useState(null);
const [unitLessonsMap, setUnitLessonsMap] = useState({});
const [unitLoadingMap, setUnitLoadingMap] = useState({});
const [courseUnitsMap, setCourseUnitsMap] = useState({});
const [courseLoadingMap, setCourseLoadingMap] = useState({});
const [referenceMetaMap, setReferenceMetaMap] = useState({});
const [lockedReqs, setLockedReqs] = useState(new Set());
const [sidebarOpen, setSidebarOpen] = useState(false);
const [desktopOpen, setDesktopOpen] = useState(true);
const [scrollPct, setScrollPct] = useState(0);
const initialised = useRef(false);
const lastAutoMarkRef = useRef(null);
// ── Fetch task + progress ─────────────────────────────────────────────────
useEffect(() => {
fetchTaskList(groupId, taskListId);
fetchTask(groupId, taskListId, taskId);
fetchProgress(groupId, taskListId, taskId);
}, [groupId, taskListId, taskId]);
// ── read_* requirements only ─────────────────────────────────────────────
const requirements = (task?.requirements ?? []).filter((r) =>
['read_course', 'read_unit', 'read_lesson'].includes(r.type)
);
// ── Scope sidebar to the type that was clicked in ViewTaskDetails ─────────
const entryType = useMemo(() => {
const s = location.state ?? {};
if (s.course) return 'read_course';
if (s.unit) return 'read_unit';
if (s.lesson) return 'read_lesson';
return null;
}, [location.state]);
const sidebarRequirements = entryType
? requirements.filter((r) => r.type === entryType)
: requirements;
// ── Fetch lessons for read_unit requirements ──────────────────────────────
useEffect(() => {
requirements.forEach((req) => {
if (req.type !== 'read_unit') return;
if (unitLessonsMap[req.requirement_id] || unitLoadingMap[req.requirement_id]) return;
setUnitLoadingMap((p) => ({ ...p, [req.requirement_id]: true }));
api.get(`/client/courses/unit/uuid/${req.reference_id}/lessons`)
.then((r) => {
const data = r.data?.data ?? null;
setUnitLessonsMap((p) => ({ ...p, [req.requirement_id]: { meta: data, lessons: data?.lessons ?? [] } }));
})
.catch((err) => {
if (err?.response?.status === 403)
setLockedReqs((p) => new Set(p).add(req.requirement_id));
})
.finally(() => setUnitLoadingMap((p) => ({ ...p, [req.requirement_id]: false })));
});
}, [requirements.length]);
// ── Fetch unit + lesson tree for read_course requirements ─────────────────
useEffect(() => {
requirements.forEach(async (req) => {
if (req.type !== 'read_course') return;
if (courseUnitsMap[req.requirement_id] || courseLoadingMap[req.requirement_id]) return;
setCourseLoadingMap((p) => ({ ...p, [req.requirement_id]: true }));
try {
const uuidRes = await api.get(`/client/courses/uuid/${req.reference_id}`);
const meta = uuidRes.data?.data;
if (!meta?.course_id) return;
const fullRes = await api.get(`/client/courses/${meta.course_id}`);
const full = fullRes.data?.data;
setCourseUnitsMap((p) => ({
...p,
[req.requirement_id]: {
meta: { ...meta, description: full?.description ?? meta.description },
units: full?.units ?? [],
assessment: full?.assessment ?? null,
course_id: meta.course_id,
},
}));
setReferenceMetaMap((p) => ({ ...p, [req.requirement_id]: { subscription: meta.subscription } }));
} catch (err) {
if (err?.response?.status === 403)
setLockedReqs((p) => new Set(p).add(req.requirement_id));
} finally {
setCourseLoadingMap((p) => ({ ...p, [req.requirement_id]: false }));
}
});
}, [requirements.length]);
// ── Tier meta for read_unit from unitLessonsMap ───────────────────────────
useEffect(() => {
requirements.forEach((req) => {
if (req.type !== 'read_unit') return;
const data = unitLessonsMap[req.requirement_id];
if (!data?.meta?.course || referenceMetaMap[req.requirement_id]) return;
setReferenceMetaMap((p) => ({
...p,
[req.requirement_id]: {
subscription: data.meta.course.subscription,
courseTitle: data.meta.course.title,
},
}));
});
}, [unitLessonsMap]);
// ── Tier meta for read_lesson (separate fetch) ────────────────────────────
useEffect(() => {
requirements.forEach(async (req) => {
if (req.type !== 'read_lesson' || referenceMetaMap[req.requirement_id]) return;
try {
const res = await api.get(`/client/courses/lesson/uuid/${req.reference_id}`);
const d = res.data?.data;
if (d) setReferenceMetaMap((p) => ({
...p,
[req.requirement_id]: {
subscription: d.unit?.course?.subscription,
courseTitle: d.unit?.course?.title,
unitTitle: d.unit?.title,
},
}));
} catch { /* silently fail */ }
});
}, [requirements.length]);
// ── Auto-select from router state or first item ───────────────────────────
useEffect(() => {
if (initialised.current || !requirements.length) return;
const state = location.state ?? {};
const trySelect = (key, type) => {
if (!state[key]?.id) return false;
const req = requirements.find((r) => r.requirement_id === state[key].id);
if (!req) return false;
const needsLesson = type === 'read_unit' || type === 'read_course';
setSelection({ reqId: req.requirement_id, ...(needsLesson ? { lessonUuid: null } : {}) });
return true;
};
if (!trySelect('course', 'read_course') && !trySelect('unit', 'read_unit') && !trySelect('lesson', 'read_lesson')) {
const first = requirements[0];
const needsLesson = first.type === 'read_unit' || first.type === 'read_course';
setSelection({ reqId: first.requirement_id, ...(needsLesson ? { lessonUuid: null } : {}) });
}
initialised.current = true;
}, [requirements, location.state]);
// ── Auto-pick first lesson once unit/course lessons load ─────────────────
useEffect(() => {
if (!selection || selection.lessonUuid !== null) return;
const req = requirements.find((r) => r.requirement_id === selection.reqId);
if (req?.type === 'read_unit') {
const lessons = unitLessonsMap[req.requirement_id]?.lessons ?? [];
if (lessons.length) setSelection((p) => ({ ...p, lessonUuid: lessons[0].uuid }));
}
if (req?.type === 'read_course') {
const units = courseUnitsMap[req.requirement_id]?.units ?? [];
const first = units.flatMap((u) => u.lessons ?? [])[0];
if (first) setSelection((p) => ({ ...p, lessonUuid: first.uuid }));
}
}, [unitLessonsMap, courseUnitsMap, selection?.reqId]);
// ── Derived objects ───────────────────────────────────────────────────────
const selectedReq = requirements.find((r) => r.requirement_id === selection?.reqId) ?? null;
const selectedLesson = (() => {
if (!selectedReq || !selection?.lessonUuid) return null;
if (selectedReq.type === 'read_unit') {
return (unitLessonsMap[selectedReq.requirement_id]?.lessons ?? [])
.find((l) => l.uuid === selection.lessonUuid) ?? null;
}
if (selectedReq.type === 'read_course') {
const units = courseUnitsMap[selectedReq.requirement_id]?.units ?? [];
return units.flatMap((u) => u.lessons ?? []).find((l) => l.uuid === selection.lessonUuid) ?? null;
}
return null;
})();
// ── Next lesson (cross-unit for read_course) ──────────────────────────────
const nextLesson = (() => {
if (!selectedReq) return null;
if (selectedReq.type === 'read_unit') {
const lessons = unitLessonsMap[selectedReq.requirement_id]?.lessons ?? [];
const idx = lessons.findIndex((l) => l.uuid === selection?.lessonUuid);
return idx !== -1 && idx < lessons.length - 1 ? lessons[idx + 1] : null;
}
if (selectedReq.type === 'read_course') {
const flat = (courseUnitsMap[selectedReq.requirement_id]?.units ?? []).flatMap((u) => u.lessons ?? []);
const idx = flat.findIndex((l) => l.uuid === selection?.lessonUuid);
return idx !== -1 && idx < flat.length - 1 ? flat[idx + 1] : null;
}
return null;
})();
// ── Scroll tracking ───────────────────────────────────────────────────────
useEffect(() => {
window.scrollTo(0, 0);
const h = document.documentElement.scrollHeight - window.innerHeight;
setScrollPct(h <= 40 ? 100 : 0);
}, [selection]);
useEffect(() => {
const onScroll = () => {
const h = document.documentElement.scrollHeight - window.innerHeight;
const y = window.scrollY;
if (h <= 0 || h - y <= 40) { setScrollPct(100); return; }
setScrollPct(Math.min(99, Math.round((y / h) * 100)));
};
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
// ── Breadcrumbs ───────────────────────────────────────────────────────────
const crumbsFull = [
{ label: 'Home', icon: <House className="size-4" />, to: '/dashboard' },
{ label: taskList?.name ?? '…', to: `/group/${groupId}/view/${taskListId}` },
{ label: task?.name ?? '…', to: `/group/${groupId}/view/${taskListId}/task/${taskId}` },
{ label: 'Requirements' },
];
const crumbsMobile = [
{ label: 'Home', icon: <House className="size-4" />, to: '/dashboard' },
{ label: 'Requirements' },
];
// ── Handlers ─────────────────────────────────────────────────────────────
const handleSelectReq = useCallback((req) => {
const needsLesson = req.type === 'read_unit' || req.type === 'read_course';
setSelection({ reqId: req.requirement_id, ...(needsLesson ? { lessonUuid: null } : {}) });
setSidebarOpen(false);
}, []);
const handleSelectLesson = useCallback((req, lesson) => {
setSelection({ reqId: req.requirement_id, lessonUuid: lesson.uuid });
setSidebarOpen(false);
}, []);
const handleMarkDone = useCallback(async () => {
if (!selectedReq) return;
const completed = !isCompleted(selectedReq.requirement_id, selectedReq.reference_id);
await updateLessonProgress(groupId, taskListId, taskId, selectedReq.requirement_id, {
reference_id: selectedReq.reference_id,
completed,
siblingLessons: [],
});
}, [selectedReq, groupId, taskListId, taskId, isCompleted, updateLessonProgress]);
const selectedDone = selectedReq ? isCompleted(selectedReq.requirement_id, selectedReq.reference_id) : false;
const isLastContent = !nextLesson;
const canMarkDone = scrollPct >= 100 && isLastContent;
// ── Auto turn-in ──────────────────────────────────────────────────────────
useEffect(() => {
if (!canMarkDone || selectedDone || progressLoading || !selectedReq) return;
if (lockedReqs.has(selectedReq.requirement_id)) return;
const key = selectedReq.requirement_id + (selection?.lessonUuid ?? '');
if (lastAutoMarkRef.current === key) return;
lastAutoMarkRef.current = key;
handleMarkDone();
}, [canMarkDone, selectedDone]); // eslint-disable-line react-hooks/exhaustive-deps
const handleNavigateToCourse = useCallback((courseId, opts) => {
navigate(`/course/${courseId}/unit`, { state: opts });
}, [navigate]);
const sidebarProps = {
requirements: sidebarRequirements,
selection,
onSelectReq: handleSelectReq,
onSelectLesson: handleSelectLesson,
isCompleted,
unitLessonsMap,
unitLoadingMap,
courseUnitsMap,
courseLoadingMap,
referenceMetaMap,
onNavigateToCourse: handleNavigateToCourse,
};
return (
<>
<PageMeta title={task ? `${task.name} – Requirements - STARR` : undefined} />
{/* ── Floating "up next" ────────────────────────────────────────── */}
{scrollPct >= 100 && nextLesson && (
<div className="fixed bottom-6 right-6 z-20 animate-in fade-in slide-in-from-bottom-2 duration-300">
<div
onClick={() => handleSelectLesson(selectedReq, nextLesson)}
className="flex items-center gap-3 bg-card border rounded-xl dark:hover:border-blue-500 px-4 py-3 shadow-xl hover:bg-muted transition-colors text-sm font-medium cursor-pointer select-none"
>
<div className="flex flex-col items-start">
<span className="text-xs text-muted-foreground font-normal">Up next</span>
{nextLesson.title}
</div>
</div>
</div>
)}
{/* ── Top sticky bar ────────────────────────────────────────────── */}
<div className="fixed top-[67px] left-0 right-0 z-40 bg-card border-b py-3 px-4 md:px-6">
<div className="w-full flex items-center justify-between gap-2">
<Button
variant="ghost"
size="icon"
className="sm:hidden shrink-0"
onClick={() => navigate(`/group/${groupId}/view/${taskListId}/task/${taskId}`)}
>
<ArrowLeft className="size-4" />
</Button>
<div className="hidden sm:block min-w-0 flex-1">
<AppBreadcrumb items={crumbsFull} />
</div>
<div className="sm:hidden min-w-0 flex-1 truncate">
<AppBreadcrumb items={crumbsMobile} />
</div>
<div className="flex items-center gap-2">
{selectedDone && (
<span className="hidden sm:flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400">
<CheckCheck className="size-4" /> Done
</span>
)}
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
<SheetTrigger asChild>
<Button variant="outline" size="icon" className="lg:hidden flex-shrink-0">
<TableOfContents className="size-4" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="p-0 w-80">
<SidebarContent {...sidebarProps} />
</SheetContent>
</Sheet>
<Button
variant="outline"
className="hidden lg:flex"
onClick={() => setDesktopOpen((p) => !p)}
>
<TableOfContents />
</Button>
</div>
</div>
{selectedReq && (
<div className="absolute bottom-0 left-0 right-0 h-1.5 bg-border">
<div
className={`h-full transition-all duration-150 ease-out ${
scrollPct >= 100 ? 'bg-green-500' : 'bg-primary'
}`}
style={{ width: `${scrollPct}%` }}
/>
</div>
)}
</div>
{/* ── Desktop sidebar ───────────────────────────────────────────── */}
{desktopOpen && (
<div className="hidden lg:block fixed top-[124px] bottom-0 left-0 w-96 bg-muted/60 border-r">
<SidebarContent {...sidebarProps} />
</div>
)}
{/* ── Main content ──────────────────────────────────────────────── */}
<div className={`mt-32 ${desktopOpen ? 'lg:ml-96' : 'lg:ml-0'} p-4 md:p-6 min-h-screen`}>
<div className="relative w-full h-full max-w-3xl mx-auto">
{loading ? (
<ContentSkeleton />
) : !requirements.length ? (
<p className="text-muted-foreground text-sm">This task has no read requirements.</p>
) : !selectedReq ? (
<p className="text-muted-foreground text-sm">Select a requirement to view.</p>
) : (
<div className="flex flex-col gap-6">
{selectedReq.type === 'read_course' && (
lockedReqs.has(selectedReq.requirement_id) ? <LockedContent /> :
selection?.lessonUuid ? <CourseUnitLessonView lessonUuid={selection.lessonUuid} /> :
courseLoadingMap[selectedReq.requirement_id] ? <ContentSkeleton /> :
<CourseOverview meta={courseUnitsMap[selectedReq.requirement_id]?.meta} />
)}
{selectedReq.type === 'read_unit' && (
lockedReqs.has(selectedReq.requirement_id) ? <LockedContent /> :
selectedLesson ? <LessonBlock lesson={selectedLesson} loading={false} /> :
<ContentSkeleton />
)}
{selectedReq.type === 'read_lesson' && (
<LessonView
req={selectedReq}
onMeta={(reqId, meta) =>
setReferenceMetaMap((p) => ({ ...p, [reqId]: meta }))
}
/>
)}
{/* Turn-in footer — only shown while not yet completed */}
{!lockedReqs.has(selectedReq.requirement_id) && !selectedDone && (
<div className="flex items-center pt-4 border-t">
{nextLesson ? (
<p className="text-sm text-muted-foreground">Continue reading all lessons to complete this requirement.</p>
) : !canMarkDone ? (
<p className="text-sm text-muted-foreground">Scroll to the end to complete this requirement.</p>
) : (
<span className="flex items-center gap-1.5 text-sm text-muted-foreground">
<RefreshCw className="size-3.5 animate-spin" /> Turning in…
</span>
)}
</div>
)}
</div>
)}
</div>
</div>
</>
);
};
export default ViewRequirement;