@burdenoff/microfe-buildmyiq 2026.705.1 → 2026.705.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/buildmyiq/context/BuildmyiqContext.js.map +1 -1
- package/dist/buildmyiq/pages/DashboardPage.js.map +1 -1
- package/dist/buildmyiq/pages/assessments/AssessmentDetailPage.js.map +1 -1
- package/dist/buildmyiq/pages/authoring/AuthoringPage.js.map +1 -1
- package/dist/buildmyiq/pages/authoring/CourseBuilderPage.js.map +1 -1
- package/dist/buildmyiq/pages/catalog/CourseDetailPage.js.map +1 -1
- package/dist/buildmyiq/pages/cohorts/CohortsPage.js.map +1 -1
- package/dist/buildmyiq/pages/learning/LessonPlayerPage.js.map +1 -1
- package/dist/buildmyiq/pages/learning/MyLearningPage.js.map +1 -1
- package/dist/buildmyiq/pages/skills/SkillsPage.js.map +1 -1
- package/dist/buildmyiq/pages/submissions/SubmissionsPage.js.map +1 -1
- package/dist/buildmyiq/types/index.js.map +1 -1
- package/dist/buildmyiq/utils/cn.js +2 -2
- package/dist/buildmyiq/utils/formatters.js.map +1 -1
- package/dist/node_modules/{.bun/clsx@2.1.1/node_modules/clsx → clsx}/dist/clsx.js +1 -1
- package/dist/node_modules/clsx/dist/clsx.js.map +1 -0
- package/dist/node_modules/{.bun/tailwind-merge@3.5.0/node_modules/tailwind-merge → tailwind-merge}/dist/bundle-mjs.js +212 -170
- package/dist/node_modules/tailwind-merge/dist/bundle-mjs.js.map +1 -0
- package/package.json +1 -1
- package/dist/buildmyiq/BuildmyiqRoot.d.ts +0 -5
- package/dist/buildmyiq/BuildmyiqRoot.d.ts.map +0 -1
- package/dist/buildmyiq/BuildmyiqRoutes.d.ts +0 -4
- package/dist/buildmyiq/BuildmyiqRoutes.d.ts.map +0 -1
- package/dist/buildmyiq/components/BuiDiscussion.d.ts +0 -18
- package/dist/buildmyiq/components/BuiDiscussion.d.ts.map +0 -1
- package/dist/buildmyiq/components/BuiTags.d.ts +0 -17
- package/dist/buildmyiq/components/BuiTags.d.ts.map +0 -1
- package/dist/buildmyiq/components/DataTable.d.ts +0 -28
- package/dist/buildmyiq/components/DataTable.d.ts.map +0 -1
- package/dist/buildmyiq/components/EmptyState.d.ts +0 -18
- package/dist/buildmyiq/components/EmptyState.d.ts.map +0 -1
- package/dist/buildmyiq/components/EvidenceUpload.d.ts +0 -14
- package/dist/buildmyiq/components/EvidenceUpload.d.ts.map +0 -1
- package/dist/buildmyiq/components/FileLink.d.ts +0 -15
- package/dist/buildmyiq/components/FileLink.d.ts.map +0 -1
- package/dist/buildmyiq/components/FilterTabs.d.ts +0 -18
- package/dist/buildmyiq/components/FilterTabs.d.ts.map +0 -1
- package/dist/buildmyiq/components/MetricsCard.d.ts +0 -23
- package/dist/buildmyiq/components/MetricsCard.d.ts.map +0 -1
- package/dist/buildmyiq/components/MiniBar.d.ts +0 -16
- package/dist/buildmyiq/components/MiniBar.d.ts.map +0 -1
- package/dist/buildmyiq/components/PageLayout.d.ts +0 -16
- package/dist/buildmyiq/components/PageLayout.d.ts.map +0 -1
- package/dist/buildmyiq/components/Pagination.d.ts +0 -22
- package/dist/buildmyiq/components/Pagination.d.ts.map +0 -1
- package/dist/buildmyiq/components/StateViews.d.ts +0 -24
- package/dist/buildmyiq/components/StateViews.d.ts.map +0 -1
- package/dist/buildmyiq/components/StatusBadge.d.ts +0 -9
- package/dist/buildmyiq/components/StatusBadge.d.ts.map +0 -1
- package/dist/buildmyiq/components/Tabs.d.ts +0 -17
- package/dist/buildmyiq/components/Tabs.d.ts.map +0 -1
- package/dist/buildmyiq/components/index.d.ts +0 -19
- package/dist/buildmyiq/components/index.d.ts.map +0 -1
- package/dist/buildmyiq/context/BuildmyiqContext.d.ts +0 -16
- package/dist/buildmyiq/context/BuildmyiqContext.d.ts.map +0 -1
- package/dist/buildmyiq/context/index.d.ts +0 -6
- package/dist/buildmyiq/context/index.d.ts.map +0 -1
- package/dist/buildmyiq/hooks/index.d.ts +0 -10
- package/dist/buildmyiq/hooks/index.d.ts.map +0 -1
- package/dist/buildmyiq/hooks/useBuiComments.d.ts +0 -36
- package/dist/buildmyiq/hooks/useBuiComments.d.ts.map +0 -1
- package/dist/buildmyiq/hooks/useBuiFiles.d.ts +0 -47
- package/dist/buildmyiq/hooks/useBuiFiles.d.ts.map +0 -1
- package/dist/buildmyiq/hooks/useBuiTags.d.ts +0 -44
- package/dist/buildmyiq/hooks/useBuiTags.d.ts.map +0 -1
- package/dist/buildmyiq/hooks/useBuildmyiqApi.d.ts +0 -65
- package/dist/buildmyiq/hooks/useBuildmyiqApi.d.ts.map +0 -1
- package/dist/buildmyiq/hooks/useCursorPager.d.ts +0 -33
- package/dist/buildmyiq/hooks/useCursorPager.d.ts.map +0 -1
- package/dist/buildmyiq/index.d.ts +0 -9
- package/dist/buildmyiq/index.d.ts.map +0 -1
- package/dist/buildmyiq/pages/DashboardPage.d.ts +0 -4
- package/dist/buildmyiq/pages/DashboardPage.d.ts.map +0 -1
- package/dist/buildmyiq/pages/_shared/CourseCard.d.ts +0 -13
- package/dist/buildmyiq/pages/_shared/CourseCard.d.ts.map +0 -1
- package/dist/buildmyiq/pages/_shared/index.d.ts +0 -6
- package/dist/buildmyiq/pages/_shared/index.d.ts.map +0 -1
- package/dist/buildmyiq/pages/assessments/AssessmentDetailPage.d.ts +0 -4
- package/dist/buildmyiq/pages/assessments/AssessmentDetailPage.d.ts.map +0 -1
- package/dist/buildmyiq/pages/assessments/AssessmentsPage.d.ts +0 -4
- package/dist/buildmyiq/pages/assessments/AssessmentsPage.d.ts.map +0 -1
- package/dist/buildmyiq/pages/authoring/AuthoringPage.d.ts +0 -4
- package/dist/buildmyiq/pages/authoring/AuthoringPage.d.ts.map +0 -1
- package/dist/buildmyiq/pages/authoring/CourseBuilderPage.d.ts +0 -4
- package/dist/buildmyiq/pages/authoring/CourseBuilderPage.d.ts.map +0 -1
- package/dist/buildmyiq/pages/authoring/_forms.d.ts +0 -44
- package/dist/buildmyiq/pages/authoring/_forms.d.ts.map +0 -1
- package/dist/buildmyiq/pages/catalog/CatalogPage.d.ts +0 -4
- package/dist/buildmyiq/pages/catalog/CatalogPage.d.ts.map +0 -1
- package/dist/buildmyiq/pages/catalog/CourseDetailPage.d.ts +0 -4
- package/dist/buildmyiq/pages/catalog/CourseDetailPage.d.ts.map +0 -1
- package/dist/buildmyiq/pages/certificates/CertificatesPage.d.ts +0 -4
- package/dist/buildmyiq/pages/certificates/CertificatesPage.d.ts.map +0 -1
- package/dist/buildmyiq/pages/cohorts/CohortsPage.d.ts +0 -4
- package/dist/buildmyiq/pages/cohorts/CohortsPage.d.ts.map +0 -1
- package/dist/buildmyiq/pages/learning/LessonPlayerPage.d.ts +0 -4
- package/dist/buildmyiq/pages/learning/LessonPlayerPage.d.ts.map +0 -1
- package/dist/buildmyiq/pages/learning/MyLearningPage.d.ts +0 -4
- package/dist/buildmyiq/pages/learning/MyLearningPage.d.ts.map +0 -1
- package/dist/buildmyiq/pages/skills/SkillsPage.d.ts +0 -4
- package/dist/buildmyiq/pages/skills/SkillsPage.d.ts.map +0 -1
- package/dist/buildmyiq/pages/submissions/SubmissionsPage.d.ts +0 -4
- package/dist/buildmyiq/pages/submissions/SubmissionsPage.d.ts.map +0 -1
- package/dist/buildmyiq/types/index.d.ts +0 -382
- package/dist/buildmyiq/types/index.d.ts.map +0 -1
- package/dist/buildmyiq/utils/cn.d.ts +0 -4
- package/dist/buildmyiq/utils/cn.d.ts.map +0 -1
- package/dist/buildmyiq/utils/formatters.d.ts +0 -33
- package/dist/buildmyiq/utils/formatters.d.ts.map +0 -1
- package/dist/buildmyiq/utils/i18n.d.ts +0 -6
- package/dist/buildmyiq/utils/i18n.d.ts.map +0 -1
- package/dist/buildmyiq/utils/index.d.ts +0 -9
- package/dist/buildmyiq/utils/index.d.ts.map +0 -1
- package/dist/buildmyiq/utils/navigation.d.ts +0 -14
- package/dist/buildmyiq/utils/navigation.d.ts.map +0 -1
- package/dist/dev/main.d.ts +0 -2
- package/dist/dev/main.d.ts.map +0 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +0 -1
- package/dist/node_modules/.bun/clsx@2.1.1/node_modules/clsx/dist/clsx.js.map +0 -1
- package/dist/node_modules/.bun/tailwind-merge@3.5.0/node_modules/tailwind-merge/dist/bundle-mjs.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CourseDetailPage.js","names":[],"sources":["../../../../src/buildmyiq/pages/catalog/CourseDetailPage.tsx"],"sourcesContent":["/**\n * CourseDetailPage — a single published course: hero, curriculum, assessments,\n * discussion + tags, and the enrol / continue-learning call to action.\n * @module buildmyiq/pages/catalog\n *\n * All data is live: `buiCourse` (hero + flat lessons), `buiListModules`\n * (curriculum), `buiListAssessments` (course exams), and `buiListEnrollments`\n * (to decide Enrol vs Continue). Enrolling calls `buiEnroll` then routes the\n * learner straight into their new enrollment. Every list has its own\n * loading / error / empty state.\n */\n\nimport type { FC } from 'react';\nimport { useMemo, useState } from 'react';\nimport { useParams } from 'react-router-dom';\nimport type { LucideIcon } from 'lucide-react';\nimport {\n ArrowLeft,\n BookOpen,\n ClipboardCheck,\n ClipboardList,\n Clock,\n FileText,\n GraduationCap,\n Layers,\n ListChecks,\n MousePointerClick,\n PlayCircle,\n Radio,\n Sparkles,\n Video,\n} from 'lucide-react';\nimport { Button, GlassCard } from '@burdenoff/fe-libs/ui';\nimport {\n PageLayout,\n StatusBadge,\n Section,\n LoadingView,\n ErrorView,\n EmptyState,\n MiniBar,\n BuiDiscussion,\n BuiTags,\n} from '../../components';\nimport { useBuildmyiq } from '../../context';\nimport {\n useBuiCourse,\n useBuiModules,\n useBuiAssessments,\n useBuiEnrollments,\n useBuiEnroll,\n} from '../../hooks';\nimport { formatHours, formatDuration, formatEnumLabel } from '../../utils/formatters';\nimport type { BuiLesson, BuiLessonType } from '../../types';\n\nconst COURSE_ENTITY = 'buildmyiq-course';\n\n/** Lesson-type → icon, so learners can scan a module at a glance. */\nconst LESSON_ICON: Record<BuiLessonType, LucideIcon> = {\n ARTICLE: FileText,\n VIDEO: Video,\n QUIZ: ListChecks,\n ASSIGNMENT: ClipboardList,\n INTERACTIVE: MousePointerClick,\n LIVE_SESSION: Radio,\n};\n\nconst LessonRow: FC<{ lesson: BuiLesson }> = ({ lesson }) => {\n const Icon = LESSON_ICON[lesson.type] ?? FileText;\n return (\n <li className=\"flex items-center gap-3 px-4 py-3\">\n <span className=\"flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-bg-sunken text-text-secondary\">\n <Icon className=\"h-4 w-4\" />\n </span>\n <div className=\"min-w-0 flex-1\">\n <p className=\"truncate text-sm font-medium text-text-primary\">{lesson.title}</p>\n <p className=\"text-xs text-text-tertiary\">{formatEnumLabel(lesson.type)}</p>\n </div>\n {lesson.isPreview && (\n <span className=\"shrink-0 rounded-full bg-status-success-bg-subtle px-2 py-0.5 text-xs font-medium text-status-success-text\">\n Preview\n </span>\n )}\n {lesson.durationMin > 0 && (\n <span className=\"hidden shrink-0 items-center gap-1 text-xs tabular-nums text-text-secondary sm:inline-flex\">\n <Clock className=\"h-3.5 w-3.5\" /> {formatDuration(lesson.durationMin)}\n </span>\n )}\n </li>\n );\n};\n\nexport const CourseDetailPage: FC = () => {\n const { courseId } = useParams<{ courseId: string }>();\n const { currentUser, navigateTo } = useBuildmyiq();\n const learnerId = currentUser?.id;\n\n const courseQuery = useBuiCourse(courseId);\n const modulesQuery = useBuiModules(courseId);\n const assessmentsQuery = useBuiAssessments(courseId);\n const enrollmentsQuery = useBuiEnrollments({\n courseId,\n learnerId,\n enabled: Boolean(courseId && learnerId),\n });\n\n const enroll = useBuiEnroll();\n const [enrolError, setEnrolError] = useState<string | null>(null);\n\n const course = courseQuery.data ?? null;\n const modules = modulesQuery.data ?? [];\n const assessments = assessmentsQuery.data ?? [];\n const enrollments = enrollmentsQuery.data?.items ?? [];\n\n // An existing, non-terminal enrollment means \"Continue\" instead of \"Enrol\".\n const activeEnrollment = useMemo(\n () => enrollments.find((e) => e.status !== 'DROPPED' && e.status !== 'EXPIRED'),\n [enrollments]\n );\n\n // Lessons attached directly to the course (no module) — surfaced as their own group.\n const ungroupedLessons = useMemo(\n () => (course?.lessons ?? []).filter((lesson) => !lesson.moduleId),\n [course]\n );\n\n const totalLessons = course?.lessons?.length ?? 0;\n\n const handleEnrol = async (): Promise<void> => {\n if (!courseId || !learnerId) return;\n setEnrolError(null);\n try {\n const created = await enroll.mutateAsync({ courseId, learnerId });\n navigateTo(`learning/${created.id}`);\n } catch (err) {\n setEnrolError(err instanceof Error ? err.message : 'Could not enrol in this course.');\n }\n };\n\n // Top-level page states.\n if (!courseId) {\n return (\n <PageLayout title=\"Course\">\n <ErrorView title=\"Missing course\" message=\"No course was specified.\" />\n </PageLayout>\n );\n }\n\n if (courseQuery.isLoading) {\n return (\n <PageLayout title=\"Course\">\n <LoadingView label=\"Loading course…\" />\n </PageLayout>\n );\n }\n\n if (courseQuery.isError) {\n return (\n <PageLayout title=\"Course\">\n <ErrorView\n title=\"Couldn't load this course\"\n message={courseQuery.error instanceof Error ? courseQuery.error.message : undefined}\n onRetry={() => courseQuery.refetch()}\n />\n </PageLayout>\n );\n }\n\n if (!course) {\n return (\n <PageLayout title=\"Course\">\n <EmptyState\n illustration=\"empty-search\"\n title=\"Course not found\"\n description=\"This course may have been unpublished or removed.\"\n action={<Button onClick={() => navigateTo('catalog')}>Back to catalog</Button>}\n />\n </PageLayout>\n );\n }\n\n return (\n <PageLayout title={course.title}>\n <div className=\"mx-auto flex w-full max-w-5xl flex-col gap-8\">\n <button\n type=\"button\"\n onClick={() => navigateTo('catalog')}\n className=\"inline-flex w-fit items-center gap-1.5 text-sm text-text-secondary transition-colors hover:text-text-primary\"\n >\n <ArrowLeft className=\"h-4 w-4\" /> Back to catalog\n </button>\n\n {/* Hero */}\n <GlassCard treatment=\"plain\" className=\"overflow-hidden\">\n <div className=\"grid grid-cols-1 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]\">\n <div className=\"relative h-48 w-full overflow-hidden bg-bg-sunken lg:h-full\">\n {course.cover?.url ? (\n <img src={course.cover.url} alt=\"\" className=\"h-full w-full object-cover\" />\n ) : (\n <div className=\"flex h-full min-h-[12rem] w-full items-center justify-center bg-[var(--color-accent-soft)]\">\n <BookOpen className=\"h-10 w-10 text-text-secondary opacity-60\" />\n </div>\n )}\n </div>\n\n <div className=\"flex flex-col gap-4 p-6\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <StatusBadge status={course.level} />\n {course.skill?.name && (\n <span className=\"inline-flex items-center gap-1 rounded-full bg-bg-sunken px-2.5 py-0.5 text-xs text-text-secondary\">\n <Sparkles className=\"h-3.5 w-3.5\" /> {course.skill.name}\n </span>\n )}\n </div>\n\n <div>\n <h1 className=\"text-2xl font-semibold text-text-primary\">{course.title}</h1>\n {course.summary && (\n <p className=\"mt-2 text-sm text-text-secondary\">{course.summary}</p>\n )}\n </div>\n\n <div className=\"flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-text-secondary\">\n <span className=\"inline-flex items-center gap-1\">\n <Layers className=\"h-3.5 w-3.5\" /> {modules.length} module\n {modules.length === 1 ? '' : 's'} · {totalLessons} lesson\n {totalLessons === 1 ? '' : 's'}\n </span>\n {course.estimatedHours > 0 && (\n <span className=\"inline-flex items-center gap-1\">\n <Clock className=\"h-3.5 w-3.5\" /> {formatHours(course.estimatedHours)}\n </span>\n )}\n </div>\n\n {course.tags.length > 0 && (\n <div className=\"flex flex-wrap gap-1.5\">\n {course.tags.map((tag) => (\n <span\n key={tag}\n className=\"rounded-full bg-bg-sunken px-2 py-0.5 text-xs text-text-secondary\"\n >\n {tag}\n </span>\n ))}\n </div>\n )}\n\n {course.description && (\n <p className=\"whitespace-pre-wrap text-sm leading-relaxed text-text-secondary\">\n {course.description}\n </p>\n )}\n\n {/* Primary CTA — Continue if enrolled, else Enrol */}\n <div className=\"mt-auto flex flex-col gap-2 pt-2\">\n {activeEnrollment ? (\n <>\n <Button\n size=\"lg\"\n className=\"w-full sm:w-auto\"\n onClick={() => navigateTo(`learning/${activeEnrollment.id}`)}\n >\n <PlayCircle className=\"mr-2 h-4 w-4\" /> Continue learning\n </Button>\n <div className=\"flex items-center gap-2\">\n <MiniBar value={activeEnrollment.progressPct} className=\"flex-1\" />\n <span className=\"shrink-0 text-xs tabular-nums text-text-secondary\">\n {activeEnrollment.progressPct}% complete\n </span>\n </div>\n </>\n ) : (\n <>\n <Button\n size=\"lg\"\n className=\"w-full sm:w-auto\"\n onClick={handleEnrol}\n disabled={enroll.isPending || !learnerId}\n >\n <GraduationCap className=\"mr-2 h-4 w-4\" />\n {enroll.isPending ? 'Enrolling…' : 'Enrol in this course'}\n </Button>\n {!learnerId && (\n <p className=\"text-xs text-text-tertiary\">\n Sign in to your workspace to enrol.\n </p>\n )}\n {enrolError && (\n <p className=\"text-xs text-status-error-text\">{enrolError}</p>\n )}\n </>\n )}\n </div>\n </div>\n </div>\n </GlassCard>\n\n {/* Curriculum */}\n <Section\n title=\"Curriculum\"\n description=\"Modules and lessons in this course.\"\n >\n {modulesQuery.isLoading ? (\n <LoadingView label=\"Loading curriculum…\" />\n ) : modulesQuery.isError ? (\n <ErrorView\n title=\"Couldn't load the curriculum\"\n message={\n modulesQuery.error instanceof Error ? modulesQuery.error.message : undefined\n }\n onRetry={() => modulesQuery.refetch()}\n />\n ) : modules.length === 0 && ungroupedLessons.length === 0 ? (\n <EmptyState\n illustration=\"empty-data\"\n title=\"No lessons yet\"\n description=\"The instructor hasn't added any modules or lessons to this course.\"\n />\n ) : (\n <div className=\"flex flex-col gap-4\">\n {modules.map((module, index) => {\n const lessons = module.lessons ?? [];\n return (\n <div\n key={module.id}\n className=\"overflow-hidden rounded-xl border border-border-seam bg-bg-surface\"\n >\n <div className=\"border-b border-border-seam px-4 py-3\">\n <div className=\"flex items-baseline justify-between gap-3\">\n <h3 className=\"font-semibold text-text-primary\">\n <span className=\"mr-2 text-text-tertiary\">\n {String(index + 1).padStart(2, '0')}\n </span>\n {module.title}\n </h3>\n <span className=\"shrink-0 text-xs text-text-tertiary\">\n {lessons.length} lesson{lessons.length === 1 ? '' : 's'}\n </span>\n </div>\n {module.description && (\n <p className=\"mt-1 text-sm text-text-secondary\">{module.description}</p>\n )}\n </div>\n {lessons.length === 0 ? (\n <p className=\"px-4 py-3 text-sm text-text-tertiary\">\n No lessons in this module yet.\n </p>\n ) : (\n <ul className=\"divide-y divide-border-seam\">\n {lessons.map((lesson) => (\n <LessonRow key={lesson.id} lesson={lesson} />\n ))}\n </ul>\n )}\n </div>\n );\n })}\n\n {ungroupedLessons.length > 0 && (\n <div className=\"overflow-hidden rounded-xl border border-border-seam bg-bg-surface\">\n <div className=\"border-b border-border-seam px-4 py-3\">\n <h3 className=\"font-semibold text-text-primary\">Additional lessons</h3>\n </div>\n <ul className=\"divide-y divide-border-seam\">\n {ungroupedLessons.map((lesson) => (\n <LessonRow key={lesson.id} lesson={lesson} />\n ))}\n </ul>\n </div>\n )}\n </div>\n )}\n </Section>\n\n {/* Assessments */}\n <Section\n title=\"Assessments\"\n description=\"Quizzes and exams for this course.\"\n >\n {assessmentsQuery.isLoading ? (\n <LoadingView label=\"Loading assessments…\" />\n ) : assessmentsQuery.isError ? (\n <ErrorView\n title=\"Couldn't load assessments\"\n message={\n assessmentsQuery.error instanceof Error\n ? assessmentsQuery.error.message\n : undefined\n }\n onRetry={() => assessmentsQuery.refetch()}\n />\n ) : assessments.length === 0 ? (\n <EmptyState\n illustration=\"empty-data\"\n icon={<ClipboardCheck className=\"h-9 w-9\" />}\n title=\"No assessments yet\"\n description=\"This course doesn't have any quizzes or exams attached.\"\n />\n ) : (\n <ul className=\"flex flex-col gap-3\">\n {assessments.map((assessment) => (\n <li\n key={assessment.id}\n className=\"flex flex-wrap items-center gap-3 rounded-xl border border-border-seam bg-bg-surface p-4\"\n >\n <span className=\"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--color-accent-soft)] text-text-primary\">\n <ClipboardCheck className=\"h-4 w-4\" />\n </span>\n <div className=\"min-w-0 flex-1\">\n <p className=\"truncate font-medium text-text-primary\">{assessment.title}</p>\n <div className=\"mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-text-secondary\">\n <span>Pass ≥ {assessment.passingScore}%</span>\n <span>\n {assessment.maxAttempts} attempt\n {assessment.maxAttempts === 1 ? '' : 's'}\n </span>\n {assessment.timeLimitMin > 0 && (\n <span className=\"inline-flex items-center gap-1\">\n <Clock className=\"h-3.5 w-3.5\" />\n {formatDuration(assessment.timeLimitMin)}\n </span>\n )}\n </div>\n </div>\n <div className=\"flex shrink-0 items-center gap-2\">\n <StatusBadge status={assessment.type} />\n <StatusBadge status={assessment.status} />\n </div>\n </li>\n ))}\n </ul>\n )}\n </Section>\n\n {/* Tags (platform wspace-tags) */}\n <BuiTags entityType={COURSE_ENTITY} entityId={courseId} />\n\n {/* Discussion (platform wspace-conversations) */}\n <BuiDiscussion\n entityType={COURSE_ENTITY}\n entityId={courseId}\n description=\"Ask questions and discuss this course with your workspace.\"\n />\n </div>\n </PageLayout>\n );\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAuDA,IAAM,IAAgB,oBAGhB,IAAiD;CACrD,SAAS;CACT,OAAO;CACP,MAAM;CACN,YAAY;CACZ,aAAa;CACb,cAAc;CACf,EAEK,KAAwC,EAAE,gBAG5C,kBAAC,MAAD;CAAI,WAAU;WAAd;EACE,kBAAC,QAAD;GAAM,WAAU;aACd,kBAJO,EAAY,EAAO,SAAS,GAInC,EAAM,WAAU,WAAY,CAAA;GACvB,CAAA;EACP,kBAAC,OAAD;GAAK,WAAU;aAAf,CACE,kBAAC,KAAD;IAAG,WAAU;cAAkD,EAAO;IAAU,CAAA,EAChF,kBAAC,KAAD;IAAG,WAAU;cAA8B,EAAgB,EAAO,KAAK;IAAK,CAAA,CACxE;;EACL,EAAO,aACN,kBAAC,QAAD;GAAM,WAAU;aAA6G;GAEtH,CAAA;EAER,EAAO,cAAc,KACpB,kBAAC,QAAD;GAAM,WAAU;aAAhB;IACE,kBAAC,GAAD,EAAO,WAAU,eAAgB,CAAA;;IAAE,EAAe,EAAO,YAAY;IAChE;;EAEN;IAII,UAA6B;CACxC,IAAM,EAAE,gBAAa,GAAiC,EAChD,EAAE,gBAAa,kBAAe,GAAc,EAC5C,IAAY,GAAa,IAEzB,IAAc,EAAa,EAAS,EACpC,IAAe,EAAc,EAAS,EACtC,IAAmB,EAAkB,EAAS,EAC9C,IAAmB,EAAkB;EACzC;EACA;EACA,SAAS,GAAQ,KAAY;EAC9B,CAAC,EAEI,IAAS,GAAc,EACvB,CAAC,GAAY,KAAiB,EAAwB,KAAK,EAE3D,IAAS,EAAY,QAAQ,MAC7B,IAAU,EAAa,QAAQ,EAAE,EACjC,IAAc,EAAiB,QAAQ,EAAE,EACzC,IAAc,EAAiB,MAAM,SAAS,EAAE,EAGhD,IAAmB,QACjB,EAAY,MAAM,MAAM,EAAE,WAAW,aAAa,EAAE,WAAW,UAAU,EAC/E,CAAC,EAAY,CACd,EAGK,IAAmB,SAChB,GAAQ,WAAW,EAAE,EAAE,QAAQ,MAAW,CAAC,EAAO,SAAS,EAClE,CAAC,EAAO,CACT,EAEK,IAAe,GAAQ,SAAS,UAAU;AAuDhD,QAzCK,IAQD,EAAY,YAEZ,kBAAC,GAAD;EAAY,OAAM;YAChB,kBAAC,GAAD,EAAa,OAAM,mBAAoB,CAAA;EAC5B,CAAA,GAIb,EAAY,UAEZ,kBAAC,GAAD;EAAY,OAAM;YAChB,kBAAC,GAAD;GACE,OAAM;GACN,SAAS,EAAY,iBAAiB,QAAQ,EAAY,MAAM,UAAU,KAAA;GAC1E,eAAe,EAAY,SAAS;GACpC,CAAA;EACS,CAAA,GAIZ,IAcH,kBAAC,GAAD;EAAY,OAAO,EAAO;YACxB,kBAAC,OAAD;GAAK,WAAU;aAAf;IACE,kBAAC,UAAD;KACE,MAAK;KACL,eAAe,EAAW,UAAU;KACpC,WAAU;eAHZ,CAKE,kBAAC,GAAD,EAAW,WAAU,WAAY,CAAA,EAAA,mBAC1B;;IAGT,kBAAC,GAAD;KAAW,WAAU;KAAQ,WAAU;eACrC,kBAAC,OAAD;MAAK,WAAU;gBAAf,CACE,kBAAC,OAAD;OAAK,WAAU;iBACZ,EAAO,OAAO,MACb,kBAAC,OAAD;QAAK,KAAK,EAAO,MAAM;QAAK,KAAI;QAAG,WAAU;QAA+B,CAAA,GAE5E,kBAAC,OAAD;QAAK,WAAU;kBACb,kBAAC,GAAD,EAAU,WAAU,4CAA6C,CAAA;QAC7D,CAAA;OAEJ,CAAA,EAEN,kBAAC,OAAD;OAAK,WAAU;iBAAf;QACE,kBAAC,OAAD;SAAK,WAAU;mBAAf,CACE,kBAAC,GAAD,EAAa,QAAQ,EAAO,OAAS,CAAA,EACpC,EAAO,OAAO,QACb,kBAAC,QAAD;UAAM,WAAU;oBAAhB;WACE,kBAAC,GAAD,EAAU,WAAU,eAAgB,CAAA;;WAAE,EAAO,MAAM;WAC9C;YAEL;;QAEN,kBAAC,OAAD,EAAA,UAAA,CACE,kBAAC,MAAD;SAAI,WAAU;mBAA4C,EAAO;SAAW,CAAA,EAC3E,EAAO,WACN,kBAAC,KAAD;SAAG,WAAU;mBAAoC,EAAO;SAAY,CAAA,CAElE,EAAA,CAAA;QAEN,kBAAC,OAAD;SAAK,WAAU;mBAAf,CACE,kBAAC,QAAD;UAAM,WAAU;oBAAhB;WACE,kBAAC,GAAD,EAAQ,WAAU,eAAgB,CAAA;;WAAE,EAAQ;WAAO;WAClD,EAAQ,WAAW,IAAI,KAAK;WAAI;WAAI;WAAa;WACjD,MAAiB,IAAI,KAAK;WACtB;aACN,EAAO,iBAAiB,KACvB,kBAAC,QAAD;UAAM,WAAU;oBAAhB;WACE,kBAAC,GAAD,EAAO,WAAU,eAAgB,CAAA;;WAAE,EAAY,EAAO,eAAe;WAChE;YAEL;;QAEL,EAAO,KAAK,SAAS,KACpB,kBAAC,OAAD;SAAK,WAAU;mBACZ,EAAO,KAAK,KAAK,MAChB,kBAAC,QAAD;UAEE,WAAU;oBAET;UACI,EAJA,EAIA,CACP;SACE,CAAA;QAGP,EAAO,eACN,kBAAC,KAAD;SAAG,WAAU;mBACV,EAAO;SACN,CAAA;QAIN,kBAAC,OAAD;SAAK,WAAU;mBACZ,IACC,kBAAA,GAAA,EAAA,UAAA,CACE,kBAAC,GAAD;UACE,MAAK;UACL,WAAU;UACV,eAAe,EAAW,YAAY,EAAiB,KAAK;oBAH9D,CAKE,kBAAC,GAAD,EAAY,WAAU,gBAAiB,CAAA,EAAA,qBAChC;aACT,kBAAC,OAAD;UAAK,WAAU;oBAAf,CACE,kBAAC,GAAD;WAAS,OAAO,EAAiB;WAAa,WAAU;WAAW,CAAA,EACnE,kBAAC,QAAD;WAAM,WAAU;qBAAhB,CACG,EAAiB,aAAY,aACzB;aACH;YACL,EAAA,CAAA,GAEH,kBAAA,GAAA,EAAA,UAAA;UACE,kBAAC,GAAD;WACE,MAAK;WACL,WAAU;WACV,SArJA,YAA2B;AACzC,mBAAC,KAAY,CAAC,IAClB;eAAc,KAAK;AACnB,iBAAI;AAEF,gBAAW,aADK,MAAM,EAAO,YAAY;eAAE;eAAU;eAAW,CAAC,EAClC,KAAK;sBAC7B,GAAK;AACZ,gBAAc,aAAe,QAAQ,EAAI,UAAU,kCAAkC;;;;WA+IrE,UAAU,EAAO,aAAa,CAAC;qBAJjC,CAME,kBAAC,GAAD,EAAe,WAAU,gBAAiB,CAAA,EACzC,EAAO,YAAY,eAAe,uBAC5B;;UACR,CAAC,KACA,kBAAC,KAAD;WAAG,WAAU;qBAA6B;WAEtC,CAAA;UAEL,KACC,kBAAC,KAAD;WAAG,WAAU;qBAAkC;WAAe,CAAA;UAE/D,EAAA,CAAA;SAED,CAAA;QACF;SACF;;KACI,CAAA;IAGZ,kBAAC,GAAD;KACE,OAAM;KACN,aAAY;eAEX,EAAa,YACZ,kBAAC,GAAD,EAAa,OAAM,uBAAwB,CAAA,GACzC,EAAa,UACf,kBAAC,GAAD;MACE,OAAM;MACN,SACE,EAAa,iBAAiB,QAAQ,EAAa,MAAM,UAAU,KAAA;MAErE,eAAe,EAAa,SAAS;MACrC,CAAA,GACA,EAAQ,WAAW,KAAK,EAAiB,WAAW,IACtD,kBAAC,GAAD;MACE,cAAa;MACb,OAAM;MACN,aAAY;MACZ,CAAA,GAEF,kBAAC,OAAD;MAAK,WAAU;gBAAf,CACG,EAAQ,KAAK,GAAQ,MAAU;OAC9B,IAAM,IAAU,EAAO,WAAW,EAAE;AACpC,cACE,kBAAC,OAAD;QAEE,WAAU;kBAFZ,CAIE,kBAAC,OAAD;SAAK,WAAU;mBAAf,CACE,kBAAC,OAAD;UAAK,WAAU;oBAAf,CACE,kBAAC,MAAD;WAAI,WAAU;qBAAd,CACE,kBAAC,QAAD;YAAM,WAAU;sBACb,OAAO,IAAQ,EAAE,CAAC,SAAS,GAAG,IAAI;YAC9B,CAAA,EACN,EAAO,MACL;cACL,kBAAC,QAAD;WAAM,WAAU;qBAAhB;YACG,EAAQ;YAAO;YAAQ,EAAQ,WAAW,IAAI,KAAK;YAC/C;aACH;aACL,EAAO,eACN,kBAAC,KAAD;UAAG,WAAU;oBAAoC,EAAO;UAAgB,CAAA,CAEtE;YACL,EAAQ,WAAW,IAClB,kBAAC,KAAD;SAAG,WAAU;mBAAuC;SAEhD,CAAA,GAEJ,kBAAC,MAAD;SAAI,WAAU;mBACX,EAAQ,KAAK,MACZ,kBAAC,GAAD,EAAmC,WAAU,EAA7B,EAAO,GAAsB,CAC7C;SACC,CAAA,CAEH;UA9BC,EAAO,GA8BR;QAER,EAED,EAAiB,SAAS,KACzB,kBAAC,OAAD;OAAK,WAAU;iBAAf,CACE,kBAAC,OAAD;QAAK,WAAU;kBACb,kBAAC,MAAD;SAAI,WAAU;mBAAkC;SAAuB,CAAA;QACnE,CAAA,EACN,kBAAC,MAAD;QAAI,WAAU;kBACX,EAAiB,KAAK,MACrB,kBAAC,GAAD,EAAmC,WAAU,EAA7B,EAAO,GAAsB,CAC7C;QACC,CAAA,CACD;SAEJ;;KAEA,CAAA;IAGV,kBAAC,GAAD;KACE,OAAM;KACN,aAAY;eAEX,EAAiB,YAChB,kBAAC,GAAD,EAAa,OAAM,wBAAyB,CAAA,GAC1C,EAAiB,UACnB,kBAAC,GAAD;MACE,OAAM;MACN,SACE,EAAiB,iBAAiB,QAC9B,EAAiB,MAAM,UACvB,KAAA;MAEN,eAAe,EAAiB,SAAS;MACzC,CAAA,GACA,EAAY,WAAW,IACzB,kBAAC,GAAD;MACE,cAAa;MACb,MAAM,kBAAC,GAAD,EAAgB,WAAU,WAAY,CAAA;MAC5C,OAAM;MACN,aAAY;MACZ,CAAA,GAEF,kBAAC,MAAD;MAAI,WAAU;gBACX,EAAY,KAAK,MAChB,kBAAC,MAAD;OAEE,WAAU;iBAFZ;QAIE,kBAAC,QAAD;SAAM,WAAU;mBACd,kBAAC,GAAD,EAAgB,WAAU,WAAY,CAAA;SACjC,CAAA;QACP,kBAAC,OAAD;SAAK,WAAU;mBAAf,CACE,kBAAC,KAAD;UAAG,WAAU;oBAA0C,EAAW;UAAU,CAAA,EAC5E,kBAAC,OAAD;UAAK,WAAU;oBAAf;WACE,kBAAC,QAAD,EAAA,UAAA;YAAM;YAAQ,EAAW;YAAa;YAAQ,EAAA,CAAA;WAC9C,kBAAC,QAAD,EAAA,UAAA;YACG,EAAW;YAAY;YACvB,EAAW,gBAAgB,IAAI,KAAK;YAChC,EAAA,CAAA;WACN,EAAW,eAAe,KACzB,kBAAC,QAAD;YAAM,WAAU;sBAAhB,CACE,kBAAC,GAAD,EAAO,WAAU,eAAgB,CAAA,EAChC,EAAe,EAAW,aAAa,CACnC;;WAEL;YACF;;QACN,kBAAC,OAAD;SAAK,WAAU;mBAAf,CACE,kBAAC,GAAD,EAAa,QAAQ,EAAW,MAAQ,CAAA,EACxC,kBAAC,GAAD,EAAa,QAAQ,EAAW,QAAU,CAAA,CACtC;;QACH;SA1BE,EAAW,GA0Bb,CACL;MACC,CAAA;KAEC,CAAA;IAGV,kBAAC,GAAD;KAAS,YAAY;KAAe,UAAU;KAAY,CAAA;IAG1D,kBAAC,GAAD;KACE,YAAY;KACZ,UAAU;KACV,aAAY;KACZ,CAAA;IACE;;EACK,CAAA,GAnRX,kBAAC,GAAD;EAAY,OAAM;YAChB,kBAAC,GAAD;GACE,cAAa;GACb,OAAM;GACN,aAAY;GACZ,QAAQ,kBAAC,GAAD;IAAQ,eAAe,EAAW,UAAU;cAAE;IAAwB,CAAA;GAC9E,CAAA;EACS,CAAA,GAnCb,kBAAC,GAAD;EAAY,OAAM;YAChB,kBAAC,GAAD;GAAW,OAAM;GAAiB,SAAQ;GAA6B,CAAA;EAC5D,CAAA"}
|
|
1
|
+
{"version":3,"file":"CourseDetailPage.js","names":[],"sources":["../../../../src/buildmyiq/pages/catalog/CourseDetailPage.tsx"],"sourcesContent":["/**\n * CourseDetailPage — a single published course: hero, curriculum, assessments,\n * discussion + tags, and the enrol / continue-learning call to action.\n * @module buildmyiq/pages/catalog\n *\n * All data is live: `buiCourse` (hero + flat lessons), `buiListModules`\n * (curriculum), `buiListAssessments` (course exams), and `buiListEnrollments`\n * (to decide Enrol vs Continue). Enrolling calls `buiEnroll` then routes the\n * learner straight into their new enrollment. Every list has its own\n * loading / error / empty state.\n */\n\nimport type { FC } from 'react';\nimport { useMemo, useState } from 'react';\nimport { useParams } from 'react-router-dom';\nimport type { LucideIcon } from 'lucide-react';\nimport {\n ArrowLeft,\n BookOpen,\n ClipboardCheck,\n ClipboardList,\n Clock,\n FileText,\n GraduationCap,\n Layers,\n ListChecks,\n MousePointerClick,\n PlayCircle,\n Radio,\n Sparkles,\n Video,\n} from 'lucide-react';\nimport { Button, GlassCard } from '@burdenoff/fe-libs/ui';\nimport {\n PageLayout,\n StatusBadge,\n Section,\n LoadingView,\n ErrorView,\n EmptyState,\n MiniBar,\n BuiDiscussion,\n BuiTags,\n} from '../../components';\nimport { useBuildmyiq } from '../../context';\nimport {\n useBuiCourse,\n useBuiModules,\n useBuiAssessments,\n useBuiEnrollments,\n useBuiEnroll,\n} from '../../hooks';\nimport { formatHours, formatDuration, formatEnumLabel } from '../../utils/formatters';\nimport type { BuiLesson, BuiLessonType } from '../../types';\n\nconst COURSE_ENTITY = 'buildmyiq-course';\n\n/** Lesson-type → icon, so learners can scan a module at a glance. */\nconst LESSON_ICON: Record<BuiLessonType, LucideIcon> = {\n ARTICLE: FileText,\n VIDEO: Video,\n QUIZ: ListChecks,\n ASSIGNMENT: ClipboardList,\n INTERACTIVE: MousePointerClick,\n LIVE_SESSION: Radio,\n};\n\nconst LessonRow: FC<{ lesson: BuiLesson }> = ({ lesson }) => {\n const Icon = LESSON_ICON[lesson.type] ?? FileText;\n return (\n <li className=\"flex items-center gap-3 px-4 py-3\">\n <span className=\"flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-bg-sunken text-text-secondary\">\n <Icon className=\"h-4 w-4\" />\n </span>\n <div className=\"min-w-0 flex-1\">\n <p className=\"truncate text-sm font-medium text-text-primary\">{lesson.title}</p>\n <p className=\"text-xs text-text-tertiary\">{formatEnumLabel(lesson.type)}</p>\n </div>\n {lesson.isPreview && (\n <span className=\"shrink-0 rounded-full bg-status-success-bg-subtle px-2 py-0.5 text-xs font-medium text-status-success-text\">\n Preview\n </span>\n )}\n {lesson.durationMin > 0 && (\n <span className=\"hidden shrink-0 items-center gap-1 text-xs tabular-nums text-text-secondary sm:inline-flex\">\n <Clock className=\"h-3.5 w-3.5\" /> {formatDuration(lesson.durationMin)}\n </span>\n )}\n </li>\n );\n};\n\nexport const CourseDetailPage: FC = () => {\n const { courseId } = useParams<{ courseId: string }>();\n const { currentUser, navigateTo } = useBuildmyiq();\n const learnerId = currentUser?.id;\n\n const courseQuery = useBuiCourse(courseId);\n const modulesQuery = useBuiModules(courseId);\n const assessmentsQuery = useBuiAssessments(courseId);\n const enrollmentsQuery = useBuiEnrollments({\n courseId,\n learnerId,\n enabled: Boolean(courseId && learnerId),\n });\n\n const enroll = useBuiEnroll();\n const [enrolError, setEnrolError] = useState<string | null>(null);\n\n const course = courseQuery.data ?? null;\n const modules = modulesQuery.data ?? [];\n const assessments = assessmentsQuery.data ?? [];\n const enrollments = enrollmentsQuery.data?.items ?? [];\n\n // An existing, non-terminal enrollment means \"Continue\" instead of \"Enrol\".\n const activeEnrollment = useMemo(\n () => enrollments.find((e) => e.status !== 'DROPPED' && e.status !== 'EXPIRED'),\n [enrollments]\n );\n\n // Lessons attached directly to the course (no module) — surfaced as their own group.\n const ungroupedLessons = useMemo(\n () => (course?.lessons ?? []).filter((lesson) => !lesson.moduleId),\n [course]\n );\n\n const totalLessons = course?.lessons?.length ?? 0;\n\n const handleEnrol = async (): Promise<void> => {\n if (!courseId || !learnerId) return;\n setEnrolError(null);\n try {\n const created = await enroll.mutateAsync({ courseId, learnerId });\n navigateTo(`learning/${created.id}`);\n } catch (err) {\n setEnrolError(err instanceof Error ? err.message : 'Could not enrol in this course.');\n }\n };\n\n // Top-level page states.\n if (!courseId) {\n return (\n <PageLayout title=\"Course\">\n <ErrorView title=\"Missing course\" message=\"No course was specified.\" />\n </PageLayout>\n );\n }\n\n if (courseQuery.isLoading) {\n return (\n <PageLayout title=\"Course\">\n <LoadingView label=\"Loading course…\" />\n </PageLayout>\n );\n }\n\n if (courseQuery.isError) {\n return (\n <PageLayout title=\"Course\">\n <ErrorView\n title=\"Couldn't load this course\"\n message={courseQuery.error instanceof Error ? courseQuery.error.message : undefined}\n onRetry={() => courseQuery.refetch()}\n />\n </PageLayout>\n );\n }\n\n if (!course) {\n return (\n <PageLayout title=\"Course\">\n <EmptyState\n illustration=\"empty-search\"\n title=\"Course not found\"\n description=\"This course may have been unpublished or removed.\"\n action={<Button onClick={() => navigateTo('catalog')}>Back to catalog</Button>}\n />\n </PageLayout>\n );\n }\n\n return (\n <PageLayout title={course.title}>\n <div className=\"mx-auto flex w-full max-w-5xl flex-col gap-8\">\n <button\n type=\"button\"\n onClick={() => navigateTo('catalog')}\n className=\"inline-flex w-fit items-center gap-1.5 text-sm text-text-secondary transition-colors hover:text-text-primary\"\n >\n <ArrowLeft className=\"h-4 w-4\" /> Back to catalog\n </button>\n\n {/* Hero */}\n <GlassCard treatment=\"plain\" className=\"overflow-hidden\">\n <div className=\"grid grid-cols-1 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]\">\n <div className=\"relative h-48 w-full overflow-hidden bg-bg-sunken lg:h-full\">\n {course.cover?.url ? (\n <img src={course.cover.url} alt=\"\" className=\"h-full w-full object-cover\" />\n ) : (\n <div className=\"flex h-full min-h-[12rem] w-full items-center justify-center bg-[var(--color-accent-soft)]\">\n <BookOpen className=\"h-10 w-10 text-text-secondary opacity-60\" />\n </div>\n )}\n </div>\n\n <div className=\"flex flex-col gap-4 p-6\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <StatusBadge status={course.level} />\n {course.skill?.name && (\n <span className=\"inline-flex items-center gap-1 rounded-full bg-bg-sunken px-2.5 py-0.5 text-xs text-text-secondary\">\n <Sparkles className=\"h-3.5 w-3.5\" /> {course.skill.name}\n </span>\n )}\n </div>\n\n <div>\n <h1 className=\"text-2xl font-semibold text-text-primary\">{course.title}</h1>\n {course.summary && (\n <p className=\"mt-2 text-sm text-text-secondary\">{course.summary}</p>\n )}\n </div>\n\n <div className=\"flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-text-secondary\">\n <span className=\"inline-flex items-center gap-1\">\n <Layers className=\"h-3.5 w-3.5\" /> {modules.length} module\n {modules.length === 1 ? '' : 's'} · {totalLessons} lesson\n {totalLessons === 1 ? '' : 's'}\n </span>\n {course.estimatedHours > 0 && (\n <span className=\"inline-flex items-center gap-1\">\n <Clock className=\"h-3.5 w-3.5\" /> {formatHours(course.estimatedHours)}\n </span>\n )}\n </div>\n\n {course.tags.length > 0 && (\n <div className=\"flex flex-wrap gap-1.5\">\n {course.tags.map((tag) => (\n <span\n key={tag}\n className=\"rounded-full bg-bg-sunken px-2 py-0.5 text-xs text-text-secondary\"\n >\n {tag}\n </span>\n ))}\n </div>\n )}\n\n {course.description && (\n <p className=\"whitespace-pre-wrap text-sm leading-relaxed text-text-secondary\">\n {course.description}\n </p>\n )}\n\n {/* Primary CTA — Continue if enrolled, else Enrol */}\n <div className=\"mt-auto flex flex-col gap-2 pt-2\">\n {activeEnrollment ? (\n <>\n <Button\n size=\"lg\"\n className=\"w-full sm:w-auto\"\n onClick={() => navigateTo(`learning/${activeEnrollment.id}`)}\n >\n <PlayCircle className=\"mr-2 h-4 w-4\" /> Continue learning\n </Button>\n <div className=\"flex items-center gap-2\">\n <MiniBar value={activeEnrollment.progressPct} className=\"flex-1\" />\n <span className=\"shrink-0 text-xs tabular-nums text-text-secondary\">\n {activeEnrollment.progressPct}% complete\n </span>\n </div>\n </>\n ) : (\n <>\n <Button\n size=\"lg\"\n className=\"w-full sm:w-auto\"\n onClick={handleEnrol}\n disabled={enroll.isPending || !learnerId}\n >\n <GraduationCap className=\"mr-2 h-4 w-4\" />\n {enroll.isPending ? 'Enrolling…' : 'Enrol in this course'}\n </Button>\n {!learnerId && (\n <p className=\"text-xs text-text-tertiary\">\n Sign in to your workspace to enrol.\n </p>\n )}\n {enrolError && <p className=\"text-xs text-status-error-text\">{enrolError}</p>}\n </>\n )}\n </div>\n </div>\n </div>\n </GlassCard>\n\n {/* Curriculum */}\n <Section title=\"Curriculum\" description=\"Modules and lessons in this course.\">\n {modulesQuery.isLoading ? (\n <LoadingView label=\"Loading curriculum…\" />\n ) : modulesQuery.isError ? (\n <ErrorView\n title=\"Couldn't load the curriculum\"\n message={modulesQuery.error instanceof Error ? modulesQuery.error.message : undefined}\n onRetry={() => modulesQuery.refetch()}\n />\n ) : modules.length === 0 && ungroupedLessons.length === 0 ? (\n <EmptyState\n illustration=\"empty-data\"\n title=\"No lessons yet\"\n description=\"The instructor hasn't added any modules or lessons to this course.\"\n />\n ) : (\n <div className=\"flex flex-col gap-4\">\n {modules.map((module, index) => {\n const lessons = module.lessons ?? [];\n return (\n <div\n key={module.id}\n className=\"overflow-hidden rounded-xl border border-border-seam bg-bg-surface\"\n >\n <div className=\"border-b border-border-seam px-4 py-3\">\n <div className=\"flex items-baseline justify-between gap-3\">\n <h3 className=\"font-semibold text-text-primary\">\n <span className=\"mr-2 text-text-tertiary\">\n {String(index + 1).padStart(2, '0')}\n </span>\n {module.title}\n </h3>\n <span className=\"shrink-0 text-xs text-text-tertiary\">\n {lessons.length} lesson{lessons.length === 1 ? '' : 's'}\n </span>\n </div>\n {module.description && (\n <p className=\"mt-1 text-sm text-text-secondary\">{module.description}</p>\n )}\n </div>\n {lessons.length === 0 ? (\n <p className=\"px-4 py-3 text-sm text-text-tertiary\">\n No lessons in this module yet.\n </p>\n ) : (\n <ul className=\"divide-y divide-border-seam\">\n {lessons.map((lesson) => (\n <LessonRow key={lesson.id} lesson={lesson} />\n ))}\n </ul>\n )}\n </div>\n );\n })}\n\n {ungroupedLessons.length > 0 && (\n <div className=\"overflow-hidden rounded-xl border border-border-seam bg-bg-surface\">\n <div className=\"border-b border-border-seam px-4 py-3\">\n <h3 className=\"font-semibold text-text-primary\">Additional lessons</h3>\n </div>\n <ul className=\"divide-y divide-border-seam\">\n {ungroupedLessons.map((lesson) => (\n <LessonRow key={lesson.id} lesson={lesson} />\n ))}\n </ul>\n </div>\n )}\n </div>\n )}\n </Section>\n\n {/* Assessments */}\n <Section title=\"Assessments\" description=\"Quizzes and exams for this course.\">\n {assessmentsQuery.isLoading ? (\n <LoadingView label=\"Loading assessments…\" />\n ) : assessmentsQuery.isError ? (\n <ErrorView\n title=\"Couldn't load assessments\"\n message={\n assessmentsQuery.error instanceof Error ? assessmentsQuery.error.message : undefined\n }\n onRetry={() => assessmentsQuery.refetch()}\n />\n ) : assessments.length === 0 ? (\n <EmptyState\n illustration=\"empty-data\"\n icon={<ClipboardCheck className=\"h-9 w-9\" />}\n title=\"No assessments yet\"\n description=\"This course doesn't have any quizzes or exams attached.\"\n />\n ) : (\n <ul className=\"flex flex-col gap-3\">\n {assessments.map((assessment) => (\n <li\n key={assessment.id}\n className=\"flex flex-wrap items-center gap-3 rounded-xl border border-border-seam bg-bg-surface p-4\"\n >\n <span className=\"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--color-accent-soft)] text-text-primary\">\n <ClipboardCheck className=\"h-4 w-4\" />\n </span>\n <div className=\"min-w-0 flex-1\">\n <p className=\"truncate font-medium text-text-primary\">{assessment.title}</p>\n <div className=\"mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-text-secondary\">\n <span>Pass ≥ {assessment.passingScore}%</span>\n <span>\n {assessment.maxAttempts} attempt\n {assessment.maxAttempts === 1 ? '' : 's'}\n </span>\n {assessment.timeLimitMin > 0 && (\n <span className=\"inline-flex items-center gap-1\">\n <Clock className=\"h-3.5 w-3.5\" />\n {formatDuration(assessment.timeLimitMin)}\n </span>\n )}\n </div>\n </div>\n <div className=\"flex shrink-0 items-center gap-2\">\n <StatusBadge status={assessment.type} />\n <StatusBadge status={assessment.status} />\n </div>\n </li>\n ))}\n </ul>\n )}\n </Section>\n\n {/* Tags (platform wspace-tags) */}\n <BuiTags entityType={COURSE_ENTITY} entityId={courseId} />\n\n {/* Discussion (platform wspace-conversations) */}\n <BuiDiscussion\n entityType={COURSE_ENTITY}\n entityId={courseId}\n description=\"Ask questions and discuss this course with your workspace.\"\n />\n </div>\n </PageLayout>\n );\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAuDA,IAAM,IAAgB,oBAGhB,IAAiD;CACrD,SAAS;CACT,OAAO;CACP,MAAM;CACN,YAAY;CACZ,aAAa;CACb,cAAc;CACf,EAEK,KAAwC,EAAE,gBAG5C,kBAAC,MAAD;CAAI,WAAU;WAAd;EACE,kBAAC,QAAD;GAAM,WAAU;aACd,kBAJO,EAAY,EAAO,SAAS,GAInC,EAAM,WAAU,WAAY,CAAA;GACvB,CAAA;EACP,kBAAC,OAAD;GAAK,WAAU;aAAf,CACE,kBAAC,KAAD;IAAG,WAAU;cAAkD,EAAO;IAAU,CAAA,EAChF,kBAAC,KAAD;IAAG,WAAU;cAA8B,EAAgB,EAAO,KAAK;IAAK,CAAA,CACxE;;EACL,EAAO,aACN,kBAAC,QAAD;GAAM,WAAU;aAA6G;GAEtH,CAAA;EAER,EAAO,cAAc,KACpB,kBAAC,QAAD;GAAM,WAAU;aAAhB;IACE,kBAAC,GAAD,EAAO,WAAU,eAAgB,CAAA;;IAAE,EAAe,EAAO,YAAY;IAChE;;EAEN;IAII,UAA6B;CACxC,IAAM,EAAE,gBAAa,GAAiC,EAChD,EAAE,gBAAa,kBAAe,GAAc,EAC5C,IAAY,GAAa,IAEzB,IAAc,EAAa,EAAS,EACpC,IAAe,EAAc,EAAS,EACtC,IAAmB,EAAkB,EAAS,EAC9C,IAAmB,EAAkB;EACzC;EACA;EACA,SAAS,GAAQ,KAAY;EAC9B,CAAC,EAEI,IAAS,GAAc,EACvB,CAAC,GAAY,KAAiB,EAAwB,KAAK,EAE3D,IAAS,EAAY,QAAQ,MAC7B,IAAU,EAAa,QAAQ,EAAE,EACjC,IAAc,EAAiB,QAAQ,EAAE,EACzC,IAAc,EAAiB,MAAM,SAAS,EAAE,EAGhD,IAAmB,QACjB,EAAY,MAAM,MAAM,EAAE,WAAW,aAAa,EAAE,WAAW,UAAU,EAC/E,CAAC,EAAY,CACd,EAGK,IAAmB,SAChB,GAAQ,WAAW,EAAE,EAAE,QAAQ,MAAW,CAAC,EAAO,SAAS,EAClE,CAAC,EAAO,CACT,EAEK,IAAe,GAAQ,SAAS,UAAU;AAuDhD,QAzCK,IAQD,EAAY,YAEZ,kBAAC,GAAD;EAAY,OAAM;YAChB,kBAAC,GAAD,EAAa,OAAM,mBAAoB,CAAA;EAC5B,CAAA,GAIb,EAAY,UAEZ,kBAAC,GAAD;EAAY,OAAM;YAChB,kBAAC,GAAD;GACE,OAAM;GACN,SAAS,EAAY,iBAAiB,QAAQ,EAAY,MAAM,UAAU,KAAA;GAC1E,eAAe,EAAY,SAAS;GACpC,CAAA;EACS,CAAA,GAIZ,IAcH,kBAAC,GAAD;EAAY,OAAO,EAAO;YACxB,kBAAC,OAAD;GAAK,WAAU;aAAf;IACE,kBAAC,UAAD;KACE,MAAK;KACL,eAAe,EAAW,UAAU;KACpC,WAAU;eAHZ,CAKE,kBAAC,GAAD,EAAW,WAAU,WAAY,CAAA,EAAA,mBAC1B;;IAGT,kBAAC,GAAD;KAAW,WAAU;KAAQ,WAAU;eACrC,kBAAC,OAAD;MAAK,WAAU;gBAAf,CACE,kBAAC,OAAD;OAAK,WAAU;iBACZ,EAAO,OAAO,MACb,kBAAC,OAAD;QAAK,KAAK,EAAO,MAAM;QAAK,KAAI;QAAG,WAAU;QAA+B,CAAA,GAE5E,kBAAC,OAAD;QAAK,WAAU;kBACb,kBAAC,GAAD,EAAU,WAAU,4CAA6C,CAAA;QAC7D,CAAA;OAEJ,CAAA,EAEN,kBAAC,OAAD;OAAK,WAAU;iBAAf;QACE,kBAAC,OAAD;SAAK,WAAU;mBAAf,CACE,kBAAC,GAAD,EAAa,QAAQ,EAAO,OAAS,CAAA,EACpC,EAAO,OAAO,QACb,kBAAC,QAAD;UAAM,WAAU;oBAAhB;WACE,kBAAC,GAAD,EAAU,WAAU,eAAgB,CAAA;;WAAE,EAAO,MAAM;WAC9C;YAEL;;QAEN,kBAAC,OAAD,EAAA,UAAA,CACE,kBAAC,MAAD;SAAI,WAAU;mBAA4C,EAAO;SAAW,CAAA,EAC3E,EAAO,WACN,kBAAC,KAAD;SAAG,WAAU;mBAAoC,EAAO;SAAY,CAAA,CAElE,EAAA,CAAA;QAEN,kBAAC,OAAD;SAAK,WAAU;mBAAf,CACE,kBAAC,QAAD;UAAM,WAAU;oBAAhB;WACE,kBAAC,GAAD,EAAQ,WAAU,eAAgB,CAAA;;WAAE,EAAQ;WAAO;WAClD,EAAQ,WAAW,IAAI,KAAK;WAAI;WAAI;WAAa;WACjD,MAAiB,IAAI,KAAK;WACtB;aACN,EAAO,iBAAiB,KACvB,kBAAC,QAAD;UAAM,WAAU;oBAAhB;WACE,kBAAC,GAAD,EAAO,WAAU,eAAgB,CAAA;;WAAE,EAAY,EAAO,eAAe;WAChE;YAEL;;QAEL,EAAO,KAAK,SAAS,KACpB,kBAAC,OAAD;SAAK,WAAU;mBACZ,EAAO,KAAK,KAAK,MAChB,kBAAC,QAAD;UAEE,WAAU;oBAET;UACI,EAJA,EAIA,CACP;SACE,CAAA;QAGP,EAAO,eACN,kBAAC,KAAD;SAAG,WAAU;mBACV,EAAO;SACN,CAAA;QAIN,kBAAC,OAAD;SAAK,WAAU;mBACZ,IACC,kBAAA,GAAA,EAAA,UAAA,CACE,kBAAC,GAAD;UACE,MAAK;UACL,WAAU;UACV,eAAe,EAAW,YAAY,EAAiB,KAAK;oBAH9D,CAKE,kBAAC,GAAD,EAAY,WAAU,gBAAiB,CAAA,EAAA,qBAChC;aACT,kBAAC,OAAD;UAAK,WAAU;oBAAf,CACE,kBAAC,GAAD;WAAS,OAAO,EAAiB;WAAa,WAAU;WAAW,CAAA,EACnE,kBAAC,QAAD;WAAM,WAAU;qBAAhB,CACG,EAAiB,aAAY,aACzB;aACH;YACL,EAAA,CAAA,GAEH,kBAAA,GAAA,EAAA,UAAA;UACE,kBAAC,GAAD;WACE,MAAK;WACL,WAAU;WACV,SArJA,YAA2B;AACzC,mBAAC,KAAY,CAAC,IAClB;eAAc,KAAK;AACnB,iBAAI;AAEF,gBAAW,aADK,MAAM,EAAO,YAAY;eAAE;eAAU;eAAW,CAAC,EAClC,KAAK;sBAC7B,GAAK;AACZ,gBAAc,aAAe,QAAQ,EAAI,UAAU,kCAAkC;;;;WA+IrE,UAAU,EAAO,aAAa,CAAC;qBAJjC,CAME,kBAAC,GAAD,EAAe,WAAU,gBAAiB,CAAA,EACzC,EAAO,YAAY,eAAe,uBAC5B;;UACR,CAAC,KACA,kBAAC,KAAD;WAAG,WAAU;qBAA6B;WAEtC,CAAA;UAEL,KAAc,kBAAC,KAAD;WAAG,WAAU;qBAAkC;WAAe,CAAA;UAC5E,EAAA,CAAA;SAED,CAAA;QACF;SACF;;KACI,CAAA;IAGZ,kBAAC,GAAD;KAAS,OAAM;KAAa,aAAY;eACrC,EAAa,YACZ,kBAAC,GAAD,EAAa,OAAM,uBAAwB,CAAA,GACzC,EAAa,UACf,kBAAC,GAAD;MACE,OAAM;MACN,SAAS,EAAa,iBAAiB,QAAQ,EAAa,MAAM,UAAU,KAAA;MAC5E,eAAe,EAAa,SAAS;MACrC,CAAA,GACA,EAAQ,WAAW,KAAK,EAAiB,WAAW,IACtD,kBAAC,GAAD;MACE,cAAa;MACb,OAAM;MACN,aAAY;MACZ,CAAA,GAEF,kBAAC,OAAD;MAAK,WAAU;gBAAf,CACG,EAAQ,KAAK,GAAQ,MAAU;OAC9B,IAAM,IAAU,EAAO,WAAW,EAAE;AACpC,cACE,kBAAC,OAAD;QAEE,WAAU;kBAFZ,CAIE,kBAAC,OAAD;SAAK,WAAU;mBAAf,CACE,kBAAC,OAAD;UAAK,WAAU;oBAAf,CACE,kBAAC,MAAD;WAAI,WAAU;qBAAd,CACE,kBAAC,QAAD;YAAM,WAAU;sBACb,OAAO,IAAQ,EAAE,CAAC,SAAS,GAAG,IAAI;YAC9B,CAAA,EACN,EAAO,MACL;cACL,kBAAC,QAAD;WAAM,WAAU;qBAAhB;YACG,EAAQ;YAAO;YAAQ,EAAQ,WAAW,IAAI,KAAK;YAC/C;aACH;aACL,EAAO,eACN,kBAAC,KAAD;UAAG,WAAU;oBAAoC,EAAO;UAAgB,CAAA,CAEtE;YACL,EAAQ,WAAW,IAClB,kBAAC,KAAD;SAAG,WAAU;mBAAuC;SAEhD,CAAA,GAEJ,kBAAC,MAAD;SAAI,WAAU;mBACX,EAAQ,KAAK,MACZ,kBAAC,GAAD,EAAmC,WAAU,EAA7B,EAAO,GAAsB,CAC7C;SACC,CAAA,CAEH;UA9BC,EAAO,GA8BR;QAER,EAED,EAAiB,SAAS,KACzB,kBAAC,OAAD;OAAK,WAAU;iBAAf,CACE,kBAAC,OAAD;QAAK,WAAU;kBACb,kBAAC,MAAD;SAAI,WAAU;mBAAkC;SAAuB,CAAA;QACnE,CAAA,EACN,kBAAC,MAAD;QAAI,WAAU;kBACX,EAAiB,KAAK,MACrB,kBAAC,GAAD,EAAmC,WAAU,EAA7B,EAAO,GAAsB,CAC7C;QACC,CAAA,CACD;SAEJ;;KAEA,CAAA;IAGV,kBAAC,GAAD;KAAS,OAAM;KAAc,aAAY;eACtC,EAAiB,YAChB,kBAAC,GAAD,EAAa,OAAM,wBAAyB,CAAA,GAC1C,EAAiB,UACnB,kBAAC,GAAD;MACE,OAAM;MACN,SACE,EAAiB,iBAAiB,QAAQ,EAAiB,MAAM,UAAU,KAAA;MAE7E,eAAe,EAAiB,SAAS;MACzC,CAAA,GACA,EAAY,WAAW,IACzB,kBAAC,GAAD;MACE,cAAa;MACb,MAAM,kBAAC,GAAD,EAAgB,WAAU,WAAY,CAAA;MAC5C,OAAM;MACN,aAAY;MACZ,CAAA,GAEF,kBAAC,MAAD;MAAI,WAAU;gBACX,EAAY,KAAK,MAChB,kBAAC,MAAD;OAEE,WAAU;iBAFZ;QAIE,kBAAC,QAAD;SAAM,WAAU;mBACd,kBAAC,GAAD,EAAgB,WAAU,WAAY,CAAA;SACjC,CAAA;QACP,kBAAC,OAAD;SAAK,WAAU;mBAAf,CACE,kBAAC,KAAD;UAAG,WAAU;oBAA0C,EAAW;UAAU,CAAA,EAC5E,kBAAC,OAAD;UAAK,WAAU;oBAAf;WACE,kBAAC,QAAD,EAAA,UAAA;YAAM;YAAQ,EAAW;YAAa;YAAQ,EAAA,CAAA;WAC9C,kBAAC,QAAD,EAAA,UAAA;YACG,EAAW;YAAY;YACvB,EAAW,gBAAgB,IAAI,KAAK;YAChC,EAAA,CAAA;WACN,EAAW,eAAe,KACzB,kBAAC,QAAD;YAAM,WAAU;sBAAhB,CACE,kBAAC,GAAD,EAAO,WAAU,eAAgB,CAAA,EAChC,EAAe,EAAW,aAAa,CACnC;;WAEL;YACF;;QACN,kBAAC,OAAD;SAAK,WAAU;mBAAf,CACE,kBAAC,GAAD,EAAa,QAAQ,EAAW,MAAQ,CAAA,EACxC,kBAAC,GAAD,EAAa,QAAQ,EAAW,QAAU,CAAA,CACtC;;QACH;SA1BE,EAAW,GA0Bb,CACL;MACC,CAAA;KAEC,CAAA;IAGV,kBAAC,GAAD;KAAS,YAAY;KAAe,UAAU;KAAY,CAAA;IAG1D,kBAAC,GAAD;KACE,YAAY;KACZ,UAAU;KACV,aAAY;KACZ,CAAA;IACE;;EACK,CAAA,GAvQX,kBAAC,GAAD;EAAY,OAAM;YAChB,kBAAC,GAAD;GACE,cAAa;GACb,OAAM;GACN,aAAY;GACZ,QAAQ,kBAAC,GAAD;IAAQ,eAAe,EAAW,UAAU;cAAE;IAAwB,CAAA;GAC9E,CAAA;EACS,CAAA,GAnCb,kBAAC,GAAD;EAAY,OAAM;YAChB,kBAAC,GAAD;GAAW,OAAM;GAAiB,SAAQ;GAA6B,CAAA;EAC5D,CAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CohortsPage.js","names":[],"sources":["../../../../src/buildmyiq/pages/cohorts/CohortsPage.tsx"],"sourcesContent":["/**\n * CohortsPage — scheduled learning cohorts for the workspace.\n * @module buildmyiq/pages/cohorts\n *\n * The primary surface of the Cohorts section. Lists real `BuiCohort` rows for\n * the workspace (optionally scoped to a course), lets facilitators filter by\n * lifecycle status, and create a new cohort. Every value is derived from live\n * `bui*` data — enrolled counts are NOT queryable per-cohort, so we only ever\n * surface the declared `capacity` (never a fabricated enrolment number).\n *\n * Reads: `buiListCohorts` (useBuiCohorts), `buiListCourses` (useBuiCourses).\n * Writes: `buiCreateCohort` (useBuiCreateCohort).\n */\n\nimport { useMemo, useState, type FC } from 'react';\nimport { Plus, Users, CalendarRange } from 'lucide-react';\nimport {\n Button,\n Label,\n Input,\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n ResponsiveDialog,\n ResponsiveDialogContent,\n ResponsiveDialogHeader,\n ResponsiveDialogTitle,\n ResponsiveDialogDescription,\n ResponsiveDialogFooter,\n} from '@burdenoff/fe-libs/ui';\nimport {\n PageLayout,\n StatusBadge,\n DataTable,\n type Column,\n FilterTabs,\n type FilterOption,\n LoadingView,\n ErrorView,\n EmptyState,\n} from '../../components';\nimport { useBuildmyiq } from '../../context';\nimport { useBuiCohorts, useBuiCourses, useBuiCreateCohort } from '../../hooks';\nimport { formatDate, formatEnumLabel, formatNumber } from '../../utils/formatters';\nimport { COHORT_STATUSES, type BuiCohort, type BuiCohortStatus } from '../../types';\n\ntype StatusFilter = BuiCohortStatus | 'ALL';\ntype CourseFilter = string | 'ALL';\n\n/** Convert a native `<input type=\"date\">` value (YYYY-MM-DD) to a DateTime, or undefined. */\nconst toDateTime = (value: string): string | undefined => {\n const trimmed = value.trim();\n if (!trimmed) return undefined;\n const d = new Date(trimmed);\n return Number.isNaN(d.getTime()) ? undefined : d.toISOString();\n};\n\ninterface CreateFormState {\n courseId: string;\n name: string;\n capacity: string;\n facilitatorId: string;\n startDate: string;\n endDate: string;\n}\n\nexport const CohortsPage: FC = () => {\n const { currentUser } = useBuildmyiq();\n\n const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');\n const [courseFilter, setCourseFilter] = useState<CourseFilter>('ALL');\n const [dialogOpen, setDialogOpen] = useState(false);\n const [formError, setFormError] = useState<string | null>(null);\n\n const emptyForm = useMemo<CreateFormState>(\n () => ({\n courseId: '',\n name: '',\n capacity: '',\n facilitatorId: currentUser?.id ?? '',\n startDate: '',\n endDate: '',\n }),\n [currentUser?.id]\n );\n const [form, setForm] = useState<CreateFormState>(emptyForm);\n\n // Course lookup: powers the course filter, the create-form course picker, and\n // the courseId → title mapping in the table.\n const coursesQuery = useBuiCourses({ limit: 100 });\n const courses = useMemo(() => coursesQuery.data?.items ?? [], [coursesQuery.data]);\n const courseTitleById = useMemo(() => {\n const map = new Map<string, string>();\n for (const c of courses) map.set(c.id, c.title);\n return map;\n }, [courses]);\n\n // Fetch cohorts scoped only by course — status is filtered client-side so the\n // status tabs can carry accurate per-status counts from a single fetch.\n const cohortsQuery = useBuiCohorts(courseFilter === 'ALL' ? undefined : courseFilter);\n const createCohort = useBuiCreateCohort();\n\n const allCohorts = useMemo(() => cohortsQuery.data ?? [], [cohortsQuery.data]);\n\n const statusCounts = useMemo(() => {\n const counts: Record<string, number> = {};\n for (const c of allCohorts) counts[c.status] = (counts[c.status] ?? 0) + 1;\n return counts;\n }, [allCohorts]);\n\n const rows = useMemo(\n () =>\n statusFilter === 'ALL'\n ? allCohorts\n : allCohorts.filter((c) => c.status === statusFilter),\n [allCohorts, statusFilter]\n );\n\n const statusOptions: FilterOption<StatusFilter>[] = useMemo(\n () => [\n { value: 'ALL', label: 'All', count: allCohorts.length },\n ...COHORT_STATUSES.map((s) => ({\n value: s,\n label: formatEnumLabel(s),\n count: statusCounts[s] ?? 0,\n })),\n ],\n [allCohorts.length, statusCounts]\n );\n\n const columns: Column<BuiCohort>[] = useMemo(\n () => [\n {\n key: 'name',\n header: 'Cohort',\n sortValue: (c) => c.name.toLowerCase(),\n render: (c) => (\n <div className=\"flex items-center gap-3\">\n <div className=\"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--color-accent-soft)] text-accent\">\n <Users className=\"h-4 w-4\" />\n </div>\n <span className=\"min-w-0 truncate font-semibold text-text-primary\">{c.name}</span>\n </div>\n ),\n },\n {\n key: 'course',\n header: 'Course',\n hideOnMobile: true,\n sortValue: (c) => (courseTitleById.get(c.courseId) ?? '').toLowerCase(),\n render: (c) => (\n <span className=\"text-text-secondary\">{courseTitleById.get(c.courseId) ?? '—'}</span>\n ),\n },\n {\n key: 'capacity',\n header: 'Capacity',\n align: 'right',\n sortValue: (c) => c.capacity,\n render: (c) => (\n <span className=\"tabular-nums text-text-secondary\">{formatNumber(c.capacity ?? 0)}</span>\n ),\n },\n {\n key: 'status',\n header: 'Status',\n render: (c) => <StatusBadge status={c.status} />,\n },\n {\n key: 'startDate',\n header: 'Starts',\n align: 'right',\n hideOnMobile: true,\n sortValue: (c) => c.startDate ?? '',\n render: (c) => <span className=\"text-text-tertiary\">{formatDate(c.startDate)}</span>,\n },\n {\n key: 'endDate',\n header: 'Ends',\n align: 'right',\n hideOnMobile: true,\n sortValue: (c) => c.endDate ?? '',\n render: (c) => <span className=\"text-text-tertiary\">{formatDate(c.endDate)}</span>,\n },\n ],\n [courseTitleById]\n );\n\n const openDialog = () => {\n setForm(emptyForm);\n setFormError(null);\n setDialogOpen(true);\n };\n\n const closeDialog = () => {\n setDialogOpen(false);\n setForm(emptyForm);\n setFormError(null);\n };\n\n const handleCreate = async () => {\n if (!form.courseId) {\n setFormError('Please choose a course for this cohort.');\n return;\n }\n if (!form.name.trim()) {\n setFormError('Cohort name is required.');\n return;\n }\n const capacityNum = form.capacity.trim() ? Number(form.capacity) : undefined;\n if (capacityNum !== undefined && (!Number.isFinite(capacityNum) || capacityNum < 0)) {\n setFormError('Capacity must be a non-negative number.');\n return;\n }\n const startIso = toDateTime(form.startDate);\n const endIso = toDateTime(form.endDate);\n if (startIso && endIso && new Date(endIso) < new Date(startIso)) {\n setFormError('End date cannot be before the start date.');\n return;\n }\n\n setFormError(null);\n try {\n await createCohort.mutateAsync({\n courseId: form.courseId,\n name: form.name.trim(),\n capacity: capacityNum,\n facilitatorId: form.facilitatorId.trim() || undefined,\n startDate: startIso,\n endDate: endIso,\n });\n closeDialog();\n } catch (err) {\n setFormError(err instanceof Error ? err.message : 'Failed to create cohort.');\n }\n };\n\n const hasCourses = courses.length > 0;\n\n return (\n <PageLayout\n title=\"Cohorts\"\n description=\"Scheduled, facilitator-led groups that move through a course together.\"\n titleGradient\n aurora\n actions={\n <Button onClick={openDialog} disabled={!hasCourses}>\n <Plus className=\"mr-1.5 h-4 w-4\" /> New cohort\n </Button>\n }\n >\n <div className=\"mb-5 flex flex-wrap items-center gap-3\">\n <FilterTabs options={statusOptions} value={statusFilter} onChange={setStatusFilter} />\n <Select\n value={courseFilter}\n onValueChange={(v) => setCourseFilter(v as CourseFilter)}\n >\n <SelectTrigger className=\"w-56\" aria-label=\"Filter by course\">\n <SelectValue placeholder=\"All courses\" />\n </SelectTrigger>\n <SelectContent>\n <SelectItem value=\"ALL\">All courses</SelectItem>\n {courses.map((c) => (\n <SelectItem key={c.id} value={c.id}>\n {c.title}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {cohortsQuery.isLoading ? (\n <LoadingView label=\"Loading cohorts…\" />\n ) : cohortsQuery.isError ? (\n <ErrorView message=\"Couldn't load cohorts.\" onRetry={() => cohortsQuery.refetch()} />\n ) : rows.length === 0 ? (\n <EmptyState\n illustration={statusFilter === 'ALL' && courseFilter === 'ALL' ? 'onboarding' : 'empty-search'}\n icon={<CalendarRange className=\"h-10 w-10\" />}\n title={\n statusFilter === 'ALL' && courseFilter === 'ALL'\n ? 'No cohorts yet'\n : 'No cohorts match these filters'\n }\n description={\n statusFilter === 'ALL' && courseFilter === 'ALL'\n ? hasCourses\n ? 'Schedule a cohort to run a course with a group of learners on a shared timeline.'\n : 'Publish a course first, then schedule a cohort to run it with a group of learners.'\n : 'Try a different status or course to see more cohorts.'\n }\n action={\n statusFilter === 'ALL' && courseFilter === 'ALL' && hasCourses ? (\n <Button onClick={openDialog}>\n <Plus className=\"mr-1.5 h-4 w-4\" /> New cohort\n </Button>\n ) : undefined\n }\n />\n ) : (\n <DataTable\n columns={columns}\n rows={rows}\n rowKey={(c) => c.id}\n defaultSort={{ key: 'startDate', dir: 'desc' }}\n />\n )}\n\n <ResponsiveDialog\n open={dialogOpen}\n onOpenChange={(open) => {\n if (!open) closeDialog();\n else setDialogOpen(true);\n }}\n >\n <ResponsiveDialogContent>\n <ResponsiveDialogHeader>\n <ResponsiveDialogTitle>New cohort</ResponsiveDialogTitle>\n <ResponsiveDialogDescription>\n Schedule a facilitator-led group to run a course together.\n </ResponsiveDialogDescription>\n </ResponsiveDialogHeader>\n\n <form\n onSubmit={(e) => {\n e.preventDefault();\n void handleCreate();\n }}\n >\n <div className=\"grid gap-4 py-2\">\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"cohort-course\">Course *</Label>\n <Select\n value={form.courseId}\n onValueChange={(v) => setForm((prev) => ({ ...prev, courseId: v }))}\n >\n <SelectTrigger id=\"cohort-course\">\n <SelectValue placeholder=\"Select a course\" />\n </SelectTrigger>\n <SelectContent>\n {courses.map((c) => (\n <SelectItem key={c.id} value={c.id}>\n {c.title}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"cohort-name\">Name *</Label>\n <Input\n id=\"cohort-name\"\n value={form.name}\n onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}\n placeholder=\"Spring 2026 Cohort\"\n />\n </div>\n\n <div className=\"grid gap-2 sm:grid-cols-2\">\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"cohort-capacity\">Capacity</Label>\n <Input\n id=\"cohort-capacity\"\n type=\"number\"\n min=\"0\"\n step=\"1\"\n value={form.capacity}\n onChange={(e) => setForm((prev) => ({ ...prev, capacity: e.target.value }))}\n placeholder=\"25\"\n />\n </div>\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"cohort-facilitator\">Facilitator (user ID)</Label>\n <Input\n id=\"cohort-facilitator\"\n value={form.facilitatorId}\n onChange={(e) =>\n setForm((prev) => ({ ...prev, facilitatorId: e.target.value }))\n }\n placeholder=\"Defaults to you\"\n />\n </div>\n </div>\n\n <div className=\"grid gap-2 sm:grid-cols-2\">\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"cohort-start\">Start date</Label>\n <Input\n id=\"cohort-start\"\n type=\"date\"\n value={form.startDate}\n onChange={(e) => setForm((prev) => ({ ...prev, startDate: e.target.value }))}\n />\n </div>\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"cohort-end\">End date</Label>\n <Input\n id=\"cohort-end\"\n type=\"date\"\n value={form.endDate}\n onChange={(e) => setForm((prev) => ({ ...prev, endDate: e.target.value }))}\n />\n </div>\n </div>\n\n {formError && <p className=\"text-sm text-status-error-text\">{formError}</p>}\n </div>\n\n <ResponsiveDialogFooter>\n <Button type=\"button\" variant=\"outline\" onClick={closeDialog}>\n Cancel\n </Button>\n <Button type=\"submit\" disabled={createCohort.isPending}>\n {createCohort.isPending ? 'Creating…' : 'Create cohort'}\n </Button>\n </ResponsiveDialogFooter>\n </form>\n </ResponsiveDialogContent>\n </ResponsiveDialog>\n </PageLayout>\n );\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"CohortsPage.js","names":[],"sources":["../../../../src/buildmyiq/pages/cohorts/CohortsPage.tsx"],"sourcesContent":["/**\n * CohortsPage — scheduled learning cohorts for the workspace.\n * @module buildmyiq/pages/cohorts\n *\n * The primary surface of the Cohorts section. Lists real `BuiCohort` rows for\n * the workspace (optionally scoped to a course), lets facilitators filter by\n * lifecycle status, and create a new cohort. Every value is derived from live\n * `bui*` data — enrolled counts are NOT queryable per-cohort, so we only ever\n * surface the declared `capacity` (never a fabricated enrolment number).\n *\n * Reads: `buiListCohorts` (useBuiCohorts), `buiListCourses` (useBuiCourses).\n * Writes: `buiCreateCohort` (useBuiCreateCohort).\n */\n\nimport { useMemo, useState, type FC } from 'react';\nimport { Plus, Users, CalendarRange } from 'lucide-react';\nimport {\n Button,\n Label,\n Input,\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n ResponsiveDialog,\n ResponsiveDialogContent,\n ResponsiveDialogHeader,\n ResponsiveDialogTitle,\n ResponsiveDialogDescription,\n ResponsiveDialogFooter,\n} from '@burdenoff/fe-libs/ui';\nimport {\n PageLayout,\n StatusBadge,\n DataTable,\n type Column,\n FilterTabs,\n type FilterOption,\n LoadingView,\n ErrorView,\n EmptyState,\n} from '../../components';\nimport { useBuildmyiq } from '../../context';\nimport { useBuiCohorts, useBuiCourses, useBuiCreateCohort } from '../../hooks';\nimport { formatDate, formatEnumLabel, formatNumber } from '../../utils/formatters';\nimport { COHORT_STATUSES, type BuiCohort, type BuiCohortStatus } from '../../types';\n\ntype StatusFilter = BuiCohortStatus | 'ALL';\ntype CourseFilter = string | 'ALL';\n\n/** Convert a native `<input type=\"date\">` value (YYYY-MM-DD) to a DateTime, or undefined. */\nconst toDateTime = (value: string): string | undefined => {\n const trimmed = value.trim();\n if (!trimmed) return undefined;\n const d = new Date(trimmed);\n return Number.isNaN(d.getTime()) ? undefined : d.toISOString();\n};\n\ninterface CreateFormState {\n courseId: string;\n name: string;\n capacity: string;\n facilitatorId: string;\n startDate: string;\n endDate: string;\n}\n\nexport const CohortsPage: FC = () => {\n const { currentUser } = useBuildmyiq();\n\n const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');\n const [courseFilter, setCourseFilter] = useState<CourseFilter>('ALL');\n const [dialogOpen, setDialogOpen] = useState(false);\n const [formError, setFormError] = useState<string | null>(null);\n\n const emptyForm = useMemo<CreateFormState>(\n () => ({\n courseId: '',\n name: '',\n capacity: '',\n facilitatorId: currentUser?.id ?? '',\n startDate: '',\n endDate: '',\n }),\n [currentUser?.id]\n );\n const [form, setForm] = useState<CreateFormState>(emptyForm);\n\n // Course lookup: powers the course filter, the create-form course picker, and\n // the courseId → title mapping in the table.\n const coursesQuery = useBuiCourses({ limit: 100 });\n const courses = useMemo(() => coursesQuery.data?.items ?? [], [coursesQuery.data]);\n const courseTitleById = useMemo(() => {\n const map = new Map<string, string>();\n for (const c of courses) map.set(c.id, c.title);\n return map;\n }, [courses]);\n\n // Fetch cohorts scoped only by course — status is filtered client-side so the\n // status tabs can carry accurate per-status counts from a single fetch.\n const cohortsQuery = useBuiCohorts(courseFilter === 'ALL' ? undefined : courseFilter);\n const createCohort = useBuiCreateCohort();\n\n const allCohorts = useMemo(() => cohortsQuery.data ?? [], [cohortsQuery.data]);\n\n const statusCounts = useMemo(() => {\n const counts: Record<string, number> = {};\n for (const c of allCohorts) counts[c.status] = (counts[c.status] ?? 0) + 1;\n return counts;\n }, [allCohorts]);\n\n const rows = useMemo(\n () =>\n statusFilter === 'ALL' ? allCohorts : allCohorts.filter((c) => c.status === statusFilter),\n [allCohorts, statusFilter]\n );\n\n const statusOptions: FilterOption<StatusFilter>[] = useMemo(\n () => [\n { value: 'ALL', label: 'All', count: allCohorts.length },\n ...COHORT_STATUSES.map((s) => ({\n value: s,\n label: formatEnumLabel(s),\n count: statusCounts[s] ?? 0,\n })),\n ],\n [allCohorts.length, statusCounts]\n );\n\n const columns: Column<BuiCohort>[] = useMemo(\n () => [\n {\n key: 'name',\n header: 'Cohort',\n sortValue: (c) => c.name.toLowerCase(),\n render: (c) => (\n <div className=\"flex items-center gap-3\">\n <div className=\"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--color-accent-soft)] text-accent\">\n <Users className=\"h-4 w-4\" />\n </div>\n <span className=\"min-w-0 truncate font-semibold text-text-primary\">{c.name}</span>\n </div>\n ),\n },\n {\n key: 'course',\n header: 'Course',\n hideOnMobile: true,\n sortValue: (c) => (courseTitleById.get(c.courseId) ?? '').toLowerCase(),\n render: (c) => (\n <span className=\"text-text-secondary\">{courseTitleById.get(c.courseId) ?? '—'}</span>\n ),\n },\n {\n key: 'capacity',\n header: 'Capacity',\n align: 'right',\n sortValue: (c) => c.capacity,\n render: (c) => (\n <span className=\"tabular-nums text-text-secondary\">{formatNumber(c.capacity ?? 0)}</span>\n ),\n },\n {\n key: 'status',\n header: 'Status',\n render: (c) => <StatusBadge status={c.status} />,\n },\n {\n key: 'startDate',\n header: 'Starts',\n align: 'right',\n hideOnMobile: true,\n sortValue: (c) => c.startDate ?? '',\n render: (c) => <span className=\"text-text-tertiary\">{formatDate(c.startDate)}</span>,\n },\n {\n key: 'endDate',\n header: 'Ends',\n align: 'right',\n hideOnMobile: true,\n sortValue: (c) => c.endDate ?? '',\n render: (c) => <span className=\"text-text-tertiary\">{formatDate(c.endDate)}</span>,\n },\n ],\n [courseTitleById]\n );\n\n const openDialog = () => {\n setForm(emptyForm);\n setFormError(null);\n setDialogOpen(true);\n };\n\n const closeDialog = () => {\n setDialogOpen(false);\n setForm(emptyForm);\n setFormError(null);\n };\n\n const handleCreate = async () => {\n if (!form.courseId) {\n setFormError('Please choose a course for this cohort.');\n return;\n }\n if (!form.name.trim()) {\n setFormError('Cohort name is required.');\n return;\n }\n const capacityNum = form.capacity.trim() ? Number(form.capacity) : undefined;\n if (capacityNum !== undefined && (!Number.isFinite(capacityNum) || capacityNum < 0)) {\n setFormError('Capacity must be a non-negative number.');\n return;\n }\n const startIso = toDateTime(form.startDate);\n const endIso = toDateTime(form.endDate);\n if (startIso && endIso && new Date(endIso) < new Date(startIso)) {\n setFormError('End date cannot be before the start date.');\n return;\n }\n\n setFormError(null);\n try {\n await createCohort.mutateAsync({\n courseId: form.courseId,\n name: form.name.trim(),\n capacity: capacityNum,\n facilitatorId: form.facilitatorId.trim() || undefined,\n startDate: startIso,\n endDate: endIso,\n });\n closeDialog();\n } catch (err) {\n setFormError(err instanceof Error ? err.message : 'Failed to create cohort.');\n }\n };\n\n const hasCourses = courses.length > 0;\n\n return (\n <PageLayout\n title=\"Cohorts\"\n description=\"Scheduled, facilitator-led groups that move through a course together.\"\n titleGradient\n aurora\n actions={\n <Button onClick={openDialog} disabled={!hasCourses}>\n <Plus className=\"mr-1.5 h-4 w-4\" /> New cohort\n </Button>\n }\n >\n <div className=\"mb-5 flex flex-wrap items-center gap-3\">\n <FilterTabs options={statusOptions} value={statusFilter} onChange={setStatusFilter} />\n <Select value={courseFilter} onValueChange={(v) => setCourseFilter(v as CourseFilter)}>\n <SelectTrigger className=\"w-56\" aria-label=\"Filter by course\">\n <SelectValue placeholder=\"All courses\" />\n </SelectTrigger>\n <SelectContent>\n <SelectItem value=\"ALL\">All courses</SelectItem>\n {courses.map((c) => (\n <SelectItem key={c.id} value={c.id}>\n {c.title}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {cohortsQuery.isLoading ? (\n <LoadingView label=\"Loading cohorts…\" />\n ) : cohortsQuery.isError ? (\n <ErrorView message=\"Couldn't load cohorts.\" onRetry={() => cohortsQuery.refetch()} />\n ) : rows.length === 0 ? (\n <EmptyState\n illustration={\n statusFilter === 'ALL' && courseFilter === 'ALL' ? 'onboarding' : 'empty-search'\n }\n icon={<CalendarRange className=\"h-10 w-10\" />}\n title={\n statusFilter === 'ALL' && courseFilter === 'ALL'\n ? 'No cohorts yet'\n : 'No cohorts match these filters'\n }\n description={\n statusFilter === 'ALL' && courseFilter === 'ALL'\n ? hasCourses\n ? 'Schedule a cohort to run a course with a group of learners on a shared timeline.'\n : 'Publish a course first, then schedule a cohort to run it with a group of learners.'\n : 'Try a different status or course to see more cohorts.'\n }\n action={\n statusFilter === 'ALL' && courseFilter === 'ALL' && hasCourses ? (\n <Button onClick={openDialog}>\n <Plus className=\"mr-1.5 h-4 w-4\" /> New cohort\n </Button>\n ) : undefined\n }\n />\n ) : (\n <DataTable\n columns={columns}\n rows={rows}\n rowKey={(c) => c.id}\n defaultSort={{ key: 'startDate', dir: 'desc' }}\n />\n )}\n\n <ResponsiveDialog\n open={dialogOpen}\n onOpenChange={(open) => {\n if (!open) closeDialog();\n else setDialogOpen(true);\n }}\n >\n <ResponsiveDialogContent>\n <ResponsiveDialogHeader>\n <ResponsiveDialogTitle>New cohort</ResponsiveDialogTitle>\n <ResponsiveDialogDescription>\n Schedule a facilitator-led group to run a course together.\n </ResponsiveDialogDescription>\n </ResponsiveDialogHeader>\n\n <form\n onSubmit={(e) => {\n e.preventDefault();\n void handleCreate();\n }}\n >\n <div className=\"grid gap-4 py-2\">\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"cohort-course\">Course *</Label>\n <Select\n value={form.courseId}\n onValueChange={(v) => setForm((prev) => ({ ...prev, courseId: v }))}\n >\n <SelectTrigger id=\"cohort-course\">\n <SelectValue placeholder=\"Select a course\" />\n </SelectTrigger>\n <SelectContent>\n {courses.map((c) => (\n <SelectItem key={c.id} value={c.id}>\n {c.title}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"cohort-name\">Name *</Label>\n <Input\n id=\"cohort-name\"\n value={form.name}\n onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}\n placeholder=\"Spring 2026 Cohort\"\n />\n </div>\n\n <div className=\"grid gap-2 sm:grid-cols-2\">\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"cohort-capacity\">Capacity</Label>\n <Input\n id=\"cohort-capacity\"\n type=\"number\"\n min=\"0\"\n step=\"1\"\n value={form.capacity}\n onChange={(e) => setForm((prev) => ({ ...prev, capacity: e.target.value }))}\n placeholder=\"25\"\n />\n </div>\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"cohort-facilitator\">Facilitator (user ID)</Label>\n <Input\n id=\"cohort-facilitator\"\n value={form.facilitatorId}\n onChange={(e) =>\n setForm((prev) => ({ ...prev, facilitatorId: e.target.value }))\n }\n placeholder=\"Defaults to you\"\n />\n </div>\n </div>\n\n <div className=\"grid gap-2 sm:grid-cols-2\">\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"cohort-start\">Start date</Label>\n <Input\n id=\"cohort-start\"\n type=\"date\"\n value={form.startDate}\n onChange={(e) => setForm((prev) => ({ ...prev, startDate: e.target.value }))}\n />\n </div>\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"cohort-end\">End date</Label>\n <Input\n id=\"cohort-end\"\n type=\"date\"\n value={form.endDate}\n onChange={(e) => setForm((prev) => ({ ...prev, endDate: e.target.value }))}\n />\n </div>\n </div>\n\n {formError && <p className=\"text-sm text-status-error-text\">{formError}</p>}\n </div>\n\n <ResponsiveDialogFooter>\n <Button type=\"button\" variant=\"outline\" onClick={closeDialog}>\n Cancel\n </Button>\n <Button type=\"submit\" disabled={createCohort.isPending}>\n {createCohort.isPending ? 'Creating…' : 'Create cohort'}\n </Button>\n </ResponsiveDialogFooter>\n </form>\n </ResponsiveDialogContent>\n </ResponsiveDialog>\n </PageLayout>\n );\n};\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"LessonPlayerPage.js","names":[],"sources":["../../../../src/buildmyiq/pages/learning/LessonPlayerPage.tsx"],"sourcesContent":["/**\n * LessonPlayerPage — the two-pane lesson player for a single enrollment.\n * @module buildmyiq/pages/learning\n *\n * Left pane: the course's module/lesson tree (from `buiCourse`) with a completed\n * check driven by the enrollment's `lessonProgress`. Right pane: the selected\n * lesson (title, type, content text rendered safely, and a `FileLink` for any\n * attached media). Opening a lesson records IN_PROGRESS once; \"Mark complete\"\n * records COMPLETED; finishing every lesson transitions the enrollment to\n * COMPLETED. All data is live — no fabrication.\n */\n\nimport type { FC } from 'react';\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport { useParams } from 'react-router-dom';\nimport {\n ArrowLeft,\n ChevronLeft,\n ChevronRight,\n CheckCircle2,\n Circle,\n ListChecks,\n Trophy,\n Loader2,\n} from 'lucide-react';\nimport { Button, GlassCard } from '@burdenoff/fe-libs/ui';\nimport {\n PageLayout,\n StatusBadge,\n MiniBar,\n LoadingView,\n ErrorView,\n EmptyState,\n FileLink,\n} from '../../components';\nimport { useBuildmyiq } from '../../context';\nimport {\n useBuiEnrollment,\n useBuiCourse,\n useBuiRecordLessonProgress,\n useBuiTransitionEnrollment,\n} from '../../hooks';\nimport { cn } from '../../utils/cn';\nimport { formatDuration } from '../../utils/formatters';\nimport type { BuiLesson, BuiModule, BuiProgressStatus } from '../../types';\n\ninterface LessonGroup {\n id: string;\n title: string;\n lessons: BuiLesson[];\n}\n\nconst sortByPosition = <T extends { position: number }>(items: T[]): T[] =>\n [...items].sort((a, b) => a.position - b.position);\n\nexport const LessonPlayerPage: FC = () => {\n const { enrollmentId } = useParams<{ enrollmentId: string }>();\n const { navigateTo } = useBuildmyiq();\n\n const enrollmentQuery = useBuiEnrollment(enrollmentId);\n const enrollment = enrollmentQuery.data ?? null;\n const courseId = enrollment?.courseId;\n const courseQuery = useBuiCourse(courseId);\n const course = courseQuery.data ?? null;\n\n const recordProgress = useBuiRecordLessonProgress();\n const transitionEnrollment = useBuiTransitionEnrollment();\n\n // react-query's mutate is referentially stable across renders, so it can be\n // used directly in effects without a render-time ref write.\n const recordMutate = recordProgress.mutate;\n const autoRecordedRef = useRef<Set<string>>(new Set());\n\n const [userSelectedId, setUserSelectedId] = useState<string | null>(null);\n const [showTree, setShowTree] = useState(false);\n\n // Grouped tree (modules → lessons) plus any standalone lessons, de-duplicated.\n const groups = useMemo<LessonGroup[]>(() => {\n if (!course) return [];\n const seen = new Set<string>();\n const modules: BuiModule[] = sortByPosition(course.modules ?? []);\n const result: LessonGroup[] = [];\n for (const m of modules) {\n const lessons = sortByPosition(m.lessons ?? []).filter((l) => {\n if (seen.has(l.id)) return false;\n seen.add(l.id);\n return true;\n });\n if (lessons.length > 0) result.push({ id: m.id, title: m.title, lessons });\n }\n const standalone = sortByPosition(course.lessons ?? []).filter((l) => {\n if (seen.has(l.id)) return false;\n seen.add(l.id);\n return true;\n });\n if (standalone.length > 0) {\n result.push({ id: '__standalone', title: 'Lessons', lessons: standalone });\n }\n return result;\n }, [course]);\n\n // Flat, ordered lesson list for prev/next + completion checks.\n const orderedLessons = useMemo<BuiLesson[]>(\n () => groups.flatMap((g) => g.lessons),\n [groups]\n );\n\n const progressByLesson = useMemo(() => {\n const map = new Map<string, BuiProgressStatus>();\n for (const p of enrollment?.lessonProgress ?? []) map.set(p.lessonId, p.status);\n return map;\n }, [enrollment]);\n\n // Resolve the active lesson as DERIVED state: the user's pick if it still\n // exists in the course, otherwise the first lesson. Avoids a setState-in-\n // effect re-anchor loop.\n const selectedLessonId = useMemo<string | null>(() => {\n if (orderedLessons.length === 0) return null;\n if (userSelectedId && orderedLessons.some((l) => l.id === userSelectedId)) {\n return userSelectedId;\n }\n return orderedLessons[0].id;\n }, [orderedLessons, userSelectedId]);\n\n // Record IN_PROGRESS once when a not-yet-started lesson is opened.\n useEffect(() => {\n if (!enrollmentId || !selectedLessonId) return;\n const status = progressByLesson.get(selectedLessonId);\n if (status === 'COMPLETED' || status === 'IN_PROGRESS') return;\n if (autoRecordedRef.current.has(selectedLessonId)) return;\n autoRecordedRef.current.add(selectedLessonId);\n recordMutate({\n enrollmentId,\n lessonId: selectedLessonId,\n status: 'IN_PROGRESS',\n });\n }, [enrollmentId, selectedLessonId, progressByLesson, recordMutate]);\n\n const selectedIndex = orderedLessons.findIndex((l) => l.id === selectedLessonId);\n const selectedLesson = selectedIndex >= 0 ? orderedLessons[selectedIndex] : null;\n const selectedStatus = selectedLesson ? progressByLesson.get(selectedLesson.id) : undefined;\n const isSelectedComplete = selectedStatus === 'COMPLETED';\n\n const allComplete =\n orderedLessons.length > 0 &&\n orderedLessons.every((l) => progressByLesson.get(l.id) === 'COMPLETED');\n const alreadyFinished = enrollment?.status === 'COMPLETED';\n\n const handleSelect = (lessonId: string) => {\n setUserSelectedId(lessonId);\n setShowTree(false);\n };\n\n const goTo = (offset: number) => {\n if (selectedIndex < 0) return;\n const next = orderedLessons[selectedIndex + offset];\n if (next) handleSelect(next.id);\n };\n\n const handleMarkComplete = async () => {\n if (!enrollmentId || !selectedLesson) return;\n await recordProgress.mutateAsync({\n enrollmentId,\n lessonId: selectedLesson.id,\n status: 'COMPLETED',\n });\n // Advance to the next incomplete lesson for a natural flow.\n const next = orderedLessons[selectedIndex + 1];\n if (next) setUserSelectedId(next.id);\n };\n\n const handleFinishCourse = async () => {\n if (!enrollmentId) return;\n await transitionEnrollment.mutateAsync({ id: enrollmentId, status: 'COMPLETED' });\n };\n\n // ---- top-level states ----------------------------------------------------\n if (!enrollmentId) {\n return (\n <PageLayout title=\"Lesson\">\n <EmptyState\n illustration=\"empty-data\"\n title=\"No enrollment selected\"\n description=\"Open a course from My Learning to start the lesson player.\"\n action={<Button onClick={() => navigateTo('learning')}>Back to My Learning</Button>}\n />\n </PageLayout>\n );\n }\n\n const isLoading = enrollmentQuery.isLoading || (Boolean(courseId) && courseQuery.isLoading);\n\n const backAction = (\n <Button variant=\"ghost\" size=\"sm\" onClick={() => navigateTo('learning')}>\n <ArrowLeft className=\"mr-1 h-4 w-4\" /> My Learning\n </Button>\n );\n\n if (isLoading) {\n return (\n <PageLayout title=\"Lesson\" actions={backAction}>\n <LoadingView label=\"Loading your lesson…\" />\n </PageLayout>\n );\n }\n\n if (enrollmentQuery.isError) {\n return (\n <PageLayout title=\"Lesson\" actions={backAction}>\n <ErrorView\n title=\"Couldn't load this enrollment\"\n message={enrollmentQuery.error instanceof Error ? enrollmentQuery.error.message : undefined}\n onRetry={() => enrollmentQuery.refetch()}\n />\n </PageLayout>\n );\n }\n\n if (!enrollment) {\n return (\n <PageLayout title=\"Lesson\" actions={backAction}>\n <EmptyState\n illustration=\"empty-data\"\n title=\"Enrollment not found\"\n description=\"This enrollment may have been removed or is not available to you.\"\n action={<Button onClick={() => navigateTo('learning')}>Back to My Learning</Button>}\n />\n </PageLayout>\n );\n }\n\n if (courseQuery.isError) {\n return (\n <PageLayout title=\"Lesson\" actions={backAction}>\n <ErrorView\n title=\"Couldn't load the course\"\n message={courseQuery.error instanceof Error ? courseQuery.error.message : undefined}\n onRetry={() => courseQuery.refetch()}\n />\n </PageLayout>\n );\n }\n\n const progressPct = Math.round(enrollment.progressPct ?? 0);\n\n return (\n <PageLayout title={course?.title ?? 'Lesson'} actions={backAction}>\n <div className=\"flex flex-col gap-5\">\n {/* Header progress */}\n <div className=\"flex flex-col gap-2 rounded-2xl border border-border-seam bg-bg-surface p-4 sm:flex-row sm:items-center sm:gap-4\">\n <div className=\"flex items-center gap-2\">\n <StatusBadge status={enrollment.status} />\n <span className=\"text-sm text-text-secondary\">\n {orderedLessons.filter((l) => progressByLesson.get(l.id) === 'COMPLETED').length} /{' '}\n {orderedLessons.length} lessons complete\n </span>\n </div>\n <div className=\"flex flex-1 items-center gap-3\">\n <MiniBar value={progressPct} className=\"flex-1\" />\n <span className=\"shrink-0 text-sm font-medium tabular-nums text-text-primary\">\n {progressPct}%\n </span>\n </div>\n {allComplete && !alreadyFinished && (\n <Button\n size=\"sm\"\n className=\"shrink-0 max-sm:w-full\"\n onClick={handleFinishCourse}\n disabled={transitionEnrollment.isPending}\n >\n {transitionEnrollment.isPending ? (\n <Loader2 className=\"mr-1 h-4 w-4 animate-spin\" />\n ) : (\n <Trophy className=\"mr-1 h-4 w-4\" />\n )}\n Finish course\n </Button>\n )}\n </div>\n\n {/* Mobile tree toggle */}\n <Button\n variant=\"secondary\"\n size=\"sm\"\n className=\"w-full lg:hidden\"\n onClick={() => setShowTree((v) => !v)}\n >\n <ListChecks className=\"mr-1 h-4 w-4\" />\n {showTree ? 'Hide lessons' : 'Show lessons'}\n </Button>\n\n <div className=\"flex flex-col gap-5 lg:flex-row\">\n {/* Left: module / lesson tree */}\n <aside\n className={cn(\n 'lg:block lg:w-80 lg:shrink-0',\n showTree ? 'block' : 'hidden'\n )}\n >\n <GlassCard treatment=\"plain\" className=\"overflow-hidden p-0 lg:sticky lg:top-4\">\n {orderedLessons.length === 0 ? (\n <div className=\"p-4 text-sm text-text-secondary\">\n This course has no lessons yet.\n </div>\n ) : (\n <nav className=\"max-h-[70vh] overflow-auto p-2\">\n {groups.map((group) => (\n <div key={group.id} className=\"mb-2 last:mb-0\">\n <p className=\"px-3 py-2 text-xs font-semibold uppercase tracking-wide text-text-secondary\">\n {group.title}\n </p>\n <ul className=\"flex flex-col gap-0.5\">\n {group.lessons.map((lesson) => {\n const done = progressByLesson.get(lesson.id) === 'COMPLETED';\n const active = lesson.id === selectedLessonId;\n return (\n <li key={lesson.id}>\n <button\n type=\"button\"\n onClick={() => handleSelect(lesson.id)}\n className={cn(\n 'flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm transition-colors',\n active\n ? 'bg-[var(--color-accent-soft)] text-text-primary'\n : 'text-text-secondary hover:bg-bg-sunken hover:text-text-primary'\n )}\n aria-current={active ? 'true' : undefined}\n >\n {done ? (\n <CheckCircle2 className=\"h-4 w-4 shrink-0 text-status-success-text\" />\n ) : (\n <Circle className=\"h-4 w-4 shrink-0 opacity-50\" />\n )}\n <span className=\"min-w-0 flex-1 truncate\">{lesson.title}</span>\n {lesson.durationMin > 0 && (\n <span className=\"shrink-0 text-xs tabular-nums text-text-secondary\">\n {formatDuration(lesson.durationMin)}\n </span>\n )}\n </button>\n </li>\n );\n })}\n </ul>\n </div>\n ))}\n </nav>\n )}\n </GlassCard>\n </aside>\n\n {/* Right: selected lesson */}\n <main className=\"min-w-0 flex-1\">\n {!selectedLesson ? (\n <EmptyState\n illustration=\"empty-data\"\n title=\"No lesson to show\"\n description=\"This course doesn't have any lessons yet. Check back once the author adds content.\"\n />\n ) : (\n <GlassCard treatment=\"plain\" className=\"flex flex-col gap-4 p-5 sm:p-6\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <StatusBadge status={selectedLesson.type} />\n {selectedLesson.durationMin > 0 && (\n <span className=\"text-xs text-text-secondary\">\n {formatDuration(selectedLesson.durationMin)}\n </span>\n )}\n {isSelectedComplete && (\n <span className=\"inline-flex items-center gap-1 text-xs font-medium text-status-success-text\">\n <CheckCircle2 className=\"h-3.5 w-3.5\" /> Completed\n </span>\n )}\n </div>\n\n <h2 className=\"text-xl font-semibold text-text-primary\">{selectedLesson.title}</h2>\n\n {selectedLesson.media ? (\n <div className=\"rounded-xl border border-border-seam bg-bg-sunken p-3\">\n <FileLink fileId={selectedLesson.media.id} />\n </div>\n ) : null}\n\n {selectedLesson.content ? (\n <p className=\"whitespace-pre-wrap text-sm leading-relaxed text-text-primary\">\n {selectedLesson.content}\n </p>\n ) : (\n <p className=\"text-sm text-text-secondary\">\n This lesson has no written content\n {selectedLesson.media ? ' beyond the attached media' : ''}.\n </p>\n )}\n\n {/* Actions */}\n <div className=\"mt-2 flex flex-col gap-3 border-t border-border-seam pt-4 sm:flex-row sm:items-center sm:justify-between\">\n <div className=\"flex items-center gap-2\">\n <Button\n variant=\"secondary\"\n size=\"sm\"\n onClick={() => goTo(-1)}\n disabled={selectedIndex <= 0}\n >\n <ChevronLeft className=\"mr-1 h-4 w-4\" /> Prev\n </Button>\n <Button\n variant=\"secondary\"\n size=\"sm\"\n onClick={() => goTo(1)}\n disabled={selectedIndex >= orderedLessons.length - 1}\n >\n Next <ChevronRight className=\"ml-1 h-4 w-4\" />\n </Button>\n </div>\n {!isSelectedComplete && (\n <Button\n size=\"sm\"\n className=\"max-sm:w-full\"\n onClick={handleMarkComplete}\n disabled={recordProgress.isPending}\n >\n {recordProgress.isPending ? (\n <Loader2 className=\"mr-1 h-4 w-4 animate-spin\" />\n ) : (\n <CheckCircle2 className=\"mr-1 h-4 w-4\" />\n )}\n Mark complete\n </Button>\n )}\n </div>\n </GlassCard>\n )}\n </main>\n </div>\n </div>\n </PageLayout>\n );\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"LessonPlayerPage.js","names":[],"sources":["../../../../src/buildmyiq/pages/learning/LessonPlayerPage.tsx"],"sourcesContent":["/**\n * LessonPlayerPage — the two-pane lesson player for a single enrollment.\n * @module buildmyiq/pages/learning\n *\n * Left pane: the course's module/lesson tree (from `buiCourse`) with a completed\n * check driven by the enrollment's `lessonProgress`. Right pane: the selected\n * lesson (title, type, content text rendered safely, and a `FileLink` for any\n * attached media). Opening a lesson records IN_PROGRESS once; \"Mark complete\"\n * records COMPLETED; finishing every lesson transitions the enrollment to\n * COMPLETED. All data is live — no fabrication.\n */\n\nimport type { FC } from 'react';\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport { useParams } from 'react-router-dom';\nimport {\n ArrowLeft,\n ChevronLeft,\n ChevronRight,\n CheckCircle2,\n Circle,\n ListChecks,\n Trophy,\n Loader2,\n} from 'lucide-react';\nimport { Button, GlassCard } from '@burdenoff/fe-libs/ui';\nimport {\n PageLayout,\n StatusBadge,\n MiniBar,\n LoadingView,\n ErrorView,\n EmptyState,\n FileLink,\n} from '../../components';\nimport { useBuildmyiq } from '../../context';\nimport {\n useBuiEnrollment,\n useBuiCourse,\n useBuiRecordLessonProgress,\n useBuiTransitionEnrollment,\n} from '../../hooks';\nimport { cn } from '../../utils/cn';\nimport { formatDuration } from '../../utils/formatters';\nimport type { BuiLesson, BuiModule, BuiProgressStatus } from '../../types';\n\ninterface LessonGroup {\n id: string;\n title: string;\n lessons: BuiLesson[];\n}\n\nconst sortByPosition = <T extends { position: number }>(items: T[]): T[] =>\n [...items].sort((a, b) => a.position - b.position);\n\nexport const LessonPlayerPage: FC = () => {\n const { enrollmentId } = useParams<{ enrollmentId: string }>();\n const { navigateTo } = useBuildmyiq();\n\n const enrollmentQuery = useBuiEnrollment(enrollmentId);\n const enrollment = enrollmentQuery.data ?? null;\n const courseId = enrollment?.courseId;\n const courseQuery = useBuiCourse(courseId);\n const course = courseQuery.data ?? null;\n\n const recordProgress = useBuiRecordLessonProgress();\n const transitionEnrollment = useBuiTransitionEnrollment();\n\n // react-query's mutate is referentially stable across renders, so it can be\n // used directly in effects without a render-time ref write.\n const recordMutate = recordProgress.mutate;\n const autoRecordedRef = useRef<Set<string>>(new Set());\n\n const [userSelectedId, setUserSelectedId] = useState<string | null>(null);\n const [showTree, setShowTree] = useState(false);\n\n // Grouped tree (modules → lessons) plus any standalone lessons, de-duplicated.\n const groups = useMemo<LessonGroup[]>(() => {\n if (!course) return [];\n const seen = new Set<string>();\n const modules: BuiModule[] = sortByPosition(course.modules ?? []);\n const result: LessonGroup[] = [];\n for (const m of modules) {\n const lessons = sortByPosition(m.lessons ?? []).filter((l) => {\n if (seen.has(l.id)) return false;\n seen.add(l.id);\n return true;\n });\n if (lessons.length > 0) result.push({ id: m.id, title: m.title, lessons });\n }\n const standalone = sortByPosition(course.lessons ?? []).filter((l) => {\n if (seen.has(l.id)) return false;\n seen.add(l.id);\n return true;\n });\n if (standalone.length > 0) {\n result.push({ id: '__standalone', title: 'Lessons', lessons: standalone });\n }\n return result;\n }, [course]);\n\n // Flat, ordered lesson list for prev/next + completion checks.\n const orderedLessons = useMemo<BuiLesson[]>(() => groups.flatMap((g) => g.lessons), [groups]);\n\n const progressByLesson = useMemo(() => {\n const map = new Map<string, BuiProgressStatus>();\n for (const p of enrollment?.lessonProgress ?? []) map.set(p.lessonId, p.status);\n return map;\n }, [enrollment]);\n\n // Resolve the active lesson as DERIVED state: the user's pick if it still\n // exists in the course, otherwise the first lesson. Avoids a setState-in-\n // effect re-anchor loop.\n const selectedLessonId = useMemo<string | null>(() => {\n if (orderedLessons.length === 0) return null;\n if (userSelectedId && orderedLessons.some((l) => l.id === userSelectedId)) {\n return userSelectedId;\n }\n return orderedLessons[0].id;\n }, [orderedLessons, userSelectedId]);\n\n // Record IN_PROGRESS once when a not-yet-started lesson is opened.\n useEffect(() => {\n if (!enrollmentId || !selectedLessonId) return;\n const status = progressByLesson.get(selectedLessonId);\n if (status === 'COMPLETED' || status === 'IN_PROGRESS') return;\n if (autoRecordedRef.current.has(selectedLessonId)) return;\n autoRecordedRef.current.add(selectedLessonId);\n recordMutate({\n enrollmentId,\n lessonId: selectedLessonId,\n status: 'IN_PROGRESS',\n });\n }, [enrollmentId, selectedLessonId, progressByLesson, recordMutate]);\n\n const selectedIndex = orderedLessons.findIndex((l) => l.id === selectedLessonId);\n const selectedLesson = selectedIndex >= 0 ? orderedLessons[selectedIndex] : null;\n const selectedStatus = selectedLesson ? progressByLesson.get(selectedLesson.id) : undefined;\n const isSelectedComplete = selectedStatus === 'COMPLETED';\n\n const allComplete =\n orderedLessons.length > 0 &&\n orderedLessons.every((l) => progressByLesson.get(l.id) === 'COMPLETED');\n const alreadyFinished = enrollment?.status === 'COMPLETED';\n\n const handleSelect = (lessonId: string) => {\n setUserSelectedId(lessonId);\n setShowTree(false);\n };\n\n const goTo = (offset: number) => {\n if (selectedIndex < 0) return;\n const next = orderedLessons[selectedIndex + offset];\n if (next) handleSelect(next.id);\n };\n\n const handleMarkComplete = async () => {\n if (!enrollmentId || !selectedLesson) return;\n await recordProgress.mutateAsync({\n enrollmentId,\n lessonId: selectedLesson.id,\n status: 'COMPLETED',\n });\n // Advance to the next incomplete lesson for a natural flow.\n const next = orderedLessons[selectedIndex + 1];\n if (next) setUserSelectedId(next.id);\n };\n\n const handleFinishCourse = async () => {\n if (!enrollmentId) return;\n await transitionEnrollment.mutateAsync({ id: enrollmentId, status: 'COMPLETED' });\n };\n\n // ---- top-level states ----------------------------------------------------\n if (!enrollmentId) {\n return (\n <PageLayout title=\"Lesson\">\n <EmptyState\n illustration=\"empty-data\"\n title=\"No enrollment selected\"\n description=\"Open a course from My Learning to start the lesson player.\"\n action={<Button onClick={() => navigateTo('learning')}>Back to My Learning</Button>}\n />\n </PageLayout>\n );\n }\n\n const isLoading = enrollmentQuery.isLoading || (Boolean(courseId) && courseQuery.isLoading);\n\n const backAction = (\n <Button variant=\"ghost\" size=\"sm\" onClick={() => navigateTo('learning')}>\n <ArrowLeft className=\"mr-1 h-4 w-4\" /> My Learning\n </Button>\n );\n\n if (isLoading) {\n return (\n <PageLayout title=\"Lesson\" actions={backAction}>\n <LoadingView label=\"Loading your lesson…\" />\n </PageLayout>\n );\n }\n\n if (enrollmentQuery.isError) {\n return (\n <PageLayout title=\"Lesson\" actions={backAction}>\n <ErrorView\n title=\"Couldn't load this enrollment\"\n message={\n enrollmentQuery.error instanceof Error ? enrollmentQuery.error.message : undefined\n }\n onRetry={() => enrollmentQuery.refetch()}\n />\n </PageLayout>\n );\n }\n\n if (!enrollment) {\n return (\n <PageLayout title=\"Lesson\" actions={backAction}>\n <EmptyState\n illustration=\"empty-data\"\n title=\"Enrollment not found\"\n description=\"This enrollment may have been removed or is not available to you.\"\n action={<Button onClick={() => navigateTo('learning')}>Back to My Learning</Button>}\n />\n </PageLayout>\n );\n }\n\n if (courseQuery.isError) {\n return (\n <PageLayout title=\"Lesson\" actions={backAction}>\n <ErrorView\n title=\"Couldn't load the course\"\n message={courseQuery.error instanceof Error ? courseQuery.error.message : undefined}\n onRetry={() => courseQuery.refetch()}\n />\n </PageLayout>\n );\n }\n\n const progressPct = Math.round(enrollment.progressPct ?? 0);\n\n return (\n <PageLayout title={course?.title ?? 'Lesson'} actions={backAction}>\n <div className=\"flex flex-col gap-5\">\n {/* Header progress */}\n <div className=\"flex flex-col gap-2 rounded-2xl border border-border-seam bg-bg-surface p-4 sm:flex-row sm:items-center sm:gap-4\">\n <div className=\"flex items-center gap-2\">\n <StatusBadge status={enrollment.status} />\n <span className=\"text-sm text-text-secondary\">\n {orderedLessons.filter((l) => progressByLesson.get(l.id) === 'COMPLETED').length} /{' '}\n {orderedLessons.length} lessons complete\n </span>\n </div>\n <div className=\"flex flex-1 items-center gap-3\">\n <MiniBar value={progressPct} className=\"flex-1\" />\n <span className=\"shrink-0 text-sm font-medium tabular-nums text-text-primary\">\n {progressPct}%\n </span>\n </div>\n {allComplete && !alreadyFinished && (\n <Button\n size=\"sm\"\n className=\"shrink-0 max-sm:w-full\"\n onClick={handleFinishCourse}\n disabled={transitionEnrollment.isPending}\n >\n {transitionEnrollment.isPending ? (\n <Loader2 className=\"mr-1 h-4 w-4 animate-spin\" />\n ) : (\n <Trophy className=\"mr-1 h-4 w-4\" />\n )}\n Finish course\n </Button>\n )}\n </div>\n\n {/* Mobile tree toggle */}\n <Button\n variant=\"secondary\"\n size=\"sm\"\n className=\"w-full lg:hidden\"\n onClick={() => setShowTree((v) => !v)}\n >\n <ListChecks className=\"mr-1 h-4 w-4\" />\n {showTree ? 'Hide lessons' : 'Show lessons'}\n </Button>\n\n <div className=\"flex flex-col gap-5 lg:flex-row\">\n {/* Left: module / lesson tree */}\n <aside className={cn('lg:block lg:w-80 lg:shrink-0', showTree ? 'block' : 'hidden')}>\n <GlassCard treatment=\"plain\" className=\"overflow-hidden p-0 lg:sticky lg:top-4\">\n {orderedLessons.length === 0 ? (\n <div className=\"p-4 text-sm text-text-secondary\">\n This course has no lessons yet.\n </div>\n ) : (\n <nav className=\"max-h-[70vh] overflow-auto p-2\">\n {groups.map((group) => (\n <div key={group.id} className=\"mb-2 last:mb-0\">\n <p className=\"px-3 py-2 text-xs font-semibold uppercase tracking-wide text-text-secondary\">\n {group.title}\n </p>\n <ul className=\"flex flex-col gap-0.5\">\n {group.lessons.map((lesson) => {\n const done = progressByLesson.get(lesson.id) === 'COMPLETED';\n const active = lesson.id === selectedLessonId;\n return (\n <li key={lesson.id}>\n <button\n type=\"button\"\n onClick={() => handleSelect(lesson.id)}\n className={cn(\n 'flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm transition-colors',\n active\n ? 'bg-[var(--color-accent-soft)] text-text-primary'\n : 'text-text-secondary hover:bg-bg-sunken hover:text-text-primary'\n )}\n aria-current={active ? 'true' : undefined}\n >\n {done ? (\n <CheckCircle2 className=\"h-4 w-4 shrink-0 text-status-success-text\" />\n ) : (\n <Circle className=\"h-4 w-4 shrink-0 opacity-50\" />\n )}\n <span className=\"min-w-0 flex-1 truncate\">{lesson.title}</span>\n {lesson.durationMin > 0 && (\n <span className=\"shrink-0 text-xs tabular-nums text-text-secondary\">\n {formatDuration(lesson.durationMin)}\n </span>\n )}\n </button>\n </li>\n );\n })}\n </ul>\n </div>\n ))}\n </nav>\n )}\n </GlassCard>\n </aside>\n\n {/* Right: selected lesson */}\n <main className=\"min-w-0 flex-1\">\n {!selectedLesson ? (\n <EmptyState\n illustration=\"empty-data\"\n title=\"No lesson to show\"\n description=\"This course doesn't have any lessons yet. Check back once the author adds content.\"\n />\n ) : (\n <GlassCard treatment=\"plain\" className=\"flex flex-col gap-4 p-5 sm:p-6\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <StatusBadge status={selectedLesson.type} />\n {selectedLesson.durationMin > 0 && (\n <span className=\"text-xs text-text-secondary\">\n {formatDuration(selectedLesson.durationMin)}\n </span>\n )}\n {isSelectedComplete && (\n <span className=\"inline-flex items-center gap-1 text-xs font-medium text-status-success-text\">\n <CheckCircle2 className=\"h-3.5 w-3.5\" /> Completed\n </span>\n )}\n </div>\n\n <h2 className=\"text-xl font-semibold text-text-primary\">{selectedLesson.title}</h2>\n\n {selectedLesson.media ? (\n <div className=\"rounded-xl border border-border-seam bg-bg-sunken p-3\">\n <FileLink fileId={selectedLesson.media.id} />\n </div>\n ) : null}\n\n {selectedLesson.content ? (\n <p className=\"whitespace-pre-wrap text-sm leading-relaxed text-text-primary\">\n {selectedLesson.content}\n </p>\n ) : (\n <p className=\"text-sm text-text-secondary\">\n This lesson has no written content\n {selectedLesson.media ? ' beyond the attached media' : ''}.\n </p>\n )}\n\n {/* Actions */}\n <div className=\"mt-2 flex flex-col gap-3 border-t border-border-seam pt-4 sm:flex-row sm:items-center sm:justify-between\">\n <div className=\"flex items-center gap-2\">\n <Button\n variant=\"secondary\"\n size=\"sm\"\n onClick={() => goTo(-1)}\n disabled={selectedIndex <= 0}\n >\n <ChevronLeft className=\"mr-1 h-4 w-4\" /> Prev\n </Button>\n <Button\n variant=\"secondary\"\n size=\"sm\"\n onClick={() => goTo(1)}\n disabled={selectedIndex >= orderedLessons.length - 1}\n >\n Next <ChevronRight className=\"ml-1 h-4 w-4\" />\n </Button>\n </div>\n {!isSelectedComplete && (\n <Button\n size=\"sm\"\n className=\"max-sm:w-full\"\n onClick={handleMarkComplete}\n disabled={recordProgress.isPending}\n >\n {recordProgress.isPending ? (\n <Loader2 className=\"mr-1 h-4 w-4 animate-spin\" />\n ) : (\n <CheckCircle2 className=\"mr-1 h-4 w-4\" />\n )}\n Mark complete\n </Button>\n )}\n </div>\n </GlassCard>\n )}\n </main>\n </div>\n </div>\n </PageLayout>\n );\n};\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MyLearningPage.js","names":[],"sources":["../../../../src/buildmyiq/pages/learning/MyLearningPage.tsx"],"sourcesContent":["/**\n * MyLearningPage — the caller's personal learning surface.\n * @module buildmyiq/pages/learning\n *\n * Renders the current learner's real enrollments (`buiListEnrollments` filtered\n * by `learnerId`) grouped by progress, plus the credentials they have earned\n * (`buiListCertificates`). Course titles are resolved from `buiListCourses`\n * (id → title map, falling back to \"Course\"). Every list carries an honest\n * loading / error / empty state — no fabricated rows.\n */\n\nimport type { FC } from 'react';\nimport { useMemo, useState } from 'react';\nimport { GraduationCap, Award, ArrowRight, PlayCircle } from 'lucide-react';\nimport { Button, GlassCard } from '@burdenoff/fe-libs/ui';\nimport {\n PageLayout,\n Tabs,\n StatusBadge,\n MiniBar,\n EmptyState,\n LoadingView,\n ErrorView,\n Section,\n FileLink,\n} from '../../components';\nimport type { TabItem } from '../../components';\nimport { useBuildmyiq } from '../../context';\nimport { useBuiEnrollments, useBuiCourses, useBuiCertificates } from '../../hooks';\nimport { formatRelativeTime, formatDate } from '../../utils/formatters';\nimport type { BuiEnrollment, BuiCourse, BuiCertificate } from '../../types';\n\ntype LearningTab = 'in_progress' | 'completed' | 'all';\n\nconst IN_PROGRESS_STATUSES = new Set(['ENROLLED', 'IN_PROGRESS']);\n\nexport const MyLearningPage: FC = () => {\n const { currentUser, navigateTo } = useBuildmyiq();\n const learnerId = currentUser?.id;\n\n const [tab, setTab] = useState<LearningTab>('in_progress');\n\n const enrollmentsQuery = useBuiEnrollments({\n learnerId,\n limit: 100,\n enabled: Boolean(learnerId),\n });\n const coursesQuery = useBuiCourses({ limit: 100 });\n const certsQuery = useBuiCertificates(undefined, learnerId);\n\n const enrollments: BuiEnrollment[] = useMemo(\n () => enrollmentsQuery.data?.items ?? [],\n [enrollmentsQuery.data]\n );\n const courses: BuiCourse[] = useMemo(\n () => coursesQuery.data?.items ?? [],\n [coursesQuery.data]\n );\n const certificates: BuiCertificate[] = useMemo(() => certsQuery.data ?? [], [certsQuery.data]);\n\n const courseTitleById = useMemo(() => {\n const map = new Map<string, string>();\n for (const c of courses) map.set(c.id, c.title);\n return map;\n }, [courses]);\n\n const titleFor = (courseId: string): string => courseTitleById.get(courseId) ?? 'Course';\n\n const inProgress = useMemo(\n () => enrollments.filter((e) => IN_PROGRESS_STATUSES.has(e.status)),\n [enrollments]\n );\n const completed = useMemo(\n () => enrollments.filter((e) => e.status === 'COMPLETED'),\n [enrollments]\n );\n\n const visible = useMemo(() => {\n const rows = tab === 'in_progress' ? inProgress : tab === 'completed' ? completed : enrollments;\n return [...rows].sort((a, b) =>\n (b.lastActiveAt ?? b.enrolledAt).localeCompare(a.lastActiveAt ?? a.enrolledAt)\n );\n }, [tab, inProgress, completed, enrollments]);\n\n const sortedCertificates = useMemo(\n () => [...certificates].sort((a, b) => b.issuedAt.localeCompare(a.issuedAt)),\n [certificates]\n );\n\n const tabs: TabItem<LearningTab>[] = [\n { value: 'in_progress', label: 'In progress', count: inProgress.length },\n { value: 'completed', label: 'Completed', count: completed.length },\n { value: 'all', label: 'All', count: enrollments.length },\n ];\n\n const isLoading =\n Boolean(learnerId) && (enrollmentsQuery.isLoading || coursesQuery.isLoading);\n\n const renderEnrollments = () => {\n if (!learnerId) {\n return (\n <EmptyState\n illustration=\"empty-people\"\n title=\"Sign in to see your learning\"\n description=\"Your enrollments and progress appear here once you are signed in to the workspace.\"\n />\n );\n }\n if (isLoading) return <LoadingView label=\"Loading your enrollments…\" />;\n if (enrollmentsQuery.isError) {\n return (\n <ErrorView\n title=\"Couldn't load your learning\"\n message={enrollmentsQuery.error instanceof Error ? enrollmentsQuery.error.message : undefined}\n onRetry={() => enrollmentsQuery.refetch()}\n />\n );\n }\n if (enrollments.length === 0) {\n return (\n <EmptyState\n illustration=\"onboarding\"\n title=\"You're not enrolled in anything yet\"\n description=\"Browse the catalog and enrol in a course to start learning.\"\n action={<Button onClick={() => navigateTo('catalog')}>Explore the catalog</Button>}\n />\n );\n }\n if (visible.length === 0) {\n const label =\n tab === 'in_progress'\n ? 'No courses in progress'\n : tab === 'completed'\n ? 'No completed courses yet'\n : 'No courses';\n return (\n <EmptyState\n illustration=\"empty-data\"\n title={label}\n description={\n tab === 'completed'\n ? 'Finish a course to see it here — and earn a certificate.'\n : 'Switch tabs or enrol in a new course to keep learning.'\n }\n />\n );\n }\n return (\n <div className=\"flex flex-col gap-3\">\n {visible.map((e) => (\n <GlassCard\n key={e.id}\n treatment=\"plain\"\n className=\"flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:gap-4\"\n >\n <div className=\"flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[var(--color-accent-soft)]\">\n <PlayCircle className=\"h-5 w-5 text-text-primary\" />\n </div>\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <p className=\"truncate font-medium text-text-primary\">{titleFor(e.courseId)}</p>\n <StatusBadge status={e.status} />\n </div>\n <div className=\"mt-2 flex items-center gap-2\">\n <MiniBar value={e.progressPct} className=\"max-w-sm flex-1\" />\n <span className=\"shrink-0 text-xs tabular-nums text-text-secondary\">\n {Math.round(e.progressPct ?? 0)}%\n </span>\n </div>\n <p className=\"mt-1 text-xs text-text-secondary\">\n {e.status === 'COMPLETED' && e.completedAt\n ? `Completed ${formatRelativeTime(e.completedAt)}`\n : `Active ${formatRelativeTime(e.lastActiveAt ?? e.enrolledAt)}`}\n </p>\n </div>\n <Button\n variant=\"secondary\"\n size=\"sm\"\n className=\"shrink-0 max-sm:w-full\"\n onClick={() => navigateTo(`learning/${e.id}`)}\n >\n {e.status === 'COMPLETED' ? 'Review' : 'Continue'}\n <ArrowRight className=\"ml-1 h-4 w-4\" />\n </Button>\n </GlassCard>\n ))}\n </div>\n );\n };\n\n const renderCertificates = () => {\n if (!learnerId) return null;\n if (certsQuery.isLoading) return <LoadingView label=\"Loading your certificates…\" />;\n if (certsQuery.isError) {\n return (\n <ErrorView\n title=\"Couldn't load your certificates\"\n message={certsQuery.error instanceof Error ? certsQuery.error.message : undefined}\n onRetry={() => certsQuery.refetch()}\n />\n );\n }\n if (sortedCertificates.length === 0) {\n return (\n <EmptyState\n illustration=\"empty-data\"\n title=\"No certificates yet\"\n description=\"Complete a credentialed course to earn a verifiable certificate.\"\n />\n );\n }\n return (\n <div className=\"grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3\">\n {sortedCertificates.map((cert) => (\n <GlassCard key={cert.id} treatment=\"plain\" className=\"flex flex-col gap-3 p-4\">\n <div className=\"flex items-start justify-between gap-3\">\n <div className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[var(--color-accent-soft)]\">\n <Award className=\"h-5 w-5 text-text-primary\" />\n </div>\n <StatusBadge status={cert.status} />\n </div>\n <div className=\"min-w-0\">\n <p className=\"truncate font-medium text-text-primary\">{titleFor(cert.courseId)}</p>\n <p className=\"mt-0.5 font-mono text-xs text-text-secondary\">{cert.serial}</p>\n </div>\n <div className=\"mt-auto flex flex-wrap items-center justify-between gap-2 pt-1 text-xs text-text-secondary\">\n <span>Issued {formatDate(cert.issuedAt)}</span>\n {cert.file ? <FileLink fileId={cert.file.id} /> : null}\n </div>\n </GlassCard>\n ))}\n </div>\n );\n };\n\n return (\n <PageLayout\n title=\"My Learning\"\n description=\"Your enrollments, progress and the credentials you've earned.\"\n titleGradient\n aurora\n >\n <div className=\"flex flex-col gap-6\">\n <Section title=\"Courses\" description=\"Everything you're enrolled in.\">\n {learnerId && enrollments.length > 0 && (\n <div className=\"mb-4\">\n <Tabs items={tabs} value={tab} onChange={setTab} />\n </div>\n )}\n {renderEnrollments()}\n </Section>\n\n <Section\n title=\"Certificates\"\n description=\"Verifiable credentials issued for the courses you've completed.\"\n action={\n <Button variant=\"ghost\" size=\"sm\" onClick={() => navigateTo('certificates')}>\n <GraduationCap className=\"mr-1 h-4 w-4\" /> All certificates\n </Button>\n }\n >\n {renderCertificates()}\n </Section>\n </div>\n </PageLayout>\n );\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"MyLearningPage.js","names":[],"sources":["../../../../src/buildmyiq/pages/learning/MyLearningPage.tsx"],"sourcesContent":["/**\n * MyLearningPage — the caller's personal learning surface.\n * @module buildmyiq/pages/learning\n *\n * Renders the current learner's real enrollments (`buiListEnrollments` filtered\n * by `learnerId`) grouped by progress, plus the credentials they have earned\n * (`buiListCertificates`). Course titles are resolved from `buiListCourses`\n * (id → title map, falling back to \"Course\"). Every list carries an honest\n * loading / error / empty state — no fabricated rows.\n */\n\nimport type { FC } from 'react';\nimport { useMemo, useState } from 'react';\nimport { GraduationCap, Award, ArrowRight, PlayCircle } from 'lucide-react';\nimport { Button, GlassCard } from '@burdenoff/fe-libs/ui';\nimport {\n PageLayout,\n Tabs,\n StatusBadge,\n MiniBar,\n EmptyState,\n LoadingView,\n ErrorView,\n Section,\n FileLink,\n} from '../../components';\nimport type { TabItem } from '../../components';\nimport { useBuildmyiq } from '../../context';\nimport { useBuiEnrollments, useBuiCourses, useBuiCertificates } from '../../hooks';\nimport { formatRelativeTime, formatDate } from '../../utils/formatters';\nimport type { BuiEnrollment, BuiCourse, BuiCertificate } from '../../types';\n\ntype LearningTab = 'in_progress' | 'completed' | 'all';\n\nconst IN_PROGRESS_STATUSES = new Set(['ENROLLED', 'IN_PROGRESS']);\n\nexport const MyLearningPage: FC = () => {\n const { currentUser, navigateTo } = useBuildmyiq();\n const learnerId = currentUser?.id;\n\n const [tab, setTab] = useState<LearningTab>('in_progress');\n\n const enrollmentsQuery = useBuiEnrollments({\n learnerId,\n limit: 100,\n enabled: Boolean(learnerId),\n });\n const coursesQuery = useBuiCourses({ limit: 100 });\n const certsQuery = useBuiCertificates(undefined, learnerId);\n\n const enrollments: BuiEnrollment[] = useMemo(\n () => enrollmentsQuery.data?.items ?? [],\n [enrollmentsQuery.data]\n );\n const courses: BuiCourse[] = useMemo(() => coursesQuery.data?.items ?? [], [coursesQuery.data]);\n const certificates: BuiCertificate[] = useMemo(() => certsQuery.data ?? [], [certsQuery.data]);\n\n const courseTitleById = useMemo(() => {\n const map = new Map<string, string>();\n for (const c of courses) map.set(c.id, c.title);\n return map;\n }, [courses]);\n\n const titleFor = (courseId: string): string => courseTitleById.get(courseId) ?? 'Course';\n\n const inProgress = useMemo(\n () => enrollments.filter((e) => IN_PROGRESS_STATUSES.has(e.status)),\n [enrollments]\n );\n const completed = useMemo(\n () => enrollments.filter((e) => e.status === 'COMPLETED'),\n [enrollments]\n );\n\n const visible = useMemo(() => {\n const rows = tab === 'in_progress' ? inProgress : tab === 'completed' ? completed : enrollments;\n return [...rows].sort((a, b) =>\n (b.lastActiveAt ?? b.enrolledAt).localeCompare(a.lastActiveAt ?? a.enrolledAt)\n );\n }, [tab, inProgress, completed, enrollments]);\n\n const sortedCertificates = useMemo(\n () => [...certificates].sort((a, b) => b.issuedAt.localeCompare(a.issuedAt)),\n [certificates]\n );\n\n const tabs: TabItem<LearningTab>[] = [\n { value: 'in_progress', label: 'In progress', count: inProgress.length },\n { value: 'completed', label: 'Completed', count: completed.length },\n { value: 'all', label: 'All', count: enrollments.length },\n ];\n\n const isLoading = Boolean(learnerId) && (enrollmentsQuery.isLoading || coursesQuery.isLoading);\n\n const renderEnrollments = () => {\n if (!learnerId) {\n return (\n <EmptyState\n illustration=\"empty-people\"\n title=\"Sign in to see your learning\"\n description=\"Your enrollments and progress appear here once you are signed in to the workspace.\"\n />\n );\n }\n if (isLoading) return <LoadingView label=\"Loading your enrollments…\" />;\n if (enrollmentsQuery.isError) {\n return (\n <ErrorView\n title=\"Couldn't load your learning\"\n message={\n enrollmentsQuery.error instanceof Error ? enrollmentsQuery.error.message : undefined\n }\n onRetry={() => enrollmentsQuery.refetch()}\n />\n );\n }\n if (enrollments.length === 0) {\n return (\n <EmptyState\n illustration=\"onboarding\"\n title=\"You're not enrolled in anything yet\"\n description=\"Browse the catalog and enrol in a course to start learning.\"\n action={<Button onClick={() => navigateTo('catalog')}>Explore the catalog</Button>}\n />\n );\n }\n if (visible.length === 0) {\n const label =\n tab === 'in_progress'\n ? 'No courses in progress'\n : tab === 'completed'\n ? 'No completed courses yet'\n : 'No courses';\n return (\n <EmptyState\n illustration=\"empty-data\"\n title={label}\n description={\n tab === 'completed'\n ? 'Finish a course to see it here — and earn a certificate.'\n : 'Switch tabs or enrol in a new course to keep learning.'\n }\n />\n );\n }\n return (\n <div className=\"flex flex-col gap-3\">\n {visible.map((e) => (\n <GlassCard\n key={e.id}\n treatment=\"plain\"\n className=\"flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:gap-4\"\n >\n <div className=\"flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[var(--color-accent-soft)]\">\n <PlayCircle className=\"h-5 w-5 text-text-primary\" />\n </div>\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <p className=\"truncate font-medium text-text-primary\">{titleFor(e.courseId)}</p>\n <StatusBadge status={e.status} />\n </div>\n <div className=\"mt-2 flex items-center gap-2\">\n <MiniBar value={e.progressPct} className=\"max-w-sm flex-1\" />\n <span className=\"shrink-0 text-xs tabular-nums text-text-secondary\">\n {Math.round(e.progressPct ?? 0)}%\n </span>\n </div>\n <p className=\"mt-1 text-xs text-text-secondary\">\n {e.status === 'COMPLETED' && e.completedAt\n ? `Completed ${formatRelativeTime(e.completedAt)}`\n : `Active ${formatRelativeTime(e.lastActiveAt ?? e.enrolledAt)}`}\n </p>\n </div>\n <Button\n variant=\"secondary\"\n size=\"sm\"\n className=\"shrink-0 max-sm:w-full\"\n onClick={() => navigateTo(`learning/${e.id}`)}\n >\n {e.status === 'COMPLETED' ? 'Review' : 'Continue'}\n <ArrowRight className=\"ml-1 h-4 w-4\" />\n </Button>\n </GlassCard>\n ))}\n </div>\n );\n };\n\n const renderCertificates = () => {\n if (!learnerId) return null;\n if (certsQuery.isLoading) return <LoadingView label=\"Loading your certificates…\" />;\n if (certsQuery.isError) {\n return (\n <ErrorView\n title=\"Couldn't load your certificates\"\n message={certsQuery.error instanceof Error ? certsQuery.error.message : undefined}\n onRetry={() => certsQuery.refetch()}\n />\n );\n }\n if (sortedCertificates.length === 0) {\n return (\n <EmptyState\n illustration=\"empty-data\"\n title=\"No certificates yet\"\n description=\"Complete a credentialed course to earn a verifiable certificate.\"\n />\n );\n }\n return (\n <div className=\"grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3\">\n {sortedCertificates.map((cert) => (\n <GlassCard key={cert.id} treatment=\"plain\" className=\"flex flex-col gap-3 p-4\">\n <div className=\"flex items-start justify-between gap-3\">\n <div className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[var(--color-accent-soft)]\">\n <Award className=\"h-5 w-5 text-text-primary\" />\n </div>\n <StatusBadge status={cert.status} />\n </div>\n <div className=\"min-w-0\">\n <p className=\"truncate font-medium text-text-primary\">{titleFor(cert.courseId)}</p>\n <p className=\"mt-0.5 font-mono text-xs text-text-secondary\">{cert.serial}</p>\n </div>\n <div className=\"mt-auto flex flex-wrap items-center justify-between gap-2 pt-1 text-xs text-text-secondary\">\n <span>Issued {formatDate(cert.issuedAt)}</span>\n {cert.file ? <FileLink fileId={cert.file.id} /> : null}\n </div>\n </GlassCard>\n ))}\n </div>\n );\n };\n\n return (\n <PageLayout\n title=\"My Learning\"\n description=\"Your enrollments, progress and the credentials you've earned.\"\n titleGradient\n aurora\n >\n <div className=\"flex flex-col gap-6\">\n <Section title=\"Courses\" description=\"Everything you're enrolled in.\">\n {learnerId && enrollments.length > 0 && (\n <div className=\"mb-4\">\n <Tabs items={tabs} value={tab} onChange={setTab} />\n </div>\n )}\n {renderEnrollments()}\n </Section>\n\n <Section\n title=\"Certificates\"\n description=\"Verifiable credentials issued for the courses you've completed.\"\n action={\n <Button variant=\"ghost\" size=\"sm\" onClick={() => navigateTo('certificates')}>\n <GraduationCap className=\"mr-1 h-4 w-4\" /> All certificates\n </Button>\n }\n >\n {renderCertificates()}\n </Section>\n </div>\n </PageLayout>\n );\n};\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SkillsPage.js","names":[],"sources":["../../../../src/buildmyiq/pages/skills/SkillsPage.tsx"],"sourcesContent":["/**\n * SkillsPage — the workspace skills taxonomy.\n * @module buildmyiq/pages/skills\n *\n * The competency vocabulary that courses map to (`BuiCourse.skillId → BuiSkill`).\n * Reads the taxonomy via `useBuiSkills(category?)` — the category filter is applied\n * server-side — and writes new competencies via `useBuiCreateSkill`. A small KPI\n * summary (total / active / inactive) is derived from the loaded list, so it is\n * honest for whichever category is currently in view. Every tile renders REAL\n * `bui*` data; the empty state is honest.\n */\n\nimport { useMemo, useState } from 'react';\nimport type { FC } from 'react';\nimport { Plus, Sparkles, CheckCircle2, CircleSlash } from 'lucide-react';\nimport {\n Button,\n GlassCard,\n Input,\n Label,\n Textarea,\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n ResponsiveDialog,\n ResponsiveDialogContent,\n ResponsiveDialogHeader,\n ResponsiveDialogTitle,\n ResponsiveDialogDescription,\n ResponsiveDialogFooter,\n} from '@burdenoff/fe-libs/ui';\nimport {\n PageLayout,\n MetricsCard,\n StatusBadge,\n FilterTabs,\n EmptyState,\n LoadingView,\n ErrorView,\n type FilterOption,\n} from '../../components';\nimport { useBuiSkills, useBuiCreateSkill } from '../../hooks';\nimport { formatEnumLabel, formatRelativeTime } from '../../utils/formatters';\nimport { SKILL_CATEGORIES } from '../../types';\nimport type { BuiSkill, BuiSkillCategory } from '../../types';\n\ntype CategoryFilter = BuiSkillCategory | 'ALL';\n\nconst CATEGORY_FILTERS: FilterOption<CategoryFilter>[] = [\n { value: 'ALL', label: 'All' },\n ...SKILL_CATEGORIES.map((c) => ({ value: c, label: formatEnumLabel(c) })),\n];\n\ninterface CreateFormState {\n name: string;\n category: BuiSkillCategory;\n description: string;\n}\n\nconst EMPTY_FORM: CreateFormState = {\n name: '',\n category: 'TECHNICAL',\n description: '',\n};\n\n/** A single competency tile — name, category badge, description, active state. */\nconst SkillTile: FC<{ skill: BuiSkill }> = ({ skill }) => (\n <GlassCard treatment=\"plain\" className=\"flex flex-col gap-3 p-5\">\n <div className=\"flex items-start justify-between gap-3\">\n <h3 className=\"min-w-0 break-words font-semibold text-text-primary\">{skill.name}</h3>\n <StatusBadge status={skill.category} className=\"shrink-0\" />\n </div>\n\n {skill.description ? (\n <p className=\"line-clamp-3 text-sm text-text-secondary\">{skill.description}</p>\n ) : (\n <p className=\"text-sm italic text-text-secondary/70\">No description provided.</p>\n )}\n\n <div className=\"mt-auto flex items-center justify-between gap-3 pt-2\">\n <span\n className={\n skill.isActive\n ? 'inline-flex items-center gap-1.5 text-xs font-medium text-status-success-text'\n : 'inline-flex items-center gap-1.5 text-xs font-medium text-text-secondary'\n }\n >\n <span\n aria-hidden=\"true\"\n className={\n skill.isActive\n ? 'size-1.5 rounded-full bg-status-success-text'\n : 'size-1.5 rounded-full bg-text-secondary'\n }\n />\n {skill.isActive ? 'Active' : 'Inactive'}\n </span>\n <span className=\"text-xs text-text-secondary\">Added {formatRelativeTime(skill.createdAt)}</span>\n </div>\n </GlassCard>\n);\n\nexport const SkillsPage: FC = () => {\n const [category, setCategory] = useState<CategoryFilter>('ALL');\n const [dialogOpen, setDialogOpen] = useState(false);\n const [form, setForm] = useState<CreateFormState>(EMPTY_FORM);\n const [formError, setFormError] = useState<string | null>(null);\n\n const { data, isLoading, isError, refetch } = useBuiSkills(\n category === 'ALL' ? undefined : category\n );\n const createSkill = useBuiCreateSkill();\n\n const skills = useMemo<BuiSkill[]>(() => data ?? [], [data]);\n\n // KPIs are derived from the loaded (optionally category-filtered) list.\n const summary = useMemo(() => {\n const total = skills.length;\n const active = skills.filter((s) => s.isActive).length;\n return { total, active, inactive: total - active };\n }, [skills]);\n\n const scopeLabel = category === 'ALL' ? '' : ` · ${formatEnumLabel(category)}`;\n\n const resetForm = () => {\n setForm(EMPTY_FORM);\n setFormError(null);\n };\n\n const closeDialog = () => {\n setDialogOpen(false);\n resetForm();\n };\n\n const handleCreate = async () => {\n const name = form.name.trim();\n if (!name) {\n setFormError('A skill name is required.');\n return;\n }\n try {\n await createSkill.mutateAsync({\n name,\n category: form.category,\n description: form.description.trim() || undefined,\n });\n closeDialog();\n } catch (err) {\n setFormError(err instanceof Error ? err.message : 'Failed to create skill.');\n }\n };\n\n return (\n <PageLayout\n title=\"Skills\"\n description=\"The competency taxonomy your courses map to — organised by category.\"\n titleGradient\n aurora\n actions={\n <Button onClick={() => setDialogOpen(true)}>\n <Plus className=\"mr-1.5 h-4 w-4\" /> New skill\n </Button>\n }\n >\n <div className=\"flex flex-col gap-6\">\n {/* KPI row — scoped to the loaded list. */}\n <div className=\"grid grid-cols-1 gap-4 sm:grid-cols-3\">\n <MetricsCard\n icon={<Sparkles className=\"h-5 w-5\" />}\n value={summary.total}\n label={`Skills${scopeLabel}`}\n featured\n />\n <MetricsCard\n icon={<CheckCircle2 className=\"h-5 w-5\" />}\n value={summary.active}\n label=\"Active\"\n accent=\"success\"\n />\n <MetricsCard\n icon={<CircleSlash className=\"h-5 w-5\" />}\n value={summary.inactive}\n label=\"Inactive\"\n />\n </div>\n\n <FilterTabs options={CATEGORY_FILTERS} value={category} onChange={setCategory} />\n\n {isLoading ? (\n <LoadingView label=\"Loading skills…\" />\n ) : isError ? (\n <ErrorView\n title=\"Couldn't load skills\"\n message=\"The skills taxonomy failed to load.\"\n onRetry={() => refetch()}\n />\n ) : skills.length === 0 ? (\n <EmptyState\n illustration=\"empty-data\"\n icon={<Sparkles className=\"h-10 w-10\" />}\n title={category === 'ALL' ? 'No skills yet' : 'No skills in this category'}\n description={\n category === 'ALL'\n ? 'Define the competencies your courses build. Skills let you organise the catalog and target learning outcomes.'\n : 'No skills are tagged with this category yet. Add one, or switch categories.'\n }\n action={\n <Button onClick={() => setDialogOpen(true)}>\n <Plus className=\"mr-1.5 h-4 w-4\" /> New skill\n </Button>\n }\n />\n ) : (\n <div className=\"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4\">\n {skills.map((skill) => (\n <SkillTile key={skill.id} skill={skill} />\n ))}\n </div>\n )}\n </div>\n\n <ResponsiveDialog\n open={dialogOpen}\n onOpenChange={(open) => {\n if (open) setDialogOpen(true);\n else closeDialog();\n }}\n >\n <ResponsiveDialogContent>\n <ResponsiveDialogHeader>\n <ResponsiveDialogTitle>New skill</ResponsiveDialogTitle>\n <ResponsiveDialogDescription>\n Add a competency to the taxonomy. Courses can then be mapped to it.\n </ResponsiveDialogDescription>\n </ResponsiveDialogHeader>\n\n <div className=\"grid gap-4 py-2\">\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"skill-name\">Name *</Label>\n <Input\n id=\"skill-name\"\n value={form.name}\n onChange={(e) => setForm({ ...form, name: e.target.value })}\n placeholder=\"e.g. Data Visualisation\"\n />\n </div>\n\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"skill-category\">Category</Label>\n <Select\n value={form.category}\n onValueChange={(v) => setForm({ ...form, category: v as BuiSkillCategory })}\n >\n <SelectTrigger id=\"skill-category\">\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {SKILL_CATEGORIES.map((c) => (\n <SelectItem key={c} value={c}>\n {formatEnumLabel(c)}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"skill-description\">Description</Label>\n <Textarea\n id=\"skill-description\"\n value={form.description}\n onChange={(e) => setForm({ ...form, description: e.target.value })}\n placeholder=\"Optional — what this competency covers and how it's assessed.\"\n rows={3}\n />\n </div>\n\n {formError && <p className=\"text-sm text-status-error-text\">{formError}</p>}\n </div>\n\n <ResponsiveDialogFooter>\n <Button variant=\"outline\" onClick={closeDialog}>\n Cancel\n </Button>\n <Button onClick={handleCreate} disabled={createSkill.isPending}>\n {createSkill.isPending ? 'Creating…' : 'Create skill'}\n </Button>\n </ResponsiveDialogFooter>\n </ResponsiveDialogContent>\n </ResponsiveDialog>\n </PageLayout>\n );\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"SkillsPage.js","names":[],"sources":["../../../../src/buildmyiq/pages/skills/SkillsPage.tsx"],"sourcesContent":["/**\n * SkillsPage — the workspace skills taxonomy.\n * @module buildmyiq/pages/skills\n *\n * The competency vocabulary that courses map to (`BuiCourse.skillId → BuiSkill`).\n * Reads the taxonomy via `useBuiSkills(category?)` — the category filter is applied\n * server-side — and writes new competencies via `useBuiCreateSkill`. A small KPI\n * summary (total / active / inactive) is derived from the loaded list, so it is\n * honest for whichever category is currently in view. Every tile renders REAL\n * `bui*` data; the empty state is honest.\n */\n\nimport { useMemo, useState } from 'react';\nimport type { FC } from 'react';\nimport { Plus, Sparkles, CheckCircle2, CircleSlash } from 'lucide-react';\nimport {\n Button,\n GlassCard,\n Input,\n Label,\n Textarea,\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n ResponsiveDialog,\n ResponsiveDialogContent,\n ResponsiveDialogHeader,\n ResponsiveDialogTitle,\n ResponsiveDialogDescription,\n ResponsiveDialogFooter,\n} from '@burdenoff/fe-libs/ui';\nimport {\n PageLayout,\n MetricsCard,\n StatusBadge,\n FilterTabs,\n EmptyState,\n LoadingView,\n ErrorView,\n type FilterOption,\n} from '../../components';\nimport { useBuiSkills, useBuiCreateSkill } from '../../hooks';\nimport { formatEnumLabel, formatRelativeTime } from '../../utils/formatters';\nimport { SKILL_CATEGORIES } from '../../types';\nimport type { BuiSkill, BuiSkillCategory } from '../../types';\n\ntype CategoryFilter = BuiSkillCategory | 'ALL';\n\nconst CATEGORY_FILTERS: FilterOption<CategoryFilter>[] = [\n { value: 'ALL', label: 'All' },\n ...SKILL_CATEGORIES.map((c) => ({ value: c, label: formatEnumLabel(c) })),\n];\n\ninterface CreateFormState {\n name: string;\n category: BuiSkillCategory;\n description: string;\n}\n\nconst EMPTY_FORM: CreateFormState = {\n name: '',\n category: 'TECHNICAL',\n description: '',\n};\n\n/** A single competency tile — name, category badge, description, active state. */\nconst SkillTile: FC<{ skill: BuiSkill }> = ({ skill }) => (\n <GlassCard treatment=\"plain\" className=\"flex flex-col gap-3 p-5\">\n <div className=\"flex items-start justify-between gap-3\">\n <h3 className=\"min-w-0 break-words font-semibold text-text-primary\">{skill.name}</h3>\n <StatusBadge status={skill.category} className=\"shrink-0\" />\n </div>\n\n {skill.description ? (\n <p className=\"line-clamp-3 text-sm text-text-secondary\">{skill.description}</p>\n ) : (\n <p className=\"text-sm italic text-text-secondary/70\">No description provided.</p>\n )}\n\n <div className=\"mt-auto flex items-center justify-between gap-3 pt-2\">\n <span\n className={\n skill.isActive\n ? 'inline-flex items-center gap-1.5 text-xs font-medium text-status-success-text'\n : 'inline-flex items-center gap-1.5 text-xs font-medium text-text-secondary'\n }\n >\n <span\n aria-hidden=\"true\"\n className={\n skill.isActive\n ? 'size-1.5 rounded-full bg-status-success-text'\n : 'size-1.5 rounded-full bg-text-secondary'\n }\n />\n {skill.isActive ? 'Active' : 'Inactive'}\n </span>\n <span className=\"text-xs text-text-secondary\">\n Added {formatRelativeTime(skill.createdAt)}\n </span>\n </div>\n </GlassCard>\n);\n\nexport const SkillsPage: FC = () => {\n const [category, setCategory] = useState<CategoryFilter>('ALL');\n const [dialogOpen, setDialogOpen] = useState(false);\n const [form, setForm] = useState<CreateFormState>(EMPTY_FORM);\n const [formError, setFormError] = useState<string | null>(null);\n\n const { data, isLoading, isError, refetch } = useBuiSkills(\n category === 'ALL' ? undefined : category\n );\n const createSkill = useBuiCreateSkill();\n\n const skills = useMemo<BuiSkill[]>(() => data ?? [], [data]);\n\n // KPIs are derived from the loaded (optionally category-filtered) list.\n const summary = useMemo(() => {\n const total = skills.length;\n const active = skills.filter((s) => s.isActive).length;\n return { total, active, inactive: total - active };\n }, [skills]);\n\n const scopeLabel = category === 'ALL' ? '' : ` · ${formatEnumLabel(category)}`;\n\n const resetForm = () => {\n setForm(EMPTY_FORM);\n setFormError(null);\n };\n\n const closeDialog = () => {\n setDialogOpen(false);\n resetForm();\n };\n\n const handleCreate = async () => {\n const name = form.name.trim();\n if (!name) {\n setFormError('A skill name is required.');\n return;\n }\n try {\n await createSkill.mutateAsync({\n name,\n category: form.category,\n description: form.description.trim() || undefined,\n });\n closeDialog();\n } catch (err) {\n setFormError(err instanceof Error ? err.message : 'Failed to create skill.');\n }\n };\n\n return (\n <PageLayout\n title=\"Skills\"\n description=\"The competency taxonomy your courses map to — organised by category.\"\n titleGradient\n aurora\n actions={\n <Button onClick={() => setDialogOpen(true)}>\n <Plus className=\"mr-1.5 h-4 w-4\" /> New skill\n </Button>\n }\n >\n <div className=\"flex flex-col gap-6\">\n {/* KPI row — scoped to the loaded list. */}\n <div className=\"grid grid-cols-1 gap-4 sm:grid-cols-3\">\n <MetricsCard\n icon={<Sparkles className=\"h-5 w-5\" />}\n value={summary.total}\n label={`Skills${scopeLabel}`}\n featured\n />\n <MetricsCard\n icon={<CheckCircle2 className=\"h-5 w-5\" />}\n value={summary.active}\n label=\"Active\"\n accent=\"success\"\n />\n <MetricsCard\n icon={<CircleSlash className=\"h-5 w-5\" />}\n value={summary.inactive}\n label=\"Inactive\"\n />\n </div>\n\n <FilterTabs options={CATEGORY_FILTERS} value={category} onChange={setCategory} />\n\n {isLoading ? (\n <LoadingView label=\"Loading skills…\" />\n ) : isError ? (\n <ErrorView\n title=\"Couldn't load skills\"\n message=\"The skills taxonomy failed to load.\"\n onRetry={() => refetch()}\n />\n ) : skills.length === 0 ? (\n <EmptyState\n illustration=\"empty-data\"\n icon={<Sparkles className=\"h-10 w-10\" />}\n title={category === 'ALL' ? 'No skills yet' : 'No skills in this category'}\n description={\n category === 'ALL'\n ? 'Define the competencies your courses build. Skills let you organise the catalog and target learning outcomes.'\n : 'No skills are tagged with this category yet. Add one, or switch categories.'\n }\n action={\n <Button onClick={() => setDialogOpen(true)}>\n <Plus className=\"mr-1.5 h-4 w-4\" /> New skill\n </Button>\n }\n />\n ) : (\n <div className=\"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4\">\n {skills.map((skill) => (\n <SkillTile key={skill.id} skill={skill} />\n ))}\n </div>\n )}\n </div>\n\n <ResponsiveDialog\n open={dialogOpen}\n onOpenChange={(open) => {\n if (open) setDialogOpen(true);\n else closeDialog();\n }}\n >\n <ResponsiveDialogContent>\n <ResponsiveDialogHeader>\n <ResponsiveDialogTitle>New skill</ResponsiveDialogTitle>\n <ResponsiveDialogDescription>\n Add a competency to the taxonomy. Courses can then be mapped to it.\n </ResponsiveDialogDescription>\n </ResponsiveDialogHeader>\n\n <div className=\"grid gap-4 py-2\">\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"skill-name\">Name *</Label>\n <Input\n id=\"skill-name\"\n value={form.name}\n onChange={(e) => setForm({ ...form, name: e.target.value })}\n placeholder=\"e.g. Data Visualisation\"\n />\n </div>\n\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"skill-category\">Category</Label>\n <Select\n value={form.category}\n onValueChange={(v) => setForm({ ...form, category: v as BuiSkillCategory })}\n >\n <SelectTrigger id=\"skill-category\">\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {SKILL_CATEGORIES.map((c) => (\n <SelectItem key={c} value={c}>\n {formatEnumLabel(c)}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"skill-description\">Description</Label>\n <Textarea\n id=\"skill-description\"\n value={form.description}\n onChange={(e) => setForm({ ...form, description: e.target.value })}\n placeholder=\"Optional — what this competency covers and how it's assessed.\"\n rows={3}\n />\n </div>\n\n {formError && <p className=\"text-sm text-status-error-text\">{formError}</p>}\n </div>\n\n <ResponsiveDialogFooter>\n <Button variant=\"outline\" onClick={closeDialog}>\n Cancel\n </Button>\n <Button onClick={handleCreate} disabled={createSkill.isPending}>\n {createSkill.isPending ? 'Creating…' : 'Create skill'}\n </Button>\n </ResponsiveDialogFooter>\n </ResponsiveDialogContent>\n </ResponsiveDialog>\n </PageLayout>\n );\n};\n"],"mappings":"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"}
|