@hydralms/components 0.2.0 → 0.3.0

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.
Files changed (283) hide show
  1. package/dist/StudentProfile-BVfZMbnV.cjs +1 -0
  2. package/dist/StudentProfile-DeMxdrL3.js +3275 -0
  3. package/dist/assessment-toolbar/question-navigator.d.ts +1 -1
  4. package/dist/assessment-toolbar/timer-display.d.ts +1 -1
  5. package/dist/common/index.d.ts +2 -1
  6. package/dist/common/pagination.d.ts +26 -0
  7. package/dist/common/types.d.ts +1 -0
  8. package/dist/components.css +1 -1
  9. package/dist/content/audio-player.d.ts +22 -0
  10. package/dist/content/code-block.d.ts +30 -0
  11. package/dist/content/embed-block.d.ts +28 -0
  12. package/dist/content/index.d.ts +6 -0
  13. package/dist/content/types.d.ts +24 -0
  14. package/dist/curriculum/course-card.d.ts +51 -0
  15. package/dist/curriculum/index.d.ts +2 -0
  16. package/dist/curriculum/types.d.ts +2 -2
  17. package/dist/index.cjs +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.js +494 -444
  20. package/dist/license/HydraContext.d.ts +16 -0
  21. package/dist/license/ProBadge.d.ts +6 -0
  22. package/dist/license/index.d.ts +7 -0
  23. package/dist/license/tiers.d.ts +3 -0
  24. package/dist/license/useHydraLicense.d.ts +6 -0
  25. package/dist/license/validate.d.ts +13 -0
  26. package/dist/license/withProGate.d.ts +6 -0
  27. package/dist/modules/AssignmentModule/AssignmentModule.d.ts +4 -7
  28. package/dist/modules/AssignmentModule/types.d.ts +5 -1
  29. package/dist/modules/CertificateModule/CertificateModule.d.ts +4 -8
  30. package/dist/modules/CertificateModule/types.d.ts +6 -4
  31. package/dist/modules/CourseCatalogModule/CourseCatalogModule.d.ts +5 -0
  32. package/dist/modules/CourseCatalogModule/types.d.ts +43 -0
  33. package/dist/modules/CoursePlayer/CoursePlayer.d.ts +4 -1
  34. package/dist/modules/DiscussionModule/DiscussionModule.d.ts +4 -7
  35. package/dist/modules/ExamModule/ExamModule.d.ts +4 -7
  36. package/dist/modules/ExamModule/types.d.ts +5 -14
  37. package/dist/modules/FlashcardLab/FlashcardLab.d.ts +4 -1
  38. package/dist/modules/FlashcardLab/types.d.ts +2 -0
  39. package/dist/modules/GradeCenterModule/GradeCenterModule.d.ts +4 -8
  40. package/dist/modules/GradeCenterModule/types.d.ts +2 -0
  41. package/dist/modules/QuizModule/QuizModule.d.ts +4 -1
  42. package/dist/modules/QuizModule/types.d.ts +5 -14
  43. package/dist/modules/StudentDashboardModule/StudentDashboardModule.d.ts +5 -0
  44. package/dist/modules/StudentDashboardModule/types.d.ts +54 -0
  45. package/dist/modules/StudentProfileModule/StudentProfileModule.d.ts +5 -0
  46. package/dist/modules/StudentProfileModule/types.d.ts +43 -0
  47. package/dist/modules/SurveyModule/SurveyModule.d.ts +4 -6
  48. package/dist/modules/SurveyModule/types.d.ts +2 -0
  49. package/dist/modules/_shared/assessment-intro.d.ts +16 -0
  50. package/dist/modules/_shared/assessment-results.d.ts +23 -0
  51. package/dist/modules/_shared/types.d.ts +10 -0
  52. package/dist/modules/_shared/use-timer.d.ts +9 -0
  53. package/dist/modules/index.d.ts +6 -0
  54. package/dist/modules.cjs +1 -1
  55. package/dist/modules.js +1266 -854
  56. package/dist/progress/types.d.ts +2 -0
  57. package/dist/provider/HydraProvider.d.ts +5 -1
  58. package/dist/questions/choice.d.ts +1 -1
  59. package/dist/questions/confidence-indicator.d.ts +37 -0
  60. package/dist/questions/essay.d.ts +1 -1
  61. package/dist/questions/fill-in-the-blank.d.ts +1 -1
  62. package/dist/questions/hotspot.d.ts +1 -1
  63. package/dist/questions/index.d.ts +2 -0
  64. package/dist/questions/inline-choice.d.ts +1 -1
  65. package/dist/questions/matching.d.ts +1 -1
  66. package/dist/questions/multiple-choice.d.ts +1 -1
  67. package/dist/questions/numeric.d.ts +1 -1
  68. package/dist/questions/ordering.d.ts +1 -1
  69. package/dist/questions/question-renderer.d.ts +1 -1
  70. package/dist/questions/scenario.d.ts +1 -1
  71. package/dist/questions/spreadsheet.d.ts +1 -1
  72. package/dist/questions/true-false.d.ts +1 -1
  73. package/dist/sections/AnnouncementFeed/AnnouncementFeed.d.ts +1 -1
  74. package/dist/sections/AnnouncementFeed/types.d.ts +15 -1
  75. package/dist/sections/AssessmentReview/AssessmentReview.d.ts +1 -1
  76. package/dist/sections/AssessmentReview/types.d.ts +6 -0
  77. package/dist/sections/AssignmentSubmission/AssignmentSubmission.d.ts +1 -1
  78. package/dist/sections/AssignmentSubmission/types.d.ts +6 -0
  79. package/dist/sections/CertificateViewer/CertificateViewer.d.ts +1 -1
  80. package/dist/sections/CertificateViewer/certificate-variants.d.ts +42 -0
  81. package/dist/sections/CertificateViewer/types.d.ts +6 -0
  82. package/dist/sections/CourseCatalog/CourseCatalog.d.ts +2 -0
  83. package/dist/sections/CourseCatalog/types.d.ts +80 -0
  84. package/dist/sections/CourseOutline/CourseOutline.d.ts +1 -1
  85. package/dist/sections/CourseOutline/types.d.ts +6 -0
  86. package/dist/sections/DiscussionThread/DiscussionThread.d.ts +1 -1
  87. package/dist/sections/DiscussionThread/types.d.ts +6 -0
  88. package/dist/sections/EnrollmentWizard/EnrollmentWizard.d.ts +2 -0
  89. package/dist/sections/EnrollmentWizard/types.d.ts +66 -0
  90. package/dist/sections/ExamSession/ExamSession.d.ts +1 -1
  91. package/dist/sections/ExamSession/types.d.ts +6 -0
  92. package/dist/sections/FlashcardStudySession/FlashcardStudySession.d.ts +1 -1
  93. package/dist/sections/FlashcardStudySession/types.d.ts +6 -0
  94. package/dist/sections/ForumBoard/ForumBoard.d.ts +1 -1
  95. package/dist/sections/ForumBoard/types.d.ts +14 -0
  96. package/dist/sections/GradebookTable/GradebookTable.d.ts +1 -1
  97. package/dist/sections/GradebookTable/types.d.ts +14 -0
  98. package/dist/sections/LecturePlayer/LecturePlayer.d.ts +1 -1
  99. package/dist/sections/LecturePlayer/types.d.ts +8 -0
  100. package/dist/sections/LessonPage/LessonPage.d.ts +1 -1
  101. package/dist/sections/LessonPage/types.d.ts +6 -0
  102. package/dist/sections/PracticeQuiz/PracticeQuiz.d.ts +1 -1
  103. package/dist/sections/PracticeQuiz/types.d.ts +6 -0
  104. package/dist/sections/ProgressDashboard/ProgressDashboard.d.ts +1 -1
  105. package/dist/sections/ProgressDashboard/types.d.ts +6 -0
  106. package/dist/sections/QuizSession/QuizSession.d.ts +1 -1
  107. package/dist/sections/QuizSession/types.d.ts +6 -0
  108. package/dist/sections/RequirementsChecklist/RequirementsChecklist.d.ts +1 -1
  109. package/dist/sections/RequirementsChecklist/types.d.ts +6 -0
  110. package/dist/sections/ResourceLibrary/ResourceLibrary.d.ts +1 -1
  111. package/dist/sections/ResourceLibrary/types.d.ts +15 -1
  112. package/dist/sections/RubricView/RubricView.d.ts +1 -1
  113. package/dist/sections/RubricView/types.d.ts +6 -0
  114. package/dist/sections/ScrollableQuiz/ScrollableQuiz.d.ts +1 -1
  115. package/dist/sections/ScrollableQuiz/types.d.ts +6 -0
  116. package/dist/sections/StudentProfile/StudentProfile.d.ts +2 -0
  117. package/dist/sections/StudentProfile/types.d.ts +98 -0
  118. package/dist/sections/SurveyForm/SurveyForm.d.ts +1 -1
  119. package/dist/sections/SurveyForm/types.d.ts +6 -0
  120. package/dist/sections/_shared/merge-answers.d.ts +9 -0
  121. package/dist/sections/_shared/section-shell.d.ts +20 -0
  122. package/dist/sections/_shared/use-assessment-session.d.ts +30 -0
  123. package/dist/sections/index.d.ts +6 -0
  124. package/dist/sections.cjs +1 -1
  125. package/dist/sections.js +268 -307
  126. package/dist/tabs-BsfVo2Bl.cjs +173 -0
  127. package/dist/{tabs-Wf3h_Cx3.js → tabs-BuY1iNJE.js} +7532 -6807
  128. package/dist/ui/badge.d.ts +1 -1
  129. package/dist/ui/index.d.ts +2 -0
  130. package/dist/ui/progress.d.ts +1 -1
  131. package/dist/ui/rich-text-editor.d.ts +3 -1
  132. package/dist/ui/toast.d.ts +43 -0
  133. package/dist/utils/debounce.d.ts +5 -1
  134. package/dist/utils/pick-palette-color.d.ts +19 -0
  135. package/dist/video/types.d.ts +15 -0
  136. package/dist/video/video-player.d.ts +1 -1
  137. package/dist/withProGate-BWqcKdPM.js +137 -0
  138. package/dist/withProGate-DX6XqKLp.cjs +1 -0
  139. package/package.json +34 -220
  140. package/src/assessment-toolbar/question-navigator.tsx +10 -5
  141. package/src/assessment-toolbar/timer-display.tsx +4 -3
  142. package/src/assessment-toolbar/use-countdown.ts +1 -1
  143. package/src/common/empty-state.tsx +1 -0
  144. package/src/common/index.ts +2 -0
  145. package/src/common/pagination.tsx +135 -0
  146. package/src/common/search-input.tsx +2 -1
  147. package/src/common/types.ts +2 -0
  148. package/src/content/attachment-list.tsx +2 -0
  149. package/src/content/audio-player.tsx +196 -0
  150. package/src/content/code-block.tsx +113 -0
  151. package/src/content/content-block.tsx +64 -0
  152. package/src/content/embed-block.tsx +78 -0
  153. package/src/content/file-upload-zone.tsx +10 -0
  154. package/src/content/index.ts +6 -0
  155. package/src/content/types.ts +5 -0
  156. package/src/curriculum/course-card.tsx +199 -0
  157. package/src/curriculum/curriculum-item.tsx +3 -3
  158. package/src/curriculum/curriculum-tree.tsx +20 -13
  159. package/src/curriculum/index.ts +2 -0
  160. package/src/curriculum/types.ts +2 -2
  161. package/src/flashcards/flashcard.tsx +28 -8
  162. package/src/index.ts +3 -0
  163. package/src/license/HydraContext.tsx +62 -0
  164. package/src/license/ProBadge.tsx +43 -0
  165. package/src/license/index.ts +7 -0
  166. package/src/license/tiers.ts +24 -0
  167. package/src/license/useHydraLicense.ts +10 -0
  168. package/src/license/validate.ts +90 -0
  169. package/src/license/withProGate.tsx +21 -0
  170. package/src/modules/AssignmentModule/AssignmentModule.tsx +17 -8
  171. package/src/modules/AssignmentModule/types.ts +5 -1
  172. package/src/modules/CertificateModule/CertificateModule.tsx +21 -9
  173. package/src/modules/CertificateModule/types.ts +6 -4
  174. package/src/modules/CourseCatalogModule/CourseCatalogModule.tsx +126 -0
  175. package/src/modules/CourseCatalogModule/types.ts +47 -0
  176. package/src/modules/CoursePlayer/CoursePlayer.tsx +37 -22
  177. package/src/modules/DiscussionModule/DiscussionModule.tsx +57 -22
  178. package/src/modules/ExamModule/ExamModule.tsx +64 -198
  179. package/src/modules/ExamModule/types.ts +5 -14
  180. package/src/modules/FlashcardLab/FlashcardLab.tsx +10 -5
  181. package/src/modules/FlashcardLab/types.ts +2 -0
  182. package/src/modules/GradeCenterModule/GradeCenterModule.tsx +7 -2
  183. package/src/modules/GradeCenterModule/types.ts +2 -0
  184. package/src/modules/QuizModule/QuizModule.tsx +49 -169
  185. package/src/modules/QuizModule/types.ts +5 -15
  186. package/src/modules/StudentDashboardModule/StudentDashboardModule.tsx +117 -0
  187. package/src/modules/StudentDashboardModule/types.ts +56 -0
  188. package/src/modules/StudentProfileModule/StudentProfileModule.tsx +289 -0
  189. package/src/modules/StudentProfileModule/types.ts +45 -0
  190. package/src/modules/SurveyModule/SurveyModule.tsx +9 -4
  191. package/src/modules/SurveyModule/types.ts +2 -0
  192. package/src/modules/_shared/assessment-intro.tsx +75 -0
  193. package/src/modules/_shared/assessment-results.tsx +133 -0
  194. package/src/modules/_shared/types.ts +11 -0
  195. package/src/modules/_shared/use-timer.ts +49 -0
  196. package/src/modules/index.ts +9 -0
  197. package/src/progress/achievement-badge.tsx +3 -3
  198. package/src/progress/grade-indicator.tsx +9 -1
  199. package/src/progress/progress-ring.tsx +2 -1
  200. package/src/progress/stat-card.tsx +8 -1
  201. package/src/progress/types.ts +2 -0
  202. package/src/provider/HydraProvider.tsx +15 -6
  203. package/src/questions/choice.tsx +13 -6
  204. package/src/questions/confidence-indicator.tsx +107 -0
  205. package/src/questions/essay.tsx +6 -4
  206. package/src/questions/fill-in-the-blank.tsx +8 -4
  207. package/src/questions/hotspot.tsx +4 -4
  208. package/src/questions/index.ts +2 -0
  209. package/src/questions/inline-choice.tsx +5 -4
  210. package/src/questions/matching.tsx +5 -4
  211. package/src/questions/multiple-choice.tsx +13 -6
  212. package/src/questions/numeric.tsx +8 -4
  213. package/src/questions/ordering.tsx +12 -4
  214. package/src/questions/question-renderer.tsx +3 -2
  215. package/src/questions/scenario.tsx +4 -4
  216. package/src/questions/spreadsheet.tsx +5 -4
  217. package/src/questions/true-false.tsx +13 -6
  218. package/src/sections/AnnouncementFeed/AnnouncementFeed.tsx +64 -8
  219. package/src/sections/AnnouncementFeed/types.ts +15 -1
  220. package/src/sections/AssessmentReview/AssessmentReview.tsx +37 -0
  221. package/src/sections/AssessmentReview/types.ts +6 -0
  222. package/src/sections/AssignmentSubmission/AssignmentSubmission.tsx +37 -1
  223. package/src/sections/AssignmentSubmission/types.ts +6 -0
  224. package/src/sections/CertificateViewer/CertificateViewer.tsx +29 -227
  225. package/src/sections/CertificateViewer/certificate-variants.tsx +170 -0
  226. package/src/sections/CertificateViewer/types.ts +6 -0
  227. package/src/sections/CourseCatalog/CourseCatalog.tsx +220 -0
  228. package/src/sections/CourseCatalog/types.ts +76 -0
  229. package/src/sections/CourseOutline/CourseOutline.tsx +41 -0
  230. package/src/sections/CourseOutline/types.ts +6 -0
  231. package/src/sections/DiscussionThread/DiscussionThread.tsx +42 -1
  232. package/src/sections/DiscussionThread/types.ts +6 -0
  233. package/src/sections/EnrollmentWizard/EnrollmentWizard.tsx +343 -0
  234. package/src/sections/EnrollmentWizard/types.ts +65 -0
  235. package/src/sections/ExamSession/ExamSession.tsx +100 -94
  236. package/src/sections/ExamSession/types.ts +6 -0
  237. package/src/sections/FlashcardStudySession/FlashcardStudySession.tsx +53 -36
  238. package/src/sections/FlashcardStudySession/types.ts +6 -0
  239. package/src/sections/ForumBoard/ForumBoard.tsx +59 -1
  240. package/src/sections/ForumBoard/types.ts +14 -0
  241. package/src/sections/GradebookTable/GradebookTable.tsx +54 -1
  242. package/src/sections/GradebookTable/types.ts +14 -0
  243. package/src/sections/LecturePlayer/LecturePlayer.tsx +63 -37
  244. package/src/sections/LecturePlayer/types.ts +8 -0
  245. package/src/sections/LessonPage/LessonPage.tsx +36 -5
  246. package/src/sections/LessonPage/types.ts +6 -0
  247. package/src/sections/PracticeQuiz/PracticeQuiz.tsx +106 -74
  248. package/src/sections/PracticeQuiz/types.ts +6 -0
  249. package/src/sections/ProgressDashboard/ProgressDashboard.tsx +64 -10
  250. package/src/sections/ProgressDashboard/types.ts +6 -0
  251. package/src/sections/QuizSession/QuizSession.tsx +71 -82
  252. package/src/sections/QuizSession/types.ts +6 -0
  253. package/src/sections/RequirementsChecklist/RequirementsChecklist.tsx +41 -1
  254. package/src/sections/RequirementsChecklist/types.ts +6 -0
  255. package/src/sections/ResourceLibrary/ResourceLibrary.tsx +64 -8
  256. package/src/sections/ResourceLibrary/types.ts +15 -1
  257. package/src/sections/RubricView/RubricView.tsx +37 -1
  258. package/src/sections/RubricView/types.ts +6 -0
  259. package/src/sections/ScrollableQuiz/ScrollableQuiz.tsx +36 -15
  260. package/src/sections/ScrollableQuiz/types.ts +6 -0
  261. package/src/sections/StudentProfile/StudentProfile.tsx +279 -0
  262. package/src/sections/StudentProfile/types.ts +99 -0
  263. package/src/sections/SurveyForm/SurveyForm.tsx +32 -5
  264. package/src/sections/SurveyForm/types.ts +6 -0
  265. package/src/sections/_shared/merge-answers.ts +22 -0
  266. package/src/sections/_shared/section-shell.tsx +64 -0
  267. package/src/sections/_shared/use-assessment-session.ts +125 -0
  268. package/src/sections/index.ts +22 -0
  269. package/src/social/user-avatar.tsx +9 -5
  270. package/src/styles/globals.css +39 -41
  271. package/src/ui/badge.tsx +8 -0
  272. package/src/ui/index.ts +2 -0
  273. package/src/ui/progress.tsx +4 -0
  274. package/src/ui/rich-text-editor.tsx +10 -0
  275. package/src/ui/rich-text-toolbar.tsx +2 -1
  276. package/src/ui/toast.tsx +170 -0
  277. package/src/utils/debounce.ts +8 -2
  278. package/src/utils/pick-palette-color.ts +33 -0
  279. package/src/video/types.ts +16 -0
  280. package/src/video/video-player.tsx +13 -1
  281. package/dist/ForumBoard-CHXU3mjC.js +0 -2207
  282. package/dist/ForumBoard-d1w5-r6n.cjs +0 -1
  283. package/dist/tabs-DRM2Iq_J.cjs +0 -172
@@ -0,0 +1,343 @@
1
+ import { useState, useMemo } from "react";
2
+ import {
3
+ CheckCircle2,
4
+ XCircle,
5
+ AlertCircle,
6
+ Clock,
7
+ BookOpen,
8
+ Loader2,
9
+ } from "lucide-react";
10
+ import { Stepper } from "../../common/stepper";
11
+ import { Card, CardContent } from "../../ui/card";
12
+ import { Button } from "../../ui/button";
13
+ import { Badge } from "../../ui/badge";
14
+ import { Separator } from "../../ui/separator";
15
+ import { UserAvatar } from "../../social/user-avatar";
16
+ import { Skeleton } from "../../ui/skeleton";
17
+ import { EmptyState } from "../../common/empty-state";
18
+ import { cn } from "../../lib/utils";
19
+ import type { EnrollmentWizardProps } from "./types";
20
+
21
+ type Step = "details" | "prerequisites" | "confirmation";
22
+
23
+ export function EnrollmentWizard({
24
+ course,
25
+ prerequisites,
26
+ onEnroll,
27
+ onCancel,
28
+ enrollLabel = "Enroll Now",
29
+ isEnrolling = false,
30
+ isLoading,
31
+ error,
32
+ onRetry,
33
+ className,
34
+ style,
35
+ }: EnrollmentWizardProps) {
36
+ const hasPrerequisites =
37
+ Array.isArray(prerequisites) && prerequisites.length > 0;
38
+
39
+ const [step, setStep] = useState<Step>("details");
40
+
41
+ /* ------------------------------------------------------------------ */
42
+ /* Stepper setup */
43
+ /* ------------------------------------------------------------------ */
44
+ const allSteps = useMemo<{ key: Step; label: string }[]>(
45
+ () =>
46
+ hasPrerequisites
47
+ ? [
48
+ { key: "details", label: "Course Details" },
49
+ { key: "prerequisites", label: "Prerequisites" },
50
+ { key: "confirmation", label: "Confirmation" },
51
+ ]
52
+ : [
53
+ { key: "details", label: "Course Details" },
54
+ { key: "confirmation", label: "Confirmation" },
55
+ ],
56
+ [hasPrerequisites],
57
+ );
58
+
59
+ /* ------------------------------------------------------------------ */
60
+ /* Loading early return */
61
+ /* ------------------------------------------------------------------ */
62
+ if (isLoading) {
63
+ return (
64
+ <div className={cn("space-y-4", className)} style={style}>
65
+ <Skeleton className="h-8 w-48" />
66
+ <Skeleton className="h-48 w-full" />
67
+ <Skeleton className="h-10 w-32" />
68
+ </div>
69
+ );
70
+ }
71
+
72
+ /* ------------------------------------------------------------------ */
73
+ /* Error early return */
74
+ /* ------------------------------------------------------------------ */
75
+ if (error) {
76
+ return (
77
+ <div className={cn("py-12", className)} style={style}>
78
+ <EmptyState
79
+ icon={<AlertCircle className="size-10 text-destructive" />}
80
+ title="Something went wrong"
81
+ description={error}
82
+ action={
83
+ onRetry ? (
84
+ <Button variant="outline" onClick={onRetry}>
85
+ Retry
86
+ </Button>
87
+ ) : undefined
88
+ }
89
+ />
90
+ </div>
91
+ );
92
+ }
93
+
94
+ const currentStepIndex = allSteps.findIndex((s) => s.key === step);
95
+
96
+ const allPrerequisitesMet =
97
+ !hasPrerequisites || prerequisites!.every((p) => p.isMet);
98
+
99
+ /* ------------------------------------------------------------------ */
100
+ /* Navigation helpers */
101
+ /* ------------------------------------------------------------------ */
102
+ function goNext() {
103
+ const nextIndex = currentStepIndex + 1;
104
+ if (nextIndex < allSteps.length) {
105
+ setStep(allSteps[nextIndex].key);
106
+ }
107
+ }
108
+
109
+ function goBack() {
110
+ const prevIndex = currentStepIndex - 1;
111
+ if (prevIndex >= 0) {
112
+ setStep(allSteps[prevIndex].key);
113
+ }
114
+ }
115
+
116
+ /* ------------------------------------------------------------------ */
117
+ /* Details step */
118
+ /* ------------------------------------------------------------------ */
119
+ function renderDetails() {
120
+ return (
121
+ <Card>
122
+ <CardContent className="pt-6">
123
+ {/* Thumbnail or initial placeholder */}
124
+ {course.thumbnailUrl ? (
125
+ <img
126
+ src={course.thumbnailUrl}
127
+ alt={course.title}
128
+ className="w-full h-48 object-cover rounded-lg mb-4"
129
+ />
130
+ ) : (
131
+ <div className="w-full h-48 rounded-lg mb-4 bg-muted flex items-center justify-center">
132
+ <BookOpen className="size-12 text-muted-foreground" />
133
+ </div>
134
+ )}
135
+
136
+ {/* Title */}
137
+ <h2 className="text-xl font-bold text-foreground mb-1">
138
+ {course.title}
139
+ </h2>
140
+
141
+ {/* Description */}
142
+ {course.description && (
143
+ <p className="text-sm text-muted-foreground mb-3">
144
+ {course.description}
145
+ </p>
146
+ )}
147
+
148
+ {/* Instructor + duration row */}
149
+ <div className="flex items-center gap-3 mb-3">
150
+ {course.instructor && (
151
+ <div className="flex items-center gap-2">
152
+ <UserAvatar
153
+ displayName={course.instructor.displayName}
154
+ avatarUrl={course.instructor.avatarUrl}
155
+ size="small"
156
+ />
157
+ <span className="text-sm text-foreground">
158
+ {course.instructor.displayName}
159
+ </span>
160
+ </div>
161
+ )}
162
+ {course.duration && (
163
+ <Badge variant="muted">
164
+ <Clock className="size-3 mr-1" />
165
+ {course.duration}
166
+ </Badge>
167
+ )}
168
+ </div>
169
+
170
+ {/* Syllabus */}
171
+ {course.syllabus && course.syllabus.length > 0 && (
172
+ <>
173
+ <Separator className="my-3" />
174
+ <div className="mb-1 text-sm font-semibold text-foreground">
175
+ Syllabus
176
+ </div>
177
+ <ul className="list-disc list-inside space-y-1">
178
+ {course.syllabus.map((item, i) => (
179
+ <li key={i} className="text-sm text-muted-foreground">
180
+ {item}
181
+ </li>
182
+ ))}
183
+ </ul>
184
+ </>
185
+ )}
186
+
187
+ {/* Actions */}
188
+ <Separator className="my-4" />
189
+ <div className="flex justify-end gap-2">
190
+ {onCancel && (
191
+ <Button variant="outline" onClick={onCancel}>
192
+ Cancel
193
+ </Button>
194
+ )}
195
+ <Button onClick={goNext}>Continue</Button>
196
+ </div>
197
+ </CardContent>
198
+ </Card>
199
+ );
200
+ }
201
+
202
+ /* ------------------------------------------------------------------ */
203
+ /* Prerequisites step */
204
+ /* ------------------------------------------------------------------ */
205
+ function renderPrerequisites() {
206
+ return (
207
+ <Card>
208
+ <CardContent className="pt-6">
209
+ <h3 className="text-lg font-semibold text-foreground mb-3">
210
+ Prerequisites
211
+ </h3>
212
+
213
+ <ul className="space-y-3">
214
+ {prerequisites!.map((prereq) => (
215
+ <li key={prereq.uid} className="flex items-start gap-2">
216
+ {prereq.isMet ? (
217
+ <CheckCircle2 className="size-4 text-success shrink-0 mt-0.5" />
218
+ ) : (
219
+ <XCircle className="size-4 text-destructive shrink-0 mt-0.5" />
220
+ )}
221
+ <div>
222
+ <span className="text-sm font-medium text-foreground">
223
+ {prereq.label}
224
+ </span>
225
+ {prereq.description && (
226
+ <p className="text-xs text-muted-foreground mt-0.5">
227
+ {prereq.description}
228
+ </p>
229
+ )}
230
+ </div>
231
+ </li>
232
+ ))}
233
+ </ul>
234
+
235
+ <Separator className="my-4" />
236
+ <div className="flex justify-end gap-2">
237
+ <Button variant="outline" onClick={goBack}>
238
+ Back
239
+ </Button>
240
+ <Button onClick={goNext} disabled={!allPrerequisitesMet}>
241
+ Continue
242
+ </Button>
243
+ </div>
244
+ </CardContent>
245
+ </Card>
246
+ );
247
+ }
248
+
249
+ /* ------------------------------------------------------------------ */
250
+ /* Confirmation step */
251
+ /* ------------------------------------------------------------------ */
252
+ function renderConfirmation() {
253
+ return (
254
+ <Card>
255
+ <CardContent className="pt-6">
256
+ <h3 className="text-lg font-semibold text-foreground mb-3">
257
+ Confirm Enrollment
258
+ </h3>
259
+
260
+ {/* Summary */}
261
+ <div className="space-y-2 mb-3">
262
+ <div className="flex items-center gap-2">
263
+ <BookOpen className="size-4 text-muted-foreground" />
264
+ <span className="text-sm font-medium text-foreground">
265
+ {course.title}
266
+ </span>
267
+ </div>
268
+ {course.instructor && (
269
+ <div className="flex items-center gap-2">
270
+ <UserAvatar
271
+ displayName={course.instructor.displayName}
272
+ avatarUrl={course.instructor.avatarUrl}
273
+ size="small"
274
+ />
275
+ <span className="text-sm text-muted-foreground">
276
+ {course.instructor.displayName}
277
+ </span>
278
+ </div>
279
+ )}
280
+ {course.duration && (
281
+ <div className="flex items-center gap-2">
282
+ <Clock className="size-4 text-muted-foreground" />
283
+ <span className="text-sm text-muted-foreground">
284
+ {course.duration}
285
+ </span>
286
+ </div>
287
+ )}
288
+ </div>
289
+
290
+ {/* Prerequisites badge */}
291
+ {hasPrerequisites && (
292
+ <div className="mb-3">
293
+ {allPrerequisitesMet ? (
294
+ <Badge variant="success">
295
+ <CheckCircle2 className="size-3 mr-1" />
296
+ All prerequisites met
297
+ </Badge>
298
+ ) : (
299
+ <Badge variant="warning">
300
+ <XCircle className="size-3 mr-1" />
301
+ Some prerequisites not met
302
+ </Badge>
303
+ )}
304
+ </div>
305
+ )}
306
+
307
+ <Separator className="my-4" />
308
+ <div className="flex justify-end gap-2">
309
+ <Button variant="outline" onClick={goBack}>
310
+ Back
311
+ </Button>
312
+ <Button
313
+ onClick={() => onEnroll(course.uid)}
314
+ disabled={isEnrolling}
315
+ >
316
+ {isEnrolling && (
317
+ <Loader2 className="size-4 mr-1 animate-spin" />
318
+ )}
319
+ {enrollLabel}
320
+ </Button>
321
+ </div>
322
+ </CardContent>
323
+ </Card>
324
+ );
325
+ }
326
+
327
+ /* ------------------------------------------------------------------ */
328
+ /* Render */
329
+ /* ------------------------------------------------------------------ */
330
+ return (
331
+ <div className={className} style={style}>
332
+ <Stepper
333
+ steps={allSteps.map((s) => ({ label: s.label }))}
334
+ currentStep={currentStepIndex}
335
+ className="mb-6"
336
+ />
337
+
338
+ {step === "details" && renderDetails()}
339
+ {step === "prerequisites" && renderPrerequisites()}
340
+ {step === "confirmation" && renderConfirmation()}
341
+ </div>
342
+ );
343
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * EnrollmentWizard section — a multi-step course enrollment flow.
3
+ *
4
+ * Guides users through course details, prerequisite checks, and enrollment
5
+ * confirmation with visual step progress.
6
+ *
7
+ * @example
8
+ * <EnrollmentWizard
9
+ * course={{ uid: "c1", title: "React 101", description: "Learn React" }}
10
+ * prerequisites={[{ uid: "p1", label: "JavaScript Basics", isMet: true }]}
11
+ * onEnroll={(courseUid) => enroll(courseUid)}
12
+ * />
13
+ */
14
+ export interface EnrollmentWizardProps {
15
+ /** Course information to display */
16
+ course: EnrollmentCourse;
17
+ /** Prerequisites for enrollment */
18
+ prerequisites?: Prerequisite[];
19
+ /** Called when the user confirms enrollment */
20
+ onEnroll: (courseUid: string) => void;
21
+ /** Called when the user cancels the wizard */
22
+ onCancel?: () => void;
23
+ /** Label for the enroll button */
24
+ enrollLabel?: string;
25
+ /** Whether enrollment is in progress (shows spinner) */
26
+ isEnrolling?: boolean;
27
+ /** Render skeleton placeholders instead of content */
28
+ isLoading?: boolean;
29
+ /** Error message — renders an error state with optional retry */
30
+ error?: string | null;
31
+ /** Called when the user clicks retry in the error state */
32
+ onRetry?: () => void;
33
+ /** CSS class name for the root element */
34
+ className?: string;
35
+ /** Inline styles for the root element */
36
+ style?: React.CSSProperties;
37
+ }
38
+
39
+ export interface EnrollmentCourse {
40
+ /** Course UID */
41
+ uid: string;
42
+ /** Course title */
43
+ title: string;
44
+ /** Course description */
45
+ description?: string;
46
+ /** Thumbnail image URL */
47
+ thumbnailUrl?: string;
48
+ /** Instructor info */
49
+ instructor?: { displayName: string; avatarUrl?: string };
50
+ /** Estimated duration */
51
+ duration?: string;
52
+ /** Syllabus bullet points */
53
+ syllabus?: string[];
54
+ }
55
+
56
+ export interface Prerequisite {
57
+ /** Unique identifier */
58
+ uid: string;
59
+ /** Prerequisite label */
60
+ label: string;
61
+ /** Whether this prerequisite has been met */
62
+ isMet: boolean;
63
+ /** Optional description */
64
+ description?: string;
65
+ }
@@ -1,16 +1,17 @@
1
- import { useEffect, useMemo, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { AssessmentToolbar, QuestionHeaderBar, QuestionMaterialsDrawer } from "../../assessment-toolbar";
3
- import type { QuestionNavigatorItem } from "../../assessment-toolbar/types";
4
- import { QuestionRenderer } from "../../questions";
5
- import type { SessionAnswer } from "../../questions/types";
6
3
  import { ConfirmDialog } from "../../common";
4
+ import { QuestionRenderer } from "../../questions";
7
5
  import { Alert, AlertDescription } from "../../ui/alert";
8
6
  import { Card, CardHeader, CardContent } from "../../ui/card";
9
- import { cn } from "../../lib/utils";
7
+ import { Skeleton } from "../../ui/skeleton";
8
+
9
+ import { useAssessmentSession } from "../_shared/use-assessment-session";
10
+ import { SectionShell } from "../_shared/section-shell";
10
11
  import type { ExamSessionProps, ExamSubmitMetadata } from "./types";
11
12
 
12
13
  export function ExamSession({
13
- questions,
14
+ questions = [],
14
15
  initialAnswers = [],
15
16
  onSubmit,
16
17
  onAnswerChange,
@@ -25,32 +26,63 @@ export function ExamSession({
25
26
  questionMaterials,
26
27
  isSubmitting = false,
27
28
  readOnly = false,
29
+ isLoading,
30
+ error,
31
+ onRetry,
28
32
  className,
29
33
  style,
30
34
  }: ExamSessionProps) {
31
- const [currentIndex, setCurrentIndex] = useState(0);
32
- const [sessionAnswers, setSessionAnswers] = useState<SessionAnswer[]>(initialAnswers);
33
- const [flaggedUids, setFlaggedUids] = useState<Set<string>>(new Set());
35
+ const {
36
+ currentIndex,
37
+ currentQuestion,
38
+ sessionAnswers,
39
+ flaggedUids,
40
+ materialsOpen,
41
+ setMaterialsOpen,
42
+ questionAreaRef,
43
+ currentQuestionAnswers,
44
+ currentMaterials,
45
+ navigatorItems,
46
+ handleAnswer,
47
+ handleNavigate,
48
+ handleToggleFlag,
49
+ goNext,
50
+ goPrevious,
51
+ hasNext,
52
+ } = useAssessmentSession({ questions, initialAnswers, onAnswerChange, questionMaterials });
53
+
34
54
  const [showConfirm, setShowConfirm] = useState(false);
35
55
  const [showTimeWarning, setShowTimeWarning] = useState(false);
36
- const [materialsOpen, setMaterialsOpen] = useState(false);
37
56
  const hasAutoSubmitted = useRef(false);
38
57
 
39
- const currentQuestion = questions[currentIndex];
58
+ // Refs for stable callbacks
59
+ const sessionAnswersRef = useRef(sessionAnswers);
60
+ sessionAnswersRef.current = sessionAnswers;
61
+ const onSubmitRef = useRef(onSubmit);
62
+ onSubmitRef.current = onSubmit;
63
+ const timeElapsedSecondsRef = useRef(timeElapsedSeconds);
64
+ timeElapsedSecondsRef.current = timeElapsedSeconds;
65
+ const currentQuestionUidRef = useRef(currentQuestion?.uid);
66
+ currentQuestionUidRef.current = currentQuestion?.uid;
67
+
40
68
  const remainingSeconds = timeLimitSeconds - timeElapsedSeconds;
41
69
 
42
- const currentQuestionAnswers = useMemo(
43
- () =>
44
- currentQuestion
45
- ? sessionAnswers.filter((a) => a.uid === currentQuestion.uid)
46
- : [],
47
- [sessionAnswers, currentQuestion],
70
+ const answeredCount = useMemo(
71
+ () => navigatorItems.filter((q) => q.isAnswered).length,
72
+ [navigatorItems],
48
73
  );
49
74
 
50
- const currentMaterials = useMemo(
51
- () => questionMaterials?.filter((m) => m.questionUid === currentQuestion?.uid) ?? [],
52
- [questionMaterials, currentQuestion],
53
- );
75
+ const doSubmit = useCallback((wasAutoSubmitted: boolean) => {
76
+ const answers = sessionAnswersRef.current;
77
+ const answeredUids = new Set(answers.map((a) => a.uid));
78
+ const metadata: ExamSubmitMetadata = {
79
+ timeElapsedSeconds: timeElapsedSecondsRef.current,
80
+ wasAutoSubmitted,
81
+ answeredCount: questions.filter((q) => answeredUids.has(q.uid)).length,
82
+ totalQuestions: questions.length,
83
+ };
84
+ onSubmitRef.current(answers, metadata);
85
+ }, [questions]);
54
86
 
55
87
  useEffect(() => {
56
88
  if (remainingSeconds <= timeWarningThreshold && remainingSeconds > 0) {
@@ -63,77 +95,50 @@ export function ExamSession({
63
95
  hasAutoSubmitted.current = true;
64
96
  doSubmit(true);
65
97
  }
66
- // eslint-disable-next-line react-hooks/exhaustive-deps
67
- }, [remainingSeconds, autoSubmitOnTimeout]);
68
-
69
- const navigatorItems = useMemo<QuestionNavigatorItem[]>(
70
- () =>
71
- questions.map((q, idx) => ({
72
- uid: q.uid,
73
- sequence: idx,
74
- isFlagged: flaggedUids.has(q.uid),
75
- isAnswered: sessionAnswers.some((a) => a.uid === q.uid),
76
- isSkipped: false,
77
- })),
78
- [questions, sessionAnswers, flaggedUids],
79
- );
98
+ }, [remainingSeconds, autoSubmitOnTimeout, doSubmit]);
80
99
 
81
- const answeredCount = useMemo(
82
- () => navigatorItems.filter((q) => q.isAnswered).length,
83
- [navigatorItems],
84
- );
85
-
86
- function handleAnswer(rawAnswers: { uid: string; content?: string }[]) {
87
- if (!currentQuestion) return;
88
- const questionUid = currentQuestion.uid;
89
- const newAnswers: SessionAnswer[] = rawAnswers.map((a) => ({
90
- uid: questionUid,
91
- answerUid: a.uid,
92
- content: a.content,
93
- }));
94
- setSessionAnswers((prev) => {
95
- const filtered = prev.filter((a) => a.uid !== questionUid);
96
- const merged = [...filtered, ...newAnswers];
97
- onAnswerChange?.(merged);
98
- return merged;
99
- });
100
- }
101
-
102
- function handleNavigate(uid: string) {
103
- const idx = questions.findIndex((q) => q.uid === uid);
104
- if (idx !== -1) setCurrentIndex(idx);
105
- }
106
-
107
- function handleToggleFlag(uid: string) {
108
- setFlaggedUids((prev) => {
109
- const next = new Set(prev);
110
- if (next.has(uid)) next.delete(uid);
111
- else next.add(uid);
112
- return next;
113
- });
114
- }
115
-
116
- function handleSubmitClick() {
100
+ const handleSubmitClick = useCallback(() => {
117
101
  if (confirmBeforeSubmit) {
118
102
  setShowConfirm(true);
119
103
  } else {
120
104
  doSubmit(false);
121
105
  }
122
- }
106
+ }, [confirmBeforeSubmit, doSubmit]);
123
107
 
124
- function doSubmit(wasAutoSubmitted: boolean) {
125
- const answeredUids = new Set(sessionAnswers.map((a) => a.uid));
126
- const metadata: ExamSubmitMetadata = {
127
- timeElapsedSeconds,
128
- wasAutoSubmitted,
129
- answeredCount: questions.filter((q) => answeredUids.has(q.uid)).length,
130
- totalQuestions: questions.length,
131
- };
132
- onSubmit(sessionAnswers, metadata);
133
- }
108
+ const handlePrevious = useCallback(() => {
109
+ if (allowBackNavigation) goPrevious();
110
+ }, [allowBackNavigation, goPrevious]);
111
+
112
+ const toggleCurrentFlag = useCallback(() => {
113
+ const uid = currentQuestionUidRef.current;
114
+ if (uid) handleToggleFlag(uid);
115
+ }, [handleToggleFlag]);
116
+
117
+ const openMaterials = useCallback(() => setMaterialsOpen(true), [setMaterialsOpen]);
118
+
119
+ const confirmSubmit = useCallback(() => {
120
+ setShowConfirm(false);
121
+ doSubmit(false);
122
+ }, [doSubmit]);
123
+
124
+ const cancelConfirm = useCallback(() => setShowConfirm(false), []);
134
125
 
135
126
  return (
136
- <div className={cn(className)} style={style}>
127
+ <SectionShell
128
+ isLoading={isLoading}
129
+ error={error}
130
+ onRetry={onRetry}
131
+ className={className}
132
+ style={style}
133
+ skeleton={
134
+ <>
135
+ <Skeleton className="h-10 w-full" />
136
+ <Skeleton className="h-48 w-full" />
137
+ <Skeleton className="h-12 w-full" />
138
+ </>
139
+ }
140
+ >
141
+ <div>
137
142
  {examTitle && (
138
143
  <p className="text-xl font-bold mb-2 text-foreground">{examTitle}</p>
139
144
  )}
@@ -149,10 +154,10 @@ export function ExamSession({
149
154
  <AssessmentToolbar
150
155
  currentQuestionIndex={currentIndex}
151
156
  totalQuestions={questions.length}
152
- hasNext={currentIndex < questions.length - 1}
157
+ hasNext={hasNext}
153
158
  hasPrevious={allowBackNavigation && currentIndex > 0}
154
- onNext={() => setCurrentIndex((i) => Math.min(i + 1, questions.length - 1))}
155
- onPrevious={() => allowBackNavigation && setCurrentIndex((i) => Math.max(i - 1, 0))}
159
+ onNext={goNext}
160
+ onPrevious={handlePrevious}
156
161
  onSubmit={handleSubmitClick}
157
162
  timeElapsedSeconds={timeElapsedSeconds}
158
163
  timeLimitSeconds={timeLimitSeconds}
@@ -169,16 +174,19 @@ export function ExamSession({
169
174
  </Card>
170
175
  )}
171
176
 
177
+ <span className="sr-only" aria-live="polite">
178
+ Question {currentIndex + 1} of {questions.length}
179
+ </span>
172
180
  {currentQuestion && (
173
- <Card className="mt-3">
181
+ <Card className="mt-3" ref={questionAreaRef} tabIndex={-1}>
174
182
  <CardHeader className="pb-0">
175
183
  <QuestionHeaderBar
176
184
  questionNumber={currentIndex + 1}
177
185
  totalQuestions={questions.length}
178
186
  isFlagged={flaggedUids.has(currentQuestion.uid)}
179
- onToggleFlag={() => handleToggleFlag(currentQuestion.uid)}
187
+ onToggleFlag={toggleCurrentFlag}
180
188
  hasMaterials={currentMaterials.length > 0}
181
- onOpenMaterials={() => setMaterialsOpen(true)}
189
+ onOpenMaterials={openMaterials}
182
190
  readOnly={readOnly}
183
191
  />
184
192
  </CardHeader>
@@ -207,13 +215,11 @@ export function ExamSession({
207
215
  confirmLabel="Submit Exam"
208
216
  cancelLabel="Continue Exam"
209
217
  confirmColor="primary"
210
- onConfirm={() => {
211
- setShowConfirm(false);
212
- doSubmit(false);
213
- }}
214
- onCancel={() => setShowConfirm(false)}
218
+ onConfirm={confirmSubmit}
219
+ onCancel={cancelConfirm}
215
220
  isLoading={isSubmitting}
216
221
  />
217
222
  </div>
223
+ </SectionShell>
218
224
  );
219
225
  }
@@ -51,6 +51,12 @@ export interface ExamSessionProps {
51
51
  questionMaterials?: QuestionMaterial[];
52
52
  /** When true, all inputs are disabled */
53
53
  readOnly?: boolean;
54
+ /** Render skeleton placeholders instead of content */
55
+ isLoading?: boolean;
56
+ /** Error message — renders an error state with optional retry */
57
+ error?: string | null;
58
+ /** Called when the user clicks retry in the error state */
59
+ onRetry?: () => void;
54
60
  /** CSS class name for the root element */
55
61
  className?: string;
56
62
  /** Inline styles for the root element */