Files
starr-philproperties/src/modules/client/pages/UnitDetails.jsx
T
rgrgogu 3c5432a32b bing
Signed-off-by: rgrgogu <obsequio.rus@gmail.com>
2026-08-13 20:00:06 +08:00

243 lines
12 KiB
React

import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useParams, useNavigate } from "react-router-dom";
import {
House, Timer, CheckCircle2, Check, CheckCheck, ClipboardList, Hourglass,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { motion } from "framer-motion";
import { useEffect } from "react";
import { useLibrary } from "@/contexts/ClientLibraryContext";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { PageMeta } from "@/contexts/MetadataContext";
import LockedContentPanel from "@/modules/client/components/LockedContentPanel";
import { TYPE_DEFS } from "@/modules/admin/components/courses/completionRequirementTypes";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function formatDuration(seconds = 0) {
if (!seconds) return null;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h}hr ${m}min`;
return `${m}min`;
}
// ─── Lessons roadmap (single unit — lessons in order + trailing quiz row) ─────
const LessonsRoadmap = ({ unitDetail, currentLessonId, onLessonClick, onContinue, onQuizClick }) => {
const lessons = unitDetail.lessons ?? [];
const quiz = unitDetail.quiz ?? null;
return (
<div className="flex flex-col gap-1 ">
{lessons.map((lesson, index) => {
const completed = lesson.status === "completed";
const isCurrent = lesson.lesson_id === currentLessonId;
return (
<motion.div
key={lesson.lesson_id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className={cn(
"flex items-center justify-between gap-3 py-3 px-3 rounded-lg cursor-pointer transition-colors",
isCurrent ? "bg-blue-50 dark:bg-blue-950/40" : "hover:bg-muted/60"
)}
onClick={() => onLessonClick(lesson)}
>
<div className="flex items-start gap-3 min-w-0">
<div className={cn(
"w-7 h-7 rounded-full border bg-background flex items-center justify-center text-xs font-medium shrink-0",
completed
? "border-emerald-500 text-emerald-500"
: isCurrent
? "border-blue-500 text-blue-500"
: "border-border text-muted-foreground"
)}>
{completed ? <Check className="size-3.5" /> : index + 1}
</div>
<div className="min-w-0">
<span className={cn(
"block truncate",
completed || isCurrent ? "font-medium text-card-foreground" : "text-muted-foreground"
)}>
{lesson.title}
</span>
<span className="text-sm text-muted-foreground">
{formatDuration(lesson.duration_seconds) ?? "—"}
{completed && " · Completed"}
{isCurrent && !completed && " · In progress"}
</span>
</div>
</div>
{isCurrent && !completed && (
<Button
size="sm"
className="bg-blue-500 shrink-0"
onClick={(e) => { e.stopPropagation(); onContinue(lesson); }}
>
Continue
</Button>
)}
</motion.div>
);
})}
{quiz && (
<div
className="flex items-center justify-between py-3 px-3 mt-1 rounded-lg border border-dashed border-blue-300 dark:border-blue-700 bg-blue-50/60 dark:bg-blue-950/30 hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors cursor-pointer"
onClick={() => onQuizClick()}
>
<div className="flex items-center gap-3 select-none min-w-0">
{quiz.has_passed
? <CheckCircle2 className="size-5 text-emerald-500 shrink-0" />
: <ClipboardList className="size-5 text-blue-500 shrink-0" />
}
<span className="font-medium text-blue-700 dark:text-blue-300 truncate">{quiz.title || "Quiz"}</span>
</div>
<Badge className={cn(
"shrink-0 ml-2 text-[10px]",
quiz.has_passed
? "bg-green-100 text-green-700 border border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700"
: "bg-blue-100 text-blue-700 border border-blue-300 dark:bg-blue-900/40 dark:text-blue-400 dark:border-blue-700"
)}>
{quiz.has_passed ? "Passed" : "Quiz"}
</Badge>
</div>
)}
</div>
);
};
// ─── Unit Details ───────────────────────────────────────────────────────────
const UnitDetails = () => {
const { uuid } = useParams();
const navigate = useNavigate();
const { getUnitDetail, unitDetail, unitDetailLoading, unitBlocked, unitBlockedInfo, resetUnitDetail } = useLibrary();
const { tierMap, getTierCategories } = useClientTiers();
const contentNotReady = !unitDetail?.duration_seconds;
useEffect(() => {
getTierCategories();
getUnitDetail(uuid);
return () => resetUnitDetail();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uuid]);
const items = [
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
{ label: "Units", to: `/units` },
{ label: unitDetail?.title ?? "Unit" },
];
// ── Deep-link to a locked unit — inline blocked panel, not a redirect ────
if (unitBlocked) {
return (
<LockedContentPanel
course={unitBlockedInfo?.course}
item={unitBlockedInfo?.item}
tierMap={tierMap}
/>
);
}
if (unitDetailLoading) {
return (
<div className="my-17 p-8 lg:container lg:mx-auto space-y-4">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-10 w-2/3" />
<Skeleton className="h-5 w-full max-w-2xl" />
</div>
);
}
const lessons = unitDetail?.lessons ?? [];
const completedCount = lessons.filter((l) => l.status === "completed").length;
const currentLesson = lessons.find((l) => l.status !== "completed") ?? null;
const progressPct = lessons.length > 0 ? Math.round((completedCount / lessons.length) * 100) : 0;
// Admin-configured completion requirement for the unit itself (null when nothing's set).
const requirementDef = unitDetail?.completion?.type ? TYPE_DEFS[unitDetail.completion.type] : null;
const RequirementIcon = requirementDef?.icon;
const handleLessonClick = (lesson) => {
navigate(`/lessons/${lesson.uuid}`);
};
const handleContinue = (lesson) => {
navigate(`/units/${uuid}/read`, { state: { lessonId: lesson.lesson_id } });
};
const handleQuizClick = () => {
navigate(`/units/${uuid}/read`, { state: { quizId: true } });
};
return (
<div>
<PageMeta title={unitDetail ? `${unitDetail.title} - STARR` : undefined} description={unitDetail?.description} />
<div className="xs:py-24 xs:px-8 lg:px-0 lg:py-28 flex flex-col gap-6 lg:container lg:mx-auto lg:max-w-2xl">
<AppBreadcrumb items={items} />
<div className="flex flex-col gap-3">
<span className="text-xs font-semibold tracking-wide text-blue-600 dark:text-blue-400 uppercase">Unit</span>
<h1 className="font-bold xs:text-2xl lg:text-4xl">{unitDetail?.title ?? "Unit"}</h1>
<p className="text-muted-foreground lg:text-lg">{unitDetail?.description ?? ""}</p>
</div>
{contentNotReady ? (
<div className="flex items-center gap-2 rounded-lg border bg-muted px-4 py-2.5 text-sm text-muted-foreground w-fit">
<Hourglass className="size-4 shrink-0" />
This unit is currently being prepared. Please check back later.
</div>
) : (
<>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground [&_svg]:size-4">
<span>{lessons.length} {lessons.length === 1 ? "lesson" : "lessons"}</span>
<span>·</span>
<span className="flex items-center gap-1"><Timer /> {formatDuration(unitDetail.duration_seconds) ?? "—"} total</span>
<span>·</span>
<span className="text-blue-600 dark:text-blue-400 font-medium">{completedCount} of {lessons.length} complete</span>
{requirementDef && (
<>
<span>·</span>
<span className="flex items-center gap-1">
{RequirementIcon && <RequirementIcon />} {requirementDef.label}
</span>
</>
)}
</div>
<Progress value={progressPct} />
{unitDetail.is_completed && (
<div className="flex items-center gap-1.5 text-sm font-medium text-emerald-600 dark:text-emerald-400 w-fit">
<CheckCheck className="size-4" />
Success — you've completed this unit.
</div>
)}
</div>
<div className="flex flex-col gap-3">
<h2 className="font-bold text-xl">Lessons in this unit</h2>
<LessonsRoadmap
unitDetail={unitDetail}
currentLessonId={currentLesson?.lesson_id}
onLessonClick={handleLessonClick}
onContinue={handleContinue}
onQuizClick={handleQuizClick}
/>
</div>
</>
)}
</div>
</div>
);
};
export default UnitDetails;