import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import { useDateFormat } from "@/hooks/useDateFormat"; import { useParams, useNavigate } from "react-router-dom"; import api from "@/utils/api.util"; import { House, Timer, Layers, GitBranch, Medal, CircleStar, LockIcon, SendHorizonal, CheckCheck, CheckCircle2, Clock, FileQuestion, ClipboardList, Hourglass, Check, } from "lucide-react"; import { cn } from "@/lib/utils"; 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 { motion, AnimatePresence } from "framer-motion"; import { useRef, useEffect, useState } from "react"; import { useScrollTrigger } from "../hooks/ScrollTrigger"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from "@/components/ui/dialog"; import { useClientCourses } from "@/contexts/ClientCoursesContext"; import { useClientTiers } from "@/contexts/ClientTiersProvider"; import { useCourseReadingProgress } from "@/contexts/ClientCourseReadingProgressContext"; import { PageMeta } from "@/contexts/MetadataContext"; import { toast } from "sonner"; import { resolveTierBadge } from "@/utils/tierBadge.util"; import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext"; import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner"; import { Sidebar, SidebarSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Sidebar"; import { Tags } from "lucide-react"; // ─── 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`; } // ─── Spine / card helpers ────────────────────────────────────────────────────── const useVisibleNodes = (refs, count) => { const [visible, setVisible] = useState(new Set()); useEffect(() => { setVisible(new Set()); const observers = []; const id = setTimeout(() => { refs.current.forEach((el, i) => { if (!el) return; const obs = new IntersectionObserver( ([entry]) => { setVisible((prev) => { const next = new Set(prev); if (entry.isIntersecting) next.add(i); return next; }); }, { threshold: 0.2 } ); obs.observe(el); observers.push(obs); }); }, 50); return () => { clearTimeout(id); observers.forEach((o) => o.disconnect()); }; }, [count]); return visible; }; // ─── Unit Accordion Block ───────────────────────────────────────────────────── const UnitAccordionBlock = ({ unit, unitIndex, i, cardRefs, courseId, onToggle, isCompleted }) => { const navigate = useNavigate(); const quiz = unit.quiz ?? null; return ( (cardRefs.current[i] = el)} className="group rounded-xl border bg-card hover:bg-blue-400/10 hover:shadow-md hover:border-blue-400 transition-all" initial={{ opacity: 0, y: 14 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.35, delay: i * 0.05, ease: "easeOut" }} > setTimeout(onToggle, 250)} >
Unit {unitIndex + 1}

{unit.title}

{unit.lessons?.length > 0 && (
{unit.lessons.length} {unit.lessons.length === 1 ? "Lesson" : "Lessons"}
)} {unit.duration_seconds > 0 && (
{formatDuration(unit.duration_seconds)}
)} {quiz && (
Quiz
)}
{(unit.lessons ?? []).map((lesson) => (
navigate(`/course/${courseId}/unit`, { state: { lessonId: lesson.lesson_id, unitId: unit.unit_id } })} >
{isCompleted(lesson.uuid) ? :
} {lesson.title}
{lesson.duration_seconds > 0 && ( {formatDuration(lesson.duration_seconds)} )}
))} {/* Quiz row — shown after lessons if unit has a quiz */} {quiz && (
navigate(`/course/${courseId}/unit`, { state: { quizUnitId: unit.unit_id } })} >
{quiz.has_passed ? : } {quiz.title}
{quiz.has_passed ? "Passed" : "Quiz"}
)}
); }; // ─── Assessment Card ────────────────────────────────────────────────────────── const AssessmentCard = ({ assessment, courseId, delay, nodeRef }) => { const navigate = useNavigate(); const passed = assessment.has_passed; return (

Final Assessment

{assessment.title}

{(() => { const count = assessment.max_questions ?? assessment.question_count; if (!count) return null; return (
{count} {count === 1 ? "question" : "questions"}
); })()}
{passed ? ( Passed ) : ( Not yet passed )}
); }; // ─── Certificate Card ───────────────────────────────────────────────────────── const CertCard = ({ courseTitle, courseLevel, badgeColor, badgeImageUrl, pendingCert, certificate, delay, nodeRef }) => { const { fmtDate } = useDateFormat(); const isIssued = !!certificate; const isPending = !isIssued && !!pendingCert; let issuedLabel = "Upon completion"; if (isIssued) issuedLabel = fmtDate(certificate.issued_at); if (isPending) issuedLabel = fmtDate(pendingCert.issue_at); return (

{isIssued ? "Issued" : "Available"}

{issuedLabel}

{isIssued ? ( Issued ) : isPending ? ( Pending ) : ( Not yet earned )}
{isIssued ? : } {isIssued ? "Certificate Issued" : isPending ? "Certificate Pending" : "Certificate"}
{isIssued ? ( <>

Your certificate for this course was officially issued on{" "} {fmtDate(certificate.issued_at)}.

{courseTitle}

You can view and download it from your Certificates page.

) : isPending ? ( <>

You passed the course assessment on{" "} {fmtDate(pendingCert.passed_at)}. Your certificate is being processed and will be officially issued on:

{fmtDate(pendingCert.issue_at)}

Once issued, it will appear in your Certificates page.

) : ( <>

Complete all lessons and pass the course assessment to earn your certificate for:

{courseTitle}

Your certificate will be issued within the hour after passing and will appear in your Certificates page.

)}
); }; // ─── Course Units (spine + cards) ───────────────────────────────────────────── const CourseUnits = ({ units = [], courseId, courseTitle, courseLevel, badgeColor, badgeImageUrl, onToggle, isCompleted, pendingCert, certificate, assessment, contentNotReady }) => { const wrapRef = useRef(null); const cardRefs = useRef([]); // Build a flat ordered list of nodes: [unit, unit, ..., assessment?, cert] // Quiz is rendered inside each unit accordion, not as a separate spine node. // While content isn't ready yet, no unit/lesson/assessment titles are shown — // only the Rewards (cert) card, as a preview of what's to come. const nodes = contentNotReady ? [{ type: "cert" }] : [ ...units.map((unit) => ({ type: "unit", unit })), ...(assessment ? [{ type: "assessment", assessment }] : []), { type: "cert" }, ]; const totalNodes = nodes.length; const visibleNodes = useVisibleNodes(cardRefs, totalNodes); const [mids, setMids] = useState([]); const maxVisible = visibleNodes.size ? Math.max(...visibleNodes) : -1; const measure = () => { if (!wrapRef.current) return; const base = wrapRef.current.getBoundingClientRect().top; setMids( cardRefs.current.map((el) => { const r = el?.getBoundingClientRect(); return r ? Math.round((r.top + r.bottom) / 2 - base) : 0; }) ); }; useEffect(() => { const id = setTimeout(measure, 60); window.addEventListener("resize", measure); return () => { clearTimeout(id); window.removeEventListener("resize", measure); }; }, [totalNodes]); const lastMid = mids.length ? mids[mids.length - 1] : 0; const svgH = lastMid + 40; const drawnTo = maxVisible >= 0 && mids[maxVisible] ? mids[maxVisible] : 0; const isIssued = !!certificate; return (
{/* Spine */}
{mids.length > 0 && ( <> {Array.from({ length: 10 }).map((_, i) => { const y1 = (mids[0] / 10) * i; const y2 = (mids[0] / 10) * (i + 1); const revealed = drawnTo >= y2; return ( ); })} {mids[0] != null && drawnTo > mids[0] && ( )} {mids.map((mid, i) => { const visible = visibleNodes.has(i); const isCert = i === totalNodes - 1; return ( {isCert ? : i + 1} ); })} )}
{/* Cards */}
{nodes.map((node, ni) => { const delay = ni * 0.05; const nodeRef = (el) => (cardRefs.current[ni] = el); if (node.type === "unit") { const unitIndex = units.indexOf(node.unit); return ( ); } if (node.type === "assessment") { return ( ); } // cert return ( ); })}
); }; // ─── Course Details ─────────────────────────────────────────────────────────── const CourseDetails = () => { const CourseBreadcrumb = useRef(null); const triggered = useScrollTrigger(CourseBreadcrumb); const { id: courseId } = useParams(); const navigate = useNavigate(); const { getCourse, course, courseBlocked, courseLoading, resetCourse } = useClientCourses(); const { myTier, getMyTier } = useClientTiers(); const { fetchCourseProgress, isCompleted, resetProgress } = useCourseReadingProgress(); const { advertisements, loading: adLoading, getActiveAdvertisements, handleAdCtaClick } = useClientAdvertisements(); const [tierMap, setTierMap] = useState({}); const [badgeImageUrl, setBadgeImageUrl] = useState(null); useEffect(() => { api.get("/client/tiers/categories") .then(({ data }) => { const m = {}; (data.data ?? []).forEach((c) => { m[c.slug] = c; }); setTierMap(m); }) .catch(() => { }); }, []); const hasCompleted = !!course?.is_completed; // duration_seconds is derived from authored lesson content (see duration.util.js) — // zero means no lesson content has been built yet, so the course isn't ready to take. const contentNotReady = !course?.duration_seconds; useEffect(() => { if (!myTier) getMyTier(); }, []); useEffect(() => { getMyTier(); getCourse(courseId); fetchCourseProgress(courseId); getActiveAdvertisements(["course_details.banner", "course_details.sidebar"]); return () => { resetCourse(); resetProgress(); setBadgeImageUrl(null); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [courseId]); const bannerAd = advertisements["course_details.banner"] ?? null; const sidebarAd = advertisements["course_details.sidebar"] ?? null; // Resolve badge image once course loads — issue a client stream token for // private S3 assets so the badge preview works on this page. useEffect(() => { if (!course?.badge_asset_id) { setBadgeImageUrl(course?.badge_image_url ?? null); return; } const STREAM_BASE = `${import.meta.env.VITE_API_URL}/client/media/stream`; api.post("/client/media/token", { asset_id: course.badge_asset_id }) .then(({ data }) => { const token = data.data?.token; setBadgeImageUrl(token ? `${STREAM_BASE}/${token}` : null); }) .catch(() => setBadgeImageUrl(course.badge_image_url ?? null)); }, [course?.badge_asset_id, course?.badge_image_url]); if (courseBlocked) { toast("You don't have access to this course. Upgrade your plan."); navigate("/course", { replace: true }); return null; } const items = [ { label: "Home", icon: , to: `/dashboard` }, { label: "Courses", to: `/course` }, { label: course?.title ?? "Course" }, ]; if (courseLoading) { return (
); } return (
{triggered && (
{contentNotReady ? ( Coming Soon ) : ( <> )}
)}
{/* Hero */}
{(() => { const slug = course?.plan_tier ?? course?.subscription ?? "free"; const { label, cls } = resolveTierBadge(slug, tierMap); return {label}; })()} {(course?.categories ?? []).map((cat) => ( {cat.name} ))}

{course?.title ?? "Course Title"}

{course?.description ?? ""}

{course?.duration_seconds > 0 && (
{formatDuration(course.duration_seconds)}
)} {course?.level && (
{course.level.charAt(0).toUpperCase() + course.level.slice(1)}
)}
{contentNotReady ? (
This course is currently being prepared. Please check back later.
) : ( )}
{/* Advertisement Banner */}
{adLoading["course_details.banner"] ? ( ) : ( )}
{/* Body */}
About this course

{course?.description ?? ""}

{/* Objectives */}
{course?.objectives?.length > 0 && (
What you will learn
    {course.objectives.map((obj) => (
  • {obj.text}
  • ))}
)}
{/* Units — while content isn't ready, only Rewards is shown */}
{course?.units?.length > 0 && ( <>
{contentNotReady ? "Rewards" : "Course content"}
)}
{/* Advertisement Sidebar */}
); }; export default CourseDetails;